| 1 | package sync |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "strings" |
| 6 | |
| 7 | "go.bigb.es/auxilia/culpa" |
| 8 | "go.bigb.es/auxilia/legatus" |
| 9 | ) |
| 10 | |
| 11 | // ErrRemoteDirty is the sentinel raised by GitGuard when the remote repo has |
| 12 | // uncommitted changes that would be clobbered by a push. |
| 13 | var ErrRemoteDirty = culpa.WithCode( |
| 14 | culpa.New("sync: remote git repo has uncommitted changes"), |
| 15 | "SYNC_REMOTE_DIRTY", |
| 16 | ) |
| 17 | |
| 18 | // EnsureRepo initializes a git repository at remoteRoot if one does not already |
| 19 | // exist (idempotent). A missing directory or a git failure raises. |
| 20 | func EnsureRepo(ctx context.Context, conn legatus.Conn, remoteRoot string) error { |
| 21 | exists, err := repoExists(ctx, conn, remoteRoot) |
| 22 | if err != nil { |
| 23 | return err |
| 24 | } |
| 25 | if exists { |
| 26 | return nil |
| 27 | } |
| 28 | script := "mkdir -p " + shellQuote(remoteRoot) + |
| 29 | " && cd " + shellQuote(remoteRoot) + |
| 30 | " && git init" |
| 31 | if _, err := runShell(ctx, conn, "", script); err != nil { |
| 32 | return culpa.Wrap(err, "sync: ensure repo") |
| 33 | } |
| 34 | return nil |
| 35 | } |
| 36 | |
| 37 | // IsDirty reports whether the remote repo has uncommitted changes, returning the |
| 38 | // porcelain status. A non-zero git exit (e.g. not a repository) raises rather |
| 39 | // than being swallowed as "clean". |
| 40 | func IsDirty(ctx context.Context, conn legatus.Conn, remoteRoot string) (bool, string, error) { |
| 41 | status, err := runShell(ctx, conn, remoteRoot, "git status --porcelain") |
| 42 | if err != nil { |
| 43 | return false, "", culpa.Wrap(err, "sync: git status") |
| 44 | } |
| 45 | status = strings.TrimSpace(status) |
| 46 | return status != "", status, nil |
| 47 | } |
| 48 | |
| 49 | // Commit stages all changes and commits them with message. It is a no-op when |
| 50 | // there is nothing to commit. |
| 51 | func Commit(ctx context.Context, conn legatus.Conn, remoteRoot, message string) error { |
| 52 | script := "git add -A && (git diff --cached --quiet || git commit -m " + |
| 53 | shellQuote(message) + ")" |
| 54 | if _, err := runShell(ctx, conn, remoteRoot, script); err != nil { |
| 55 | return culpa.Wrap(err, "sync: git commit") |
| 56 | } |
| 57 | return nil |
| 58 | } |
| 59 | |
| 60 | // CommitOrRollback runs apply(); on success it commits okMsg, on failure it |
| 61 | // commits failMsg and returns the original apply error — so the remote repo is |
| 62 | // left committed (never dirty) regardless of how apply fails, and the next push |
| 63 | // is not blocked by uncommitted drift. A failure of the rollback commit itself is |
| 64 | // wrapped onto the apply error, never swallowed (GPC6). |
| 65 | func CommitOrRollback(ctx context.Context, conn legatus.Conn, root, okMsg, failMsg string, apply func() error) error { |
| 66 | if aerr := apply(); aerr != nil { |
| 67 | if cerr := Commit(ctx, conn, root, failMsg); cerr != nil { |
| 68 | return culpa.Wrapf(aerr, "sync: apply failed and rollback commit also failed: %v", cerr) |
| 69 | } |
| 70 | return aerr |
| 71 | } |
| 72 | return Commit(ctx, conn, root, okMsg) |
| 73 | } |
| 74 | |
| 75 | // Status returns a short human-readable summary of the remote repo state: |
| 76 | // "no repo", "clean", or "dirty (<n> changes)". |
| 77 | func Status(ctx context.Context, conn legatus.Conn, remoteRoot string) (string, error) { |
| 78 | exists, err := repoExists(ctx, conn, remoteRoot) |
| 79 | if err != nil { |
| 80 | return "", err |
| 81 | } |
| 82 | if !exists { |
| 83 | return "no repo", nil |
| 84 | } |
| 85 | dirty, status, err := IsDirty(ctx, conn, remoteRoot) |
| 86 | if err != nil { |
| 87 | return "", err |
| 88 | } |
| 89 | if !dirty { |
| 90 | return "clean", nil |
| 91 | } |
| 92 | n := len(strings.Split(status, "\n")) |
| 93 | return "dirty (" + itoa(n) + " changes)", nil |
| 94 | } |
| 95 | |
| 96 | func repoExists(ctx context.Context, conn legatus.Conn, remoteRoot string) (bool, error) { |
| 97 | out, err := runShell(ctx, conn, "", |
| 98 | "test -d "+shellQuote(remoteRoot+"/.git")+" && echo yes || echo no") |
| 99 | if err != nil { |
| 100 | return false, culpa.Wrap(err, "sync: check repo") |
| 101 | } |
| 102 | return strings.TrimSpace(out) == "yes", nil |
| 103 | } |
| 104 | |
| 105 | func itoa(n int) string { |
| 106 | if n == 0 { |
| 107 | return "0" |
| 108 | } |
| 109 | var buf [20]byte |
| 110 | i := len(buf) |
| 111 | neg := n < 0 |
| 112 | if neg { |
| 113 | n = -n |
| 114 | } |
| 115 | for n > 0 { |
| 116 | i-- |
| 117 | buf[i] = byte('0' + n%10) |
| 118 | n /= 10 |
| 119 | } |
| 120 | if neg { |
| 121 | i-- |
| 122 | buf[i] = '-' |
| 123 | } |
| 124 | return string(buf[i:]) |
| 125 | } |
| 126 | |
| 127 | // GitGuard is a gate step: it blocks the flow when the remote git repo is dirty, |
| 128 | // preventing a push from clobbering uncommitted remote drift. Check reports |
| 129 | // need=true when dirty; Apply raises ErrRemoteDirty. |
| 130 | type GitGuard struct { |
| 131 | // RemoteRoot is the remote repository directory. |
| 132 | RemoteRoot string |
| 133 | } |
| 134 | |
| 135 | // Name identifies the step. |
| 136 | func (g *GitGuard) Name() string { return "sync.git-guard" } |
| 137 | |
| 138 | // Check reports whether the remote is dirty (needs attention). |
| 139 | func (g *GitGuard) Check(fc *legatus.FlowCtx) (bool, error) { |
| 140 | dirty, _, err := IsDirty(fc.Ctx(), fc.Conn(), g.RemoteRoot) |
| 141 | if err != nil { |
| 142 | return false, err |
| 143 | } |
| 144 | return dirty, nil |
| 145 | } |
| 146 | |
| 147 | // Apply raises if the remote is dirty; otherwise reports a clean, no-change |
| 148 | // outcome. |
| 149 | func (g *GitGuard) Apply(fc *legatus.FlowCtx) (legatus.Outcome, error) { |
| 150 | dirty, status, err := IsDirty(fc.Ctx(), fc.Conn(), g.RemoteRoot) |
| 151 | if err != nil { |
| 152 | return legatus.Outcome{}, err |
| 153 | } |
| 154 | if dirty { |
| 155 | return legatus.Outcome{}, culpa.Wrapf(ErrRemoteDirty, "sync: %s\n%s", g.RemoteRoot, status) |
| 156 | } |
| 157 | return legatus.Outcome{Changed: false, Message: "remote clean"}, nil |
| 158 | } |
| 159 | |