| 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 | |