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