Repository navigation
Expand file tree
/
Copy pathsafety_test.go
More file actions
405 lines (381 loc) · 17.3 KB
/
Copy pathsafety_test.go
File metadata and controls
405 lines (381 loc) · 17.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
package main
import (
"os"
"path/filepath"
"slices"
"strings"
"testing"
"time"
)
// Each test here is a way a removal could lose work that no remote has; the
// verdict must not be reclaimable, or the removal must keep a copy.
func scanned(t *testing.T, repo string) *Snapshot {
t.Helper()
snap, _ := summarize([]*Repo{scanRepo(repo, &sizeCache{m: map[string]sizeEntry{}}, true, nil)}, time.Now(), 0)
return snap
}
func worktreeIn(t *testing.T, snap *Snapshot, path string) *Worktree {
t.Helper()
_, w := find(snap, path)
if w == nil {
t.Fatalf("scan does not list %s", path)
}
return w
}
// removeNow removes paths through the shared chokepoint and indexes the results.
func removeNow(snap *Snapshot, q removeRequest) (*removeReport, map[string]*removal) {
rep := removeWorktrees(snap, q, removeIn, func() string { return "" })
got := map[string]*removal{}
for _, it := range rep.Results {
if got[it.Path] == nil {
got[it.Path] = it
}
}
return rep, got
}
func agentyardRefs(t *testing.T, repo string) []string {
t.Helper()
return strings.Fields(gitT(t, repo, "for-each-ref", "--format=%(refname)", "refs/agentyard/"))
}
// keptOrRefused fails the test unless path is kept by the scan and its
// removal is refused with file still on disk.
func keptOrRefused(t *testing.T, snap *Snapshot, path, reason, file string) {
t.Helper()
w := worktreeIn(t, snap, path)
if reclaimable(w.Verdict) || !strings.Contains(w.Reason, reason) {
t.Errorf("%s: %s %q, want not reclaimable with %q", filepath.Base(path), w.Verdict, w.Reason, reason)
}
_, got := removeNow(snap, removeRequest{Paths: []string{path}})
if r := got[path]; r == nil || r.Outcome != "refused" {
t.Errorf("%s: removal %+v", filepath.Base(path), r)
}
if !exists(filepath.Join(path, file)) {
t.Errorf("%s lost %s", filepath.Base(path), file)
}
}
// A submodule's edits are one " M sub" line to the superproject, and git add
// records only its commit: the probe would equal the remote.
func TestDirtySubmoduleIsKept(t *testing.T) {
base, repo := gitRepo(t)
lib := filepath.Join(base, "lib")
gitT(t, base, "init", "-q", "-b", "main", lib)
writeIn(t, lib, "lib.txt", "v1\n", true)
gitT(t, repo, "-c", "protocol.file.allow=always", "submodule", "add", "-q", lib, "sub")
gitT(t, repo, "commit", "-q", "-m", "sub")
gitT(t, repo, "push", "-q", "origin", "main")
wt := filepath.Join(base, "wt", "withsub")
gitT(t, repo, "worktree", "add", "-q", "-b", "withsub", wt)
gitT(t, wt, "-c", "protocol.file.allow=always", "submodule", "update", "-q", "--init")
writeIn(t, filepath.Join(wt, "sub"), "lib.txt", "edited in the submodule\n", false)
writeIn(t, filepath.Join(wt, "sub"), "new.txt", "new in the submodule\n", false)
keptOrRefused(t, scanned(t, repo), wt, "submodule", "sub/new.txt")
}
// Uncommitted files that only another branch has are not merged, even with
// HEAD in the default branch, and are never forced away.
func TestDirtyContentOnlyElsewhere(t *testing.T) {
base, repo := gitRepo(t)
gitT(t, repo, "switch", "-q", "-c", "experiment")
writeIn(t, repo, "x.txt", "only on experiment\n", true)
gitT(t, repo, "push", "-q", "origin", "experiment")
gitT(t, repo, "switch", "-q", "main")
wt := filepath.Join(base, "wt", "detached")
gitT(t, repo, "worktree", "add", "-q", "--detach", wt, "main")
writeIn(t, wt, "x.txt", "only on experiment\n", false)
snap := scanned(t, repo)
if w := worktreeIn(t, snap, wt); w.ContentRef != "origin/experiment" || w.RemoveCmd != "" {
t.Errorf("content ref %q, command %q", w.ContentRef, w.RemoveCmd)
}
keptOrRefused(t, snap, wt, "local changes already on origin/experiment", "x.txt")
}
// A staged version that the file on disk no longer has is work too: git add -A
// in the probe would replace it with the disk's copy.
func TestStagedOnlyWorkIsKept(t *testing.T) {
base, repo := gitRepo(t)
wt := filepath.Join(base, "wt", "staged")
gitT(t, repo, "worktree", "add", "-q", "-b", "staged", wt)
writeIn(t, wt, "README", "staged, unique\n", false)
gitT(t, wt, "add", "README")
writeIn(t, wt, "README", "hi\n", false) // what the remote has
keptOrRefused(t, scanned(t, repo), wt, "uncommitted", "README")
if got := gitT(t, wt, "show", ":README"); got != "staged, unique\n" {
t.Errorf("the staged version is gone: %q", got)
}
}
// A custom merge driver or a clean filter can make git's comparison report a
// match that dropped the local bytes.
func TestMergeDriverAndFilterAreKept(t *testing.T) {
base, repo := gitRepo(t)
writeIn(t, repo, ".gitattributes", "*.cfg merge=ours\n*.nb filter=strip\n", true)
gitT(t, repo, "push", "-q", "origin", "main")
wt := func(name string) string { return filepath.Join(base, "wt", name) }
for _, name := range []string{"driver", "filter"} {
gitT(t, repo, "worktree", "add", "-q", "-b", name, wt(name))
}
gitT(t, repo, "config", "merge.ours.driver", "true")
gitT(t, repo, "config", "filter.strip.clean", "sed -e d")
writeIn(t, repo, "app.cfg", "the remote's\n", true)
writeIn(t, repo, "out.nb", "", true)
gitT(t, repo, "push", "-q", "origin", "main")
writeIn(t, wt("driver"), "app.cfg", "mine, nowhere else\n", false)
writeIn(t, wt("filter"), "out.nb", "outputs nowhere else\n", false)
snap := scanned(t, repo)
keptOrRefused(t, snap, wt("driver"), "uncommitted", "app.cfg")
keptOrRefused(t, snap, wt("filter"), "uncommitted", "out.nb")
}
// Edits to an assume-unchanged file are invisible to git status, and so to
// git's own check in worktree remove.
func TestAssumeUnchangedEditIsKept(t *testing.T) {
base, repo := gitRepo(t)
wt := filepath.Join(base, "wt", "hidden")
gitT(t, repo, "worktree", "add", "-q", "-b", "hidden", wt)
gitT(t, wt, "update-index", "--assume-unchanged", "README")
writeIn(t, wt, "README", "an edit status cannot see\n", false)
keptOrRefused(t, scanned(t, repo), wt, "assume-unchanged", "README")
}
// The history check matches content and mode: a file made executable holds a
// change the remote never had, even though its bytes were pushed once.
func TestModeChangeIsKept(t *testing.T) {
base, repo := gitRepo(t)
writeIn(t, repo, "run.sh", "echo one\n", true)
gitT(t, repo, "push", "-q", "origin", "main")
wt := filepath.Join(base, "wt", "chmod")
gitT(t, repo, "worktree", "add", "-q", "-b", "chmod", wt)
writeIn(t, repo, "run.sh", "echo two\n", true)
gitT(t, repo, "push", "-q", "origin", "main")
if err := os.Chmod(filepath.Join(wt, "run.sh"), 0o755); err != nil {
t.Fatal(err)
}
keptOrRefused(t, scanned(t, repo), wt, "uncommitted: 1 modified", "run.sh")
}
// A nested repository anywhere under an ignored folder goes with the worktree:
// under a skipDirs name like vendor/ too, and with a .GIT spelled otherwise.
func TestNestedRepoUnderIgnoredDirsIsKept(t *testing.T) {
base, repo := gitRepo(t)
writeIn(t, repo, ".gitignore", ".worktrees/\n.env\nvendor/\nbuild/\n", true)
gitT(t, repo, "push", "-q", "origin", "main")
wt := func(name string) string { return filepath.Join(base, "wt", name) }
for _, name := range []string{"vendored", "upper"} {
gitT(t, repo, "worktree", "add", "-q", "-b", name, wt(name))
}
clone := filepath.Join(wt("vendored"), "vendor", "lib", "clone")
gitT(t, base, "init", "-q", clone)
writeIn(t, clone, "work.txt", "a clone's own work\n", true)
upper := filepath.Join(wt("upper"), "build", "x", "repo")
gitT(t, base, "init", "-q", upper)
if err := os.Rename(filepath.Join(upper, ".git"), filepath.Join(upper, ".GIT")); err != nil {
t.Fatal(err)
}
snap := scanned(t, repo)
keptOrRefused(t, snap, wt("vendored"), "another git checkout sits in its ignored folder vendor/lib/clone/", "vendor/lib/clone/work.txt")
if exists(filepath.Join(upper, ".git", "HEAD")) { // a case-insensitive file system, as macOS has by default
keptOrRefused(t, snap, wt("upper"), "another git checkout sits in its ignored folder build/x/repo/", "build/x/repo/.GIT/HEAD")
}
}
// A detached HEAD whose commits net out to what main has is reclaimable, but
// its commits exist nowhere else: the removal keeps them under a ref.
func TestDetachedCommitsKeptOnRemoval(t *testing.T) {
base, repo := gitRepo(t)
wt := filepath.Join(base, "wt", "reverted")
gitT(t, repo, "worktree", "add", "-q", "--detach", wt)
writeIn(t, wt, "README", "an idea\n", true)
idea := strings.TrimSpace(gitT(t, wt, "rev-parse", "HEAD"))
writeIn(t, wt, "README", "hi\n", true) // reverted
snap := scanned(t, repo)
w := worktreeIn(t, snap, wt)
if w.Verdict != "merged" || w.RemoveCmd != "agentyard remove "+w.Path {
t.Fatalf("detached, reverted: %s %q, command %q", w.Verdict, w.Reason, w.RemoveCmd)
}
_, got := removeNow(snap, removeRequest{Paths: []string{wt}})
r := got[wt]
if r == nil || r.Outcome != "removed" || !strings.HasPrefix(r.Backup, "refs/agentyard/removed/") || exists(wt) {
t.Fatalf("removal: %+v", r)
}
gitT(t, repo, "merge-base", "--is-ancestor", idea, r.Backup) // the idea commit is still reachable
}
// A covered dirty worktree is forced away only after a copy is kept, and a
// write after that copy refuses the removal.
func TestCheckedRemovalKeepsCopyAndRechecks(t *testing.T) {
base, repo := gitRepo(t)
wt := func(name string) string { return filepath.Join(base, "wt", name) }
for _, name := range []string{"copied", "written"} {
gitT(t, repo, "worktree", "add", "-q", "-b", name, wt(name))
writeIn(t, wt(name), "b.txt", "pushed later\n", false)
}
writeIn(t, repo, "b.txt", "pushed later\n", true)
gitT(t, repo, "push", "-q", "origin", "main")
snap := scanned(t, repo)
for _, name := range []string{"copied", "written"} {
if w := worktreeIn(t, snap, wt(name)); w.Verdict != "merged" || w.RemoveCmd != "agentyard remove "+w.Path {
t.Fatalf("%s: %s %q, command %q", name, w.Verdict, w.Reason, w.RemoveCmd)
}
}
defer func(f func(*Worktree)) { beforeExec = f }(beforeExec)
beforeExec = func(w *Worktree) {
if w.Path == wt("written") {
writeIn(t, w.Path, "late.txt", "written after the copy\n", false)
}
}
_, got := removeNow(snap, removeRequest{Paths: []string{wt("copied"), wt("written")}})
r := got[wt("copied")]
if r == nil || r.Outcome != "removed" || r.Untracked != 1 || r.ContentRef != "origin/main" || r.Backup == "" || exists(wt("copied")) {
t.Fatalf("copied: %+v", r)
}
if b := gitT(t, repo, "show", r.Backup+":b.txt"); b != "pushed later\n" {
t.Errorf("the copy lacks the uncommitted file: %q", b)
}
if r := got[wt("written")]; r == nil || r.Outcome != "refused" || !strings.Contains(r.Reason, "changed while it was being removed") {
t.Errorf("written: %+v", r)
}
if !exists(filepath.Join(wt("written"), "late.txt")) {
t.Errorf("the late write is gone")
}
if refs := agentyardRefs(t, repo); len(refs) != 1 || refs[0] != r.Backup {
t.Errorf("refs left: %v, want only %s", refs, r.Backup)
}
}
// Without a fetch, the remote-tracking refs may be stale, so nothing whose
// safety rests on them goes; a clean merged worktree still can.
func TestFetchFailureRefusesCheckedRemoval(t *testing.T) {
base, repo := gitRepo(t)
wt := func(name string) string { return filepath.Join(base, "wt", name) }
for _, name := range []string{"covered", "clean"} {
gitT(t, repo, "worktree", "add", "-q", "-b", name, wt(name))
}
writeIn(t, wt("covered"), "b.txt", "pushed later\n", false)
writeIn(t, repo, "b.txt", "pushed later\n", true)
gitT(t, repo, "push", "-q", "origin", "main")
snap := scanned(t, repo)
gitT(t, repo, "remote", "set-url", "origin", filepath.Join(base, "unreachable.git"))
rep, got := removeNow(snap, removeRequest{Paths: []string{wt("covered"), wt("clean")}})
if r := got[wt("covered")]; r == nil || r.Outcome != "refused" || !strings.Contains(r.Reason, "could not fetch") || !exists(filepath.Join(wt("covered"), "b.txt")) {
t.Errorf("covered: %+v", r)
}
if r := got[wt("clean")]; r == nil || r.Outcome != "removed" {
t.Errorf("clean: %+v", r)
}
if len(rep.Warnings) == 0 {
t.Errorf("no fetch warning")
}
}
// A dry run's plan binds the removal: an ignored file that appears after it
// refuses the removal until it is checked again.
func TestPlanBindsRemoval(t *testing.T) {
base, repo := gitRepo(t)
wt := filepath.Join(base, "wt", "planned")
gitT(t, repo, "worktree", "add", "-q", "-b", "planned", wt)
snap := scanned(t, repo)
_, dry := removeNow(snap, removeRequest{Paths: []string{wt}, DryRun: true})
plan := dry[wt].Plan
if dry[wt].Outcome != "would_remove" || plan == "" {
t.Fatalf("dry run: %+v", dry[wt])
}
writeIn(t, wt, ".env", "SECRET=1\n", false)
_, got := removeNow(snap, removeRequest{Paths: []string{wt}, Plans: map[string]string{wt: plan}})
if r := got[wt]; r.Outcome != "refused" || !strings.Contains(r.Reason, "changed since the dry run") || !exists(filepath.Join(wt, ".env")) {
t.Fatalf("stale plan: %+v", r)
}
_, dry = removeNow(snap, removeRequest{Paths: []string{wt}, DryRun: true})
if r := dry[wt]; len(r.Ignored) != 1 || r.Plan == plan {
t.Fatalf("new dry run: %+v", r)
}
_, got = removeNow(snap, removeRequest{Paths: []string{wt}, Plans: map[string]string{wt: dry[wt].Plan}})
if r := got[wt]; r.Outcome != "removed" || exists(wt) {
t.Errorf("fresh plan: %+v", r)
}
}
// Too many untracked files to hash on every scan: kept, not compared.
func TestUntrackedCapKeeps(t *testing.T) {
defer func(n int) { probeMaxFiles = n }(probeMaxFiles)
probeMaxFiles = 2
base, repo := gitRepo(t)
wt := filepath.Join(base, "wt", "many")
gitT(t, repo, "worktree", "add", "-q", "-b", "many", wt)
for _, f := range []string{"a.txt", "b.txt", "c.txt"} {
writeIn(t, wt, f, f+"\n", false)
writeIn(t, repo, f, f+"\n", true)
}
gitT(t, repo, "push", "-q", "origin", "main")
keptOrRefused(t, scanned(t, repo), wt, "uncommitted: 3 untracked", "a.txt")
}
// A snapshot whose scan began before a removal hides the removed worktree even
// when it lands after the removal; one begun afterwards is left alone.
func TestRemovedPathsHideByScanStart(t *testing.T) {
removedAt := time.Now()
mk := func(started, at time.Time) *Snapshot {
snap, _ := summarize([]*Repo{{Path: "/r", Worktrees: []*Worktree{{Path: "/r/wt/a", Verdict: "merged"}, {Path: "/r/wt/b", Verdict: "keep"}}}}, at, 0)
snap.Started = started
return snap
}
var g removedPaths
g.add([]string{"/r/wt/a"}, removedAt)
if out, n := g.hide(mk(removedAt.Add(-time.Minute), removedAt.Add(time.Minute))); n != 1 || len(out.Repos[0].Worktrees) != 1 || !out.Started.Equal(removedAt.Add(-time.Minute)) {
t.Errorf("scan begun before the removal still shows it: %d", n)
}
if _, n := g.hide(mk(removedAt.Add(time.Second), removedAt.Add(time.Minute))); n != 0 {
t.Errorf("scan begun after the removal was filtered: %d", n)
}
}
// status.showUntrackedFiles=no hides untracked files from a plain git status;
// the scan, and git's own check during removal, must still see them.
func TestUntrackedHiddenByConfigAreSeen(t *testing.T) {
base, repo := gitRepo(t)
wt := filepath.Join(base, "wt", "hidden")
gitT(t, repo, "worktree", "add", "-q", "-b", "hidden", wt)
gitT(t, repo, "config", "status.showUntrackedFiles", "no")
os.WriteFile(filepath.Join(wt, "notes.txt"), []byte("only here"), 0o644)
if w := worktreeIn(t, scanned(t, repo), wt); w.Verdict != "keep" || w.Untracked != 1 {
t.Fatalf("hidden untracked file: %s %q untracked=%d", w.Verdict, w.Reason, w.Untracked)
}
if args := removeArgs(repo, &Worktree{Path: wt}, false); !slices.Contains(args, "status.showUntrackedFiles=normal") {
t.Fatalf("git's own check can be blinded: %v", args)
}
}
// A clean filter decides what git sees of a file; bytes it drops are only on
// disk, so such a worktree is never Safe to delete. Git LFS keeps every byte.
func TestCleanFilterKeeps(t *testing.T) {
base, repo := gitRepo(t)
wt := filepath.Join(base, "wt", "strip")
gitT(t, repo, "worktree", "add", "-q", "-b", "strip", wt)
gitT(t, wt, "config", "filter.strip.clean", "cat")
common := strings.TrimSpace(gitT(t, wt, "rev-parse", "--path-format=absolute", "--git-common-dir"))
os.MkdirAll(filepath.Join(common, "info"), 0o755)
attrs := filepath.Join(common, "info", "attributes")
os.WriteFile(attrs, []byte("README filter=strip\n"), 0o644)
if w := worktreeIn(t, scanned(t, repo), wt); w.Verdict != "keep" || !strings.Contains(w.Reason, "clean filter") {
t.Fatalf("filter: %s %q", w.Verdict, w.Reason)
}
// Only the attribute is judged here: a machine with git-lfs installed
// would run the real filter on the file.
os.WriteFile(attrs, []byte("README filter=lfs\n"), 0o644)
if hz := trackedHazard(wt); hz != "" {
t.Fatalf("lfs: %s", hz)
}
}
// Staging a change after the copy was kept, and before the removal runs, is
// seen in the real index: the copy does not hold it.
func TestStagedAfterCopyRefuses(t *testing.T) {
base, repo := gitRepo(t)
wt := filepath.Join(base, "wt", "staged")
gitT(t, repo, "worktree", "add", "-q", "-b", "staged", wt)
writeIn(t, repo, "landed", "already remote\n", true)
gitT(t, repo, "push", "-q", "origin", "main")
writeIn(t, wt, "landed", "already remote\n", false)
snap := scanned(t, repo)
defer func(f func(*Worktree)) { beforeLastUse = f }(beforeLastUse)
beforeLastUse = func(w *Worktree) {
blob := strings.TrimSpace(gitInput(t, w.Path, "unique staged work\n", "hash-object", "-w", "--stdin"))
gitT(t, w.Path, "update-index", "--add", "--cacheinfo", "100644,"+blob+",staged-only")
}
_, results := removeNow(snap, removeRequest{Paths: []string{wt}})
if it := results[wt]; it.Outcome != "refused" || !exists(wt) || len(agentyardRefs(t, repo)) != 0 {
t.Fatalf("staged after the copy: %+v", it)
}
}
func gitInput(t *testing.T, dir, input string, args ...string) string {
t.Helper()
out, err := runInput(gitTimeout, dir, nil, input, "git", args...)
if err != nil {
t.Fatal(err)
}
return out
}