diff.go

v0.6.0
Doc Versions Source
1
package sync
2
3
import (
4
	"os"
5
	"path"
6
	"path/filepath"
7
	"sort"
8
	"strings"
9
10
	"go.bigb.es/auxilia/culpa"
11
	"go.bigb.es/auxilia/legatus"
12
)
13
14
// Diff is a read-only step: it downloads each remote file, compares it to its
15
// local counterpart, and reports a unified diff. It never writes anywhere —
16
// Outcome.Changed is always false (PC: idempotent, observation-only). The
17
// rendered diff is in Outcome.Data (string); empty string means no differences.
18
type Diff struct {
19
	// Files are individual paths relative to LocalRoot/RemoteRoot. Exclude does
20
	// not apply to these: naming a file explicitly is the intent.
21
	Files []string
22
	// Dirs are directory paths (relative) compared recursively.
23
	Dirs []string
24
	// Exclude holds gitignore-style patterns (see matchIgnore) scoping the Dirs
25
	// walk, so local build artifacts and ignored state are not reported as drift.
26
	Exclude []string
27
	// LocalRoot is the base of the local tree.
28
	LocalRoot string
29
	// RemoteRoot is the base of the remote tree.
30
	RemoteRoot string
31
	// Normalize, when set, is applied to both local and remote bytes before the
32
	// equality test, so a JSON-canonicalizer or whitespace-normalizer makes the
33
	// comparison semantic. The rendered diff is of the normalized forms.
34
	Normalize func([]byte) ([]byte, error)
35
	// Summary, when set, makes Apply report a content-free per-file verdict
36
	// ("rel: same" / "rel: differs") instead of a unified diff — so secret-bearing
37
	// files can be diffed for change-detection without printing their content.
38
	Summary bool
39
}
40
41
// Name identifies the step.
42
func (d *Diff) Name() string { return "sync.diff" }
43
44
// fileStatus is one file's comparison result. diff holds the rendered unified
45
// diff (empty in Summary mode or when the file matches).
46
type fileStatus struct {
47
	rel     string
48
	differs bool
49
	diff    string
50
}
51
52
// Check reports whether any file differs.
53
func (d *Diff) Check(fc *legatus.FlowCtx) (bool, error) {
54
	sts, err := d.statuses(fc)
55
	if err != nil {
56
		return false, err
57
	}
58
	for _, s := range sts {
59
		if s.differs {
60
			return true, nil
61
		}
62
	}
63
	return false, nil
64
}
65
66
// Apply renders the comparison and reports it. It is read-only: Changed is false.
67
// In Summary mode the Data is a content-free per-file verdict; otherwise it is
68
// the concatenated unified diffs of the differing files.
69
func (d *Diff) Apply(fc *legatus.FlowCtx) (legatus.Outcome, error) {
70
	sts, err := d.statuses(fc)
71
	if err != nil {
72
		return legatus.Outcome{}, err
73
	}
74
	var b strings.Builder
75
	any := false
76
	for _, s := range sts {
77
		if s.differs {
78
			any = true
79
		}
80
		switch {
81
		case d.Summary:
82
			verdict := "same"
83
			if s.differs {
84
				verdict = "differs"
85
			}
86
			b.WriteString(s.rel + ": " + verdict + "\n")
87
		case s.differs:
88
			b.WriteString(s.diff)
89
		}
90
	}
91
	msg := "no differences"
92
	if any {
93
		msg = "differences found"
94
	}
95
	return legatus.Outcome{Changed: false, Message: msg, Data: b.String()}, nil
96
}
97
98
func (d *Diff) statuses(fc *legatus.FlowCtx) ([]fileStatus, error) {
99
	rels, err := d.relPaths()
100
	if err != nil {
101
		return nil, err
102
	}
103
	out := make([]fileStatus, 0, len(rels))
104
	for _, rel := range rels {
105
		localPath := filepath.Join(d.LocalRoot, filepath.FromSlash(rel))
106
		remotePath := path.Join(d.RemoteRoot, rel)
107
108
		localData, lerr := os.ReadFile(localPath)
109
		localMissing := os.IsNotExist(lerr)
110
		if lerr != nil && !localMissing {
111
			return nil, culpa.Wrapf(lerr, "sync: read local %s", localPath)
112
		}
113
114
		remoteData, err := downloadRemote(fc.Ctx(), fc.Conn(), remotePath)
115
		if err != nil {
116
			return nil, err
117
		}
118
119
		local, remote := localData, remoteData
120
		if d.Normalize != nil {
121
			if local, err = d.Normalize(localData); err != nil {
122
				return nil, culpa.Wrapf(err, "sync: normalize local %s", rel)
123
			}
124
			if remote, err = d.Normalize(remoteData); err != nil {
125
				return nil, culpa.Wrapf(err, "sync: normalize remote %s", rel)
126
			}
127
		}
128
129
		st := fileStatus{rel: rel, differs: string(local) != string(remote)}
130
		if st.differs && !d.Summary {
131
			st.diff = unifiedDiff(rel, remote, local)
132
		}
133
		out = append(out, st)
134
	}
135
	return out, nil
136
}
137
138
func (d *Diff) relPaths() ([]string, error) {
139
	set := map[string]bool{}
140
	for _, f := range d.Files {
141
		set[filepath.ToSlash(f)] = true
142
	}
143
	ignore := newIgnoreSet(d.Exclude)
144
	for _, dir := range d.Dirs {
145
		locals, err := walkLocalDir(d.LocalRoot, dir)
146
		if err != nil {
147
			return nil, err
148
		}
149
		for rel := range locals {
150
			if ignore.match(rel) {
151
				continue
152
			}
153
			set[rel] = true
154
		}
155
	}
156
	rels := make([]string, 0, len(set))
157
	for rel := range set {
158
		rels = append(rels, rel)
159
	}
160
	sort.Strings(rels)
161
	return rels, nil
162
}
163
164
// unifiedDiff renders a minimal unified diff between remote (a) and local (b)
165
// content for display. It emits a full-context block; this is a human-facing
166
// report, not a machine patch.
167
func unifiedDiff(name string, a, b []byte) string {
168
	aLines := splitKeepLines(string(a))
169
	bLines := splitKeepLines(string(b))
170
171
	var sb strings.Builder
172
	sb.WriteString("--- remote/" + name + "\n")
173
	sb.WriteString("+++ local/" + name + "\n")
174
175
	la, lb := lcs(aLines, bLines)
176
	ia, ib := 0, 0
177
	for _, op := range diffOps(aLines, bLines, la, lb) {
178
		switch op.kind {
179
		case opEqual:
180
			sb.WriteString(" " + aLines[ia])
181
			ia++
182
			ib++
183
		case opDel:
184
			sb.WriteString("-" + aLines[ia])
185
			ia++
186
		case opAdd:
187
			sb.WriteString("+" + bLines[ib])
188
			ib++
189
		}
190
	}
191
	if !strings.HasSuffix(sb.String(), "\n") {
192
		sb.WriteString("\n")
193
	}
194
	return sb.String()
195
}
196
197
func splitKeepLines(s string) []string {
198
	if s == "" {
199
		return nil
200
	}
201
	parts := strings.SplitAfter(s, "\n")
202
	if parts[len(parts)-1] == "" {
203
		parts = parts[:len(parts)-1]
204
	}
205
	return parts
206
}
207
208
type diffKind int
209
210
const (
211
	opEqual diffKind = iota
212
	opDel
213
	opAdd
214
)
215
216
type diffOp struct{ kind diffKind }
217
218
// lcs builds the length table for the longest common subsequence of a and b.
219
func lcs(a, b []string) ([][]int, int) {
220
	n, m := len(a), len(b)
221
	t := make([][]int, n+1)
222
	for i := range t {
223
		t[i] = make([]int, m+1)
224
	}
225
	for i := n - 1; i >= 0; i-- {
226
		for j := m - 1; j >= 0; j-- {
227
			if a[i] == b[j] {
228
				t[i][j] = t[i+1][j+1] + 1
229
			} else if t[i+1][j] >= t[i][j+1] {
230
				t[i][j] = t[i+1][j]
231
			} else {
232
				t[i][j] = t[i][j+1]
233
			}
234
		}
235
	}
236
	return t, t[0][0]
237
}
238
239
// diffOps walks the LCS table to produce an ordered edit script.
240
func diffOps(a, b []string, t [][]int, _ int) []diffOp {
241
	var ops []diffOp
242
	i, j := 0, 0
243
	n, m := len(a), len(b)
244
	for i < n && j < m {
245
		if a[i] == b[j] {
246
			ops = append(ops, diffOp{opEqual})
247
			i++
248
			j++
249
		} else if t[i+1][j] >= t[i][j+1] {
250
			ops = append(ops, diffOp{opDel})
251
			i++
252
		} else {
253
			ops = append(ops, diffOp{opAdd})
254
			j++
255
		}
256
	}
257
	for ; i < n; i++ {
258
		ops = append(ops, diffOp{opDel})
259
	}
260
	for ; j < m; j++ {
261
		ops = append(ops, diffOp{opAdd})
262
	}
263
	return ops
264
}
265

Source Files