diff.go

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

Source Files