git.go

v0.6.0
Doc Versions Source
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

Source Files