sync.go

v0.6.0
Doc Versions Source
1
// Package sync provides legatus steps for keeping a local file tree and a
2
// remote host in agreement: Push (upload changed files), Pull (download with
3
// per-file conflict resolution), Diff (read-only unified diff), and a git-drift
4
// battery (EnsureRepo, IsDirty, Commit, Status, plus a GitGuard gate step).
5
//
6
// All remote work goes through legatus.Conn. Every interactive decision goes
7
// through the FlowCtx prompter (PC2); the non-interactive default declines, so
8
// destructive paths (delete, conflict overwrite) skip rather than force.
9
package sync
10
11
import (
12
	"bytes"
13
	"context"
14
	"path"
15
	"strings"
16
17
	"go.bigb.es/auxilia/culpa"
18
	"go.bigb.es/auxilia/legatus"
19
)
20
21
// shellQuote single-quotes s for safe interpolation into a /bin/sh -c script.
22
func shellQuote(s string) string {
23
	return "'" + strings.ReplaceAll(s, "'", `'"'"'`) + "'"
24
}
25
26
// runShell executes script under `sh -c` in the given remote directory and
27
// returns trimmed stdout. A non-zero exit (or transport error) raises (PC: no
28
// swallowed failures).
29
func runShell(ctx context.Context, conn legatus.Conn, dir, script string) (string, error) {
30
	full := script
31
	if dir != "" {
32
		full = "cd " + shellQuote(dir) + " && " + script
33
	}
34
	res, err := conn.Exec(ctx, legatus.Cmd{Path: "sh", Args: []string{"-c", full}})
35
	if err != nil {
36
		return "", culpa.Wrapf(err, "sync: exec %q", script)
37
	}
38
	if res.ExitCode != 0 {
39
		return "", culpa.Errorf("sync: command %q failed (exit %d): %s",
40
			script, res.ExitCode, strings.TrimSpace(string(res.Combined())))
41
	}
42
	return strings.TrimSpace(string(res.Stdout)), nil
43
}
44
45
// remoteSHA256 returns the lowercase hex sha256 of the remote file, or "" if the
46
// file does not exist. A transport/exec error raises.
47
func remoteSHA256(ctx context.Context, conn legatus.Conn, remote string) (string, error) {
48
	// `sha256sum` prints "<hex>  <path>"; if the file is absent it exits
49
	// non-zero, which we deliberately turn into an empty hash (missing remote),
50
	// not an error. The `|| true` keeps the shell exit at 0 so a real exec
51
	// failure is still distinguishable.
52
	script := "sha256sum " + shellQuote(remote) + " 2>/dev/null || true"
53
	res, err := conn.Exec(ctx, legatus.Cmd{Path: "sh", Args: []string{"-c", script}})
54
	if err != nil {
55
		return "", culpa.Wrapf(err, "sync: hash remote %s", remote)
56
	}
57
	if res.ExitCode != 0 {
58
		return "", culpa.Errorf("sync: hash remote %s failed (exit %d): %s",
59
			remote, res.ExitCode, strings.TrimSpace(string(res.Combined())))
60
	}
61
	out := strings.TrimSpace(string(res.Stdout))
62
	if out == "" {
63
		return "", nil
64
	}
65
	return strings.Fields(out)[0], nil
66
}
67
68
// downloadRemote reads the full content of a remote file into memory.
69
func downloadRemote(ctx context.Context, conn legatus.Conn, remote string) ([]byte, error) {
70
	var buf bytes.Buffer
71
	if err := conn.Get(ctx, remote, &buf); err != nil {
72
		return nil, culpa.Wrapf(err, "sync: download %s", remote)
73
	}
74
	return buf.Bytes(), nil
75
}
76
77
// listRemoteFiles returns paths relative to root for every regular file under
78
// the remote directory. Returns nil if the directory does not exist.
79
func listRemoteFiles(ctx context.Context, conn legatus.Conn, root string) ([]string, error) {
80
	script := "find " + shellQuote(root) + " -type f 2>/dev/null || true"
81
	res, err := conn.Exec(ctx, legatus.Cmd{Path: "sh", Args: []string{"-c", script}})
82
	if err != nil {
83
		return nil, culpa.Wrapf(err, "sync: list remote %s", root)
84
	}
85
	if res.ExitCode != 0 {
86
		return nil, culpa.Errorf("sync: list remote %s failed (exit %d): %s",
87
			root, res.ExitCode, strings.TrimSpace(string(res.Combined())))
88
	}
89
	var rels []string
90
	for _, line := range strings.Split(strings.TrimSpace(string(res.Stdout)), "\n") {
91
		line = strings.TrimSpace(line)
92
		if line == "" {
93
			continue
94
		}
95
		rel := line
96
		if r, err := relPath(root, line); err == nil {
97
			rel = r
98
		}
99
		rels = append(rels, rel)
100
	}
101
	return rels, nil
102
}
103
104
// relPath returns the slash path of target relative to base.
105
func relPath(base, target string) (string, error) {
106
	base = path.Clean(base)
107
	target = path.Clean(target)
108
	if base == target {
109
		return ".", nil
110
	}
111
	prefix := base + "/"
112
	if !strings.HasPrefix(target, prefix) {
113
		return "", culpa.Errorf("sync: %s not under %s", target, base)
114
	}
115
	return strings.TrimPrefix(target, prefix), nil
116
}
117

Source Files