返回 DeepSeek-Reasonix
worktree_test.go
根目录 / internal / worktree / worktree_test.go
1 package worktree
2
3 import (
4 "context"
5 "os"
6 "os/exec"
7 "path/filepath"
8 "runtime"
9 "strings"
10 "testing"
11 "time"
12
13 "reasonix/internal/gitcmd"
14 )
15
16 // opened is dir's identity as a session opening it resolves it; a directory
17 // outside any work tree yields the unresolved Repo a session would hold.
18 func opened(t *testing.T, dir string) gitcmd.Repo {
19 t.Helper()
20 repo, err := gitcmd.Open(context.Background(), dir)
21 if err != nil {
22 return gitcmd.Repo{Dir: dir}
23 }
24 return repo
25 }
26
27 func requireGit(t *testing.T) {
28 t.Helper()
29 if _, err := exec.LookPath("git"); err != nil {
30 t.Skip("git is unavailable")
31 }
32 isolateGitConfig(t)
33 }
34
35 // isolateGitConfig detaches the test from the machine's git configuration.
36 //
37 // A machine with core.hooksPath set redirects `rev-parse --git-path
38 // hooks/pre-commit` out of the temporary repository and into the developer's
39 // own hooks directory, which the hook tests then overwrite. It also makes them
40 // pass for the wrong reason: the hook they plant is not the one git would run.
41 //
42 // Set on the process, not per command, and only here: the code under test
43 // spawns git itself and inherits os.Environ(), so per-command environment
44 // would isolate the helpers and leave MergeBack's own invocations reading the
45 // user file.
46 //
47 // An empty file rather than os.DevNull keeps the value an ordinary path on
48 // every platform.
49 func isolateGitConfig(t *testing.T) {
50 t.Helper()
51 empty := filepath.Join(t.TempDir(), "gitconfig")
52 if err := os.WriteFile(empty, nil, 0o644); err != nil {
53 t.Fatal(err)
54 }
55 t.Setenv("GIT_CONFIG_GLOBAL", empty)
56 t.Setenv("GIT_CONFIG_NOSYSTEM", "1")
57 }
58
59 func initRepo(t *testing.T) string {
60 t.Helper()
61 repo := t.TempDir()
62 git := func(args ...string) string {
63 t.Helper()
64 cmd := exec.Command("git", append([]string{"-C", repo}, args...)...)
65 cmd.Env = append(os.Environ(),
66 "GIT_AUTHOR_NAME=Reasonix Test", "GIT_AUTHOR_EMAIL=reasonix@example.invalid",
67 "GIT_COMMITTER_NAME=Reasonix Test", "GIT_COMMITTER_EMAIL=reasonix@example.invalid")
68 out, err := cmd.CombinedOutput()
69 if err != nil {
70 t.Fatalf("git %v: %v\n%s", args, err, out)
71 }
72 return strings.TrimSpace(string(out))
73 }
74 git("init")
75 if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("base\n"), 0o644); err != nil {
76 t.Fatal(err)
77 }
78 git("add", "README.md")
79 git("commit", "-m", "initial")
80 return repo
81 }
82
83 func TestCreateManagedWorktreeFromRepositoryFolder(t *testing.T) {
84 requireGit(t)
85 repo := initRepo(t)
86 managed := t.TempDir()
87 result, err := Create(context.Background(), opened(t, repo), managed)
88 if err != nil {
89 t.Fatal(err)
90 }
91 wantSource, err := filepath.EvalSymlinks(repo)
92 if err != nil {
93 t.Fatal(err)
94 }
95 if result.SourceRoot != wantSource {
96 t.Fatalf("source root = %q, want %q", result.SourceRoot, wantSource)
97 }
98 if result.WorkspaceRoot != result.WorktreeRoot {
99 t.Fatalf("workspace root = %q, worktree root = %q", result.WorkspaceRoot, result.WorktreeRoot)
100 }
101 if !strings.HasPrefix(result.Branch, "reasonix/delivery-") {
102 t.Fatalf("branch = %q", result.Branch)
103 }
104 if _, err := os.Stat(filepath.Join(result.WorktreeRoot, "README.md")); err != nil {
105 t.Fatalf("created worktree missing committed file: %v", err)
106 }
107 if _, err := os.Stat(filepath.Join(result.WorktreeRoot, ".reasonix-source")); !os.IsNotExist(err) {
108 t.Fatalf("worktree must not contain a Reasonix marker, stat err = %v", err)
109 }
110 branchCmd := exec.Command("git", "-C", result.WorktreeRoot, "branch", "--show-current")
111 branchOut, err := branchCmd.Output()
112 if err != nil || strings.TrimSpace(string(branchOut)) != result.Branch {
113 t.Fatalf("worktree branch = %q err=%v, want %q", strings.TrimSpace(string(branchOut)), err, result.Branch)
114 }
115 }
116
117 func TestCreatePreservesSelectedRepositorySubdirectory(t *testing.T) {
118 requireGit(t)
119 repo := initRepo(t)
120 subdir := filepath.Join(repo, "packages", "app")
121 if err := os.MkdirAll(subdir, 0o755); err != nil {
122 t.Fatal(err)
123 }
124 if err := os.WriteFile(filepath.Join(subdir, "app.txt"), []byte("app\n"), 0o644); err != nil {
125 t.Fatal(err)
126 }
127 cmd := exec.Command("git", "-C", repo, "add", ".")
128 if out, err := cmd.CombinedOutput(); err != nil {
129 t.Fatalf("git add: %v %s", err, out)
130 }
131 cmd = exec.Command("git", "-C", repo, "-c", "user.name=Reasonix Test", "-c", "user.email=reasonix@example.invalid", "commit", "-m", "subdir")
132 if out, err := cmd.CombinedOutput(); err != nil {
133 t.Fatalf("git commit: %v %s", err, out)
134 }
135 result, err := Create(context.Background(), opened(t, subdir), t.TempDir())
136 if err != nil {
137 t.Fatal(err)
138 }
139 want := filepath.Join(result.WorktreeRoot, "packages", "app")
140 if result.WorkspaceRoot != want {
141 t.Fatalf("workspace root = %q, want %q", result.WorkspaceRoot, want)
142 }
143 }
144
145 func TestInspectRejectsUncommittedSelectedSubdirectoryWithoutGitMutation(t *testing.T) {
146 requireGit(t)
147 repo := initRepo(t)
148 untracked := filepath.Join(repo, "untracked-project")
149 if err := os.MkdirAll(untracked, 0o755); err != nil {
150 t.Fatal(err)
151 }
152 before, _, err := runGit(context.Background(), opened(t, repo), "for-each-ref", "--format=%(refname)", "refs/heads/reasonix/delivery-")
153 if err != nil {
154 t.Fatal(err)
155 }
156 got := Inspect(context.Background(), opened(t, untracked))
157 if got.Available || !strings.Contains(got.Reason, "committed HEAD") {
158 t.Fatalf("availability = %+v", got)
159 }
160 after, _, err := runGit(context.Background(), opened(t, repo), "for-each-ref", "--format=%(refname)", "refs/heads/reasonix/delivery-")
161 if err != nil {
162 t.Fatal(err)
163 }
164 if after != before {
165 t.Fatalf("availability probe mutated Git refs: before=%q after=%q", before, after)
166 }
167 }
168
169 func TestCreateFromExistingLinkedWorktree(t *testing.T) {
170 requireGit(t)
171 repo := initRepo(t)
172 linked := filepath.Join(t.TempDir(), "already-linked-worktree")
173 cmd := exec.Command("git", "-C", repo, "worktree", "add", "-b", "test/existing-linked", linked, "HEAD")
174 if out, err := cmd.CombinedOutput(); err != nil {
175 t.Fatalf("git worktree add: %v %s", err, out)
176 }
177
178 result, err := Create(context.Background(), opened(t, linked), t.TempDir())
179 if err != nil {
180 t.Fatal(err)
181 }
182 wantSource, err := filepath.EvalSymlinks(linked)
183 if err != nil {
184 t.Fatal(err)
185 }
186 if result.SourceRoot != wantSource {
187 t.Fatalf("source root = %q, want linked worktree %q", result.SourceRoot, wantSource)
188 }
189 if result.WorkspaceRoot != result.WorktreeRoot {
190 t.Fatalf("workspace root = %q, worktree root = %q", result.WorkspaceRoot, result.WorktreeRoot)
191 }
192 if result.WorktreeRoot == wantSource {
193 t.Fatal("managed worktree reused the already-open linked worktree")
194 }
195 cmd = exec.Command("git", "-C", linked, "branch", "--show-current")
196 out, err := cmd.Output()
197 if err != nil || strings.TrimSpace(string(out)) != "test/existing-linked" {
198 t.Fatalf("source linked-worktree branch changed: %q err=%v", strings.TrimSpace(string(out)), err)
199 }
200 }
201
202 func TestCreateDoesNotCopyOrChangeDirtySource(t *testing.T) {
203 requireGit(t)
204 repo := initRepo(t)
205 dirtyPath := filepath.Join(repo, "README.md")
206 if err := os.WriteFile(dirtyPath, []byte("uncommitted\n"), 0o644); err != nil {
207 t.Fatal(err)
208 }
209 result, err := Create(context.Background(), opened(t, repo), t.TempDir())
210 if err != nil {
211 t.Fatal(err)
212 }
213 if !result.SourceDirty {
214 t.Fatal("dirty source was not reported")
215 }
216 got, err := os.ReadFile(filepath.Join(result.WorktreeRoot, "README.md"))
217 if err != nil {
218 t.Fatal(err)
219 }
220 // Git for Windows may materialize the committed LF as CRLF according to
221 // core.autocrlf. Compare the file's semantic content so this assertion only
222 // detects the behavior under test: copying the dirty source value.
223 if strings.TrimSpace(string(got)) != "base" {
224 t.Fatalf("worktree copied uncommitted content: %q", got)
225 }
226 source, _ := os.ReadFile(dirtyPath)
227 if string(source) != "uncommitted\n" {
228 t.Fatalf("source was modified: %q", source)
229 }
230 }
231
232 func TestInspectRejectsNonRepositoryAndUnbornRepository(t *testing.T) {
233 requireGit(t)
234 if got := Inspect(context.Background(), opened(t, t.TempDir())); got.Available || !strings.Contains(got.Reason, "not inside a Git repository") {
235 t.Fatalf("non-repo availability = %+v", got)
236 }
237 unborn := t.TempDir()
238 if out, err := exec.Command("git", "-C", unborn, "init").CombinedOutput(); err != nil {
239 t.Fatalf("git init: %v %s", err, out)
240 }
241 if got := Inspect(context.Background(), opened(t, unborn)); got.Available || !strings.Contains(got.Reason, "initial commit") {
242 t.Fatalf("unborn availability = %+v", got)
243 }
244 }
245
246 func TestInspectWithoutGitExplainsSafeFallback(t *testing.T) {
247 if runtime.GOOS == "windows" {
248 t.Setenv("PATH", t.TempDir())
249 } else {
250 t.Setenv("PATH", t.TempDir())
251 }
252 got := Inspect(context.Background(), opened(t, t.TempDir()))
253 if got.Available || !strings.Contains(got.Reason, "Git is not installed") || !strings.Contains(got.Reason, "serialize writes") {
254 t.Fatalf("no-Git availability = %+v", got)
255 }
256 }
257
258 func TestManagedPathBoundary(t *testing.T) {
259 managed := filepath.Join(t.TempDir(), "worktrees")
260 inside := filepath.Join(managed, "repo", "id")
261 sibling := managed + "-backup"
262 if !IsManagedPath(inside, managed) {
263 t.Fatal("inside path not recognized")
264 }
265 if IsManagedPath(managed, managed) || IsManagedPath(sibling, managed) {
266 t.Fatal("managed root or prefix sibling was incorrectly recognized")
267 }
268 }
269
270 func TestSafePathComponentHandlesWindowsNames(t *testing.T) {
271 for input, want := range map[string]string{
272 "repo:demo": "repo-demo",
273 "CON": "_CON",
274 "lpt1.txt": "_lpt1.txt",
275 "a<b>c": "a-b-c",
276 } {
277 if got := safePathComponent(input); got != want {
278 t.Errorf("safePathComponent(%q) = %q, want %q", input, got, want)
279 }
280 }
281 }
282
283 // Argument construction — including the Windows-only core.longpaths override —
284 // now lives in internal/gitcmd and is covered by that package's tests.
285
286 func TestGitWorktreeAddUsesExtendedTimeout(t *testing.T) {
287 if got := gitTimeout([]string{"status", "--porcelain=v1"}); got != gitProbeTimeout {
288 t.Fatalf("status timeout = %v, want %v", got, gitProbeTimeout)
289 }
290 if got := gitTimeout([]string{"reset", "--hard", "HEAD"}); got != gitWorktreeMutationTimeout {
291 t.Fatalf("reset timeout = %v, want %v: the new worktree's checkout happens there", got, gitWorktreeMutationTimeout)
292 }
293 got := gitTimeout([]string{"worktree", "add", "-b", "branch", "destination", "HEAD"})
294 if got != gitWorktreeMutationTimeout {
295 t.Fatalf("worktree add timeout = %v, want %v", got, gitWorktreeMutationTimeout)
296 }
297 if remove := gitTimeout([]string{"worktree", "remove", "destination"}); remove != gitWorktreeMutationTimeout {
298 t.Fatalf("worktree remove timeout = %v, want %v", remove, gitWorktreeMutationTimeout)
299 }
300 if got < 2*time.Minute || got <= gitProbeTimeout {
301 t.Fatalf("worktree add timeout = %v, want a checkout-safe timeout longer than probes", got)
302 }
303 }
304
305 func TestRollbackCreateRemovesOnlyUntouchedWorktree(t *testing.T) {
306 requireGit(t)
307 repo := initRepo(t)
308 result, err := Create(context.Background(), opened(t, repo), t.TempDir())
309 if err != nil {
310 t.Fatal(err)
311 }
312 if err := RollbackCreate(context.Background(), result); err != nil {
313 t.Fatalf("RollbackCreate: %v", err)
314 }
315 if _, err := os.Stat(result.WorktreeRoot); !os.IsNotExist(err) {
316 t.Fatalf("worktree still exists after rollback: %v", err)
317 }
318 if _, err := os.Stat(metadataPath(result.WorktreeRoot)); !os.IsNotExist(err) {
319 t.Fatalf("worktree metadata still exists after rollback: %v", err)
320 }
321 cmd := exec.Command("git", "-C", repo, "show-ref", "--verify", "--quiet", "refs/heads/"+result.Branch)
322 if err := cmd.Run(); err == nil {
323 t.Fatalf("branch %q still exists after rollback", result.Branch)
324 }
325 if _, err := os.Stat(filepath.Join(repo, "README.md")); err != nil {
326 t.Fatalf("source repository was damaged: %v", err)
327 }
328 }
329
330 func TestRollbackCreatePreservesChangedWorktree(t *testing.T) {
331 requireGit(t)
332 repo := initRepo(t)
333 result, err := Create(context.Background(), opened(t, repo), t.TempDir())
334 if err != nil {
335 t.Fatal(err)
336 }
337 change := filepath.Join(result.WorktreeRoot, "uncommitted.txt")
338 if err := os.WriteFile(change, []byte("preserve me\n"), 0o644); err != nil {
339 t.Fatal(err)
340 }
341 if err := RollbackCreate(context.Background(), result); err == nil || !strings.Contains(err.Error(), "contains changes") {
342 t.Fatalf("RollbackCreate error = %v, want changed-worktree refusal", err)
343 }
344 if got, err := os.ReadFile(change); err != nil || string(got) != "preserve me\n" {
345 t.Fatalf("changed worktree was not preserved: data=%q err=%v", got, err)
346 }
347 cmd := exec.Command("git", "-C", repo, "show-ref", "--verify", "--quiet", "refs/heads/"+result.Branch)
348 if err := cmd.Run(); err != nil {
349 t.Fatalf("changed worktree branch was removed: %v", err)
350 }
351 }
352
353 func TestRollbackCreatePreservesIgnoredContent(t *testing.T) {
354 requireGit(t)
355 for _, tc := range []struct {
356 name string
357 path string
358 }{
359 {name: "file", path: "ignored.txt"},
360 {name: "directory", path: filepath.Join("cache", "artifact.bin")},
361 } {
362 t.Run(tc.name, func(t *testing.T) {
363 repo := initRepo(t)
364 if err := os.WriteFile(filepath.Join(repo, ".gitignore"), []byte("ignored.txt\ncache/\n"), 0o644); err != nil {
365 t.Fatal(err)
366 }
367 git := exec.Command("git", "-C", repo, "add", ".gitignore")
368 if out, err := git.CombinedOutput(); err != nil {
369 t.Fatalf("git add .gitignore: %v %s", err, out)
370 }
371 git = exec.Command("git", "-C", repo, "-c", "user.name=Reasonix Test", "-c", "user.email=reasonix@example.invalid", "commit", "-m", "ignore generated files")
372 if out, err := git.CombinedOutput(); err != nil {
373 t.Fatalf("git commit .gitignore: %v %s", err, out)
374 }
375
376 result, err := Create(context.Background(), opened(t, repo), t.TempDir())
377 if err != nil {
378 t.Fatal(err)
379 }
380 ignoredPath := filepath.Join(result.WorktreeRoot, tc.path)
381 if err := os.MkdirAll(filepath.Dir(ignoredPath), 0o755); err != nil {
382 t.Fatal(err)
383 }
384 if err := os.WriteFile(ignoredPath, []byte("preserve me\n"), 0o644); err != nil {
385 t.Fatal(err)
386 }
387
388 if err := RollbackCreate(context.Background(), result); err == nil || !strings.Contains(err.Error(), "contains changes") {
389 t.Fatalf("RollbackCreate error = %v, want ignored-content refusal", err)
390 }
391 if got, err := os.ReadFile(ignoredPath); err != nil || string(got) != "preserve me\n" {
392 t.Fatalf("ignored content was not preserved: data=%q err=%v", got, err)
393 }
394 git = exec.Command("git", "-C", repo, "show-ref", "--verify", "--quiet", "refs/heads/"+result.Branch)
395 if err := git.Run(); err != nil {
396 t.Fatalf("ignored-content branch was removed: %v", err)
397 }
398 })
399 }
400 }
401
402 func TestCreateSupportsPathsWithSpaces(t *testing.T) {
403 requireGit(t)
404 parent := t.TempDir()
405 repo := filepath.Join(parent, "repo with spaces")
406 if err := os.MkdirAll(repo, 0o755); err != nil {
407 t.Fatal(err)
408 }
409 git := func(args ...string) {
410 cmd := exec.Command("git", append([]string{"-C", repo}, args...)...)
411 cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=Reasonix Test", "GIT_AUTHOR_EMAIL=reasonix@example.invalid", "GIT_COMMITTER_NAME=Reasonix Test", "GIT_COMMITTER_EMAIL=reasonix@example.invalid")
412 if out, err := cmd.CombinedOutput(); err != nil {
413 t.Fatalf("git %v: %v %s", args, err, out)
414 }
415 }
416 git("init")
417 if err := os.WriteFile(filepath.Join(repo, "file.txt"), []byte("x"), 0o644); err != nil {
418 t.Fatal(err)
419 }
420 git("add", ".")
421 git("commit", "-m", "initial")
422 result, err := Create(context.Background(), opened(t, repo), filepath.Join(parent, "managed worktrees"))
423 if err != nil {
424 t.Fatal(err)
425 }
426 if runtime.GOOS != "windows" && !strings.Contains(result.WorktreeRoot, "managed worktrees") {
427 t.Fatalf("unexpected worktree root %q", result.WorktreeRoot)
428 }
429 }
430
430 lines GO