返回 DeepSeek-Reasonix
fork_worktree_test.go
根目录 / desktop / fork_worktree_test.go
1 package main
2
3 import (
4 "context"
5 "os"
6 "path/filepath"
7 "testing"
8
9 "reasonix/internal/agent"
10 "reasonix/internal/config"
11 "reasonix/internal/worktree"
12 )
13
14 func TestForkWorktreeForTabCreatesIsolatedWorkspace(t *testing.T) {
15 isolateDesktopUserDirs(t)
16 managed := config.DeliveryWorktreeDir()
17 isolatedRoot := filepath.Join(managed, "repo", "id", "isolated-project")
18 if err := os.MkdirAll(isolatedRoot, 0o755); err != nil {
19 t.Fatal(err)
20 }
21
22 origInspect := inspectDeliveryWorktree
23 origCreate := createDeliveryWorktree
24 origRollback := rollbackDeliveryWorktree
25 t.Cleanup(func() {
26 inspectDeliveryWorktree = origInspect
27 createDeliveryWorktree = origCreate
28 rollbackDeliveryWorktree = origRollback
29 })
30
31 inspectDeliveryWorktree = func(_ context.Context, root string) worktree.Availability {
32 return worktree.Availability{Available: true, RepoRoot: root, Branch: "main"}
33 }
34 createDeliveryWorktree = func(_ context.Context, source, gotManaged string) (worktree.Result, error) {
35 return worktree.Result{
36 WorkspaceRoot: isolatedRoot,
37 WorktreeRoot: filepath.Dir(isolatedRoot),
38 SourceRoot: source,
39 Branch: "reasonix/fork-test",
40 }, nil
41 }
42
43 ctrl := &blockingForkTabController{
44 tabScopedActionController: newTabScopedActionController(),
45 path: filepath.Join(config.SessionDir(), "fork.jsonl"),
46 started: make(chan struct{}),
47 release: make(chan struct{}),
48 }
49 close(ctrl.release)
50
51 app := NewApp()
52 app.setTestCtrl(ctrl, "")
53 app.tabs["test"].Scope = "project"
54 app.tabs["test"].WorkspaceRoot = "source-project"
55 app.tabs["test"].TopicTitle = "Source topic"
56
57 result, err := app.ForkWorktreeForTab("test", 1)
58 if err != nil {
59 t.Fatalf("ForkWorktreeForTab failed: %v", err)
60 }
61 meta := result.Tab
62 if !result.Isolated || result.SourceDirty || result.FallbackToShared {
63 t.Fatalf("unexpected worktree result: %+v", result)
64 }
65 if meta.ID == "" || meta.ID == "test" {
66 t.Fatalf("expected new tab ID, got %q", meta.ID)
67 }
68 if meta.WorkspaceRoot != isolatedRoot {
69 t.Fatalf("fork tab workspaceRoot = %q, want isolated %q", meta.WorkspaceRoot, isolatedRoot)
70 }
71 foundProject := false
72 for _, project := range loadProjectsFile().Projects {
73 if sameProjectRoot(project.Root, isolatedRoot) {
74 foundProject = containsDesktopString(project.Topics, meta.TopicID)
75 }
76 }
77 if !foundProject {
78 t.Fatalf("isolated project/topic was not persisted: %+v", loadProjectsFile().Projects)
79 }
80 if got := loadWorkspaces(); len(got) == 0 || !sameProjectRoot(got[0], isolatedRoot) {
81 t.Fatalf("isolated workspace was not remembered: %v", got)
82 }
83 }
84
85 func TestForkWorktreeForTabRefusesDirtySourceWithoutMutation(t *testing.T) {
86 isolateDesktopUserDirs(t)
87 origInspect := inspectDeliveryWorktree
88 origCreate := createDeliveryWorktree
89 t.Cleanup(func() {
90 inspectDeliveryWorktree = origInspect
91 createDeliveryWorktree = origCreate
92 })
93 inspectDeliveryWorktree = func(_ context.Context, root string) worktree.Availability {
94 return worktree.Availability{Available: true, RepoRoot: root, SourceDirty: true}
95 }
96 createCalls := 0
97 createDeliveryWorktree = func(context.Context, string, string) (worktree.Result, error) {
98 createCalls++
99 return worktree.Result{}, nil
100 }
101
102 ctrl := newTabScopedActionController()
103 app := NewApp()
104 app.setTestCtrl(ctrl, "")
105 app.tabs["test"].Scope = "project"
106 app.tabs["test"].WorkspaceRoot = t.TempDir()
107
108 result, err := app.ForkWorktreeForTab("test", 1)
109 if err != nil {
110 t.Fatalf("ForkWorktreeForTab: %v", err)
111 }
112 if !result.SourceDirty || result.Tab.ID != "" {
113 t.Fatalf("dirty result = %+v", result)
114 }
115 if createCalls != 0 || ctrl.forkCalls != 0 {
116 t.Fatalf("dirty source mutated state: create=%d fork=%d", createCalls, ctrl.forkCalls)
117 }
118 }
119
120 func TestForkWorktreeForTabFallsBackToSharedFork(t *testing.T) {
121 isolateDesktopUserDirs(t)
122 origInspect := inspectDeliveryWorktree
123 origCreate := createDeliveryWorktree
124 t.Cleanup(func() {
125 inspectDeliveryWorktree = origInspect
126 createDeliveryWorktree = origCreate
127 })
128 inspectDeliveryWorktree = func(context.Context, string) worktree.Availability {
129 return worktree.Availability{Reason: "not a repository"}
130 }
131 createDeliveryWorktree = func(context.Context, string, string) (worktree.Result, error) {
132 t.Fatal("fallback must not create a worktree")
133 return worktree.Result{}, nil
134 }
135 ctrl := &blockingForkTabController{
136 tabScopedActionController: newTabScopedActionController(),
137 path: filepath.Join(config.SessionDir(), "shared-fork.jsonl"),
138 started: make(chan struct{}),
139 release: make(chan struct{}),
140 }
141 close(ctrl.release)
142 app := NewApp()
143 app.setTestCtrl(ctrl, "")
144 app.tabs["test"].Scope = "project"
145 sourceRoot := t.TempDir()
146 app.tabs["test"].WorkspaceRoot = sourceRoot
147
148 result, err := app.ForkWorktreeForTab("test", 1)
149 if err != nil {
150 t.Fatalf("ForkWorktreeForTab: %v", err)
151 }
152 if !result.FallbackToShared || result.Isolated || result.Tab.WorkspaceRoot != sourceRoot {
153 t.Fatalf("fallback result = %+v", result)
154 }
155 }
156
157 func TestForkWorktreeForTabRollsBackUnusedCreation(t *testing.T) {
158 isolateDesktopUserDirs(t)
159 origInspect := inspectDeliveryWorktree
160 origCreate := createDeliveryWorktree
161 origRollback := rollbackDeliveryWorktree
162 t.Cleanup(func() {
163 inspectDeliveryWorktree = origInspect
164 createDeliveryWorktree = origCreate
165 rollbackDeliveryWorktree = origRollback
166 })
167 inspectDeliveryWorktree = func(_ context.Context, root string) worktree.Availability {
168 return worktree.Availability{Available: true, RepoRoot: root}
169 }
170 created := worktree.Result{
171 WorkspaceRoot: filepath.Join(t.TempDir(), "workspace"),
172 WorktreeRoot: filepath.Join(t.TempDir(), "worktree"),
173 SourceRoot: t.TempDir(),
174 Branch: "reasonix/delivery-test",
175 Head: "deadbeef",
176 }
177 createDeliveryWorktree = func(context.Context, string, string) (worktree.Result, error) {
178 return created, nil
179 }
180 rollbackCalls := 0
181 rollbackDeliveryWorktree = func(_ context.Context, got worktree.Result) error {
182 rollbackCalls++
183 if got != created {
184 t.Fatalf("rollback result = %+v, want %+v", got, created)
185 }
186 return nil
187 }
188
189 ctrl := newTabScopedActionController()
190 app := NewApp()
191 app.setTestCtrl(ctrl, "")
192 app.tabs["test"].Scope = "project"
193 app.tabs["test"].WorkspaceRoot = t.TempDir()
194
195 if _, err := app.ForkWorktreeForTab("test", 1); err == nil {
196 t.Fatal("expected conversation fork failure")
197 }
198 if rollbackCalls != 1 {
199 t.Fatalf("rollback calls = %d, want 1", rollbackCalls)
200 }
201 }
202
203 func TestForkWorktreeForTabPreservesReferencedWorkspaceWhenSourceCloses(t *testing.T) {
204 isolateDesktopUserDirs(t)
205 origInspect := inspectDeliveryWorktree
206 origCreate := createDeliveryWorktree
207 origRollback := rollbackDeliveryWorktree
208 t.Cleanup(func() {
209 inspectDeliveryWorktree = origInspect
210 createDeliveryWorktree = origCreate
211 rollbackDeliveryWorktree = origRollback
212 forkTabBeforePublishHookForTest.Store(nil)
213 })
214 inspectDeliveryWorktree = func(_ context.Context, root string) worktree.Availability {
215 return worktree.Availability{Available: true, RepoRoot: root}
216 }
217 worktreeRoot := filepath.Join(t.TempDir(), "worktree")
218 isolatedRoot := filepath.Join(worktreeRoot, "project")
219 if err := os.MkdirAll(isolatedRoot, 0o755); err != nil {
220 t.Fatal(err)
221 }
222 created := worktree.Result{
223 WorkspaceRoot: isolatedRoot,
224 WorktreeRoot: worktreeRoot,
225 SourceRoot: t.TempDir(),
226 Branch: "reasonix/delivery-preserved",
227 Head: "deadbeef",
228 }
229 createDeliveryWorktree = func(context.Context, string, string) (worktree.Result, error) {
230 return created, nil
231 }
232 rollbackCalls := 0
233 rollbackDeliveryWorktree = func(context.Context, worktree.Result) error {
234 rollbackCalls++
235 return nil
236 }
237
238 ctrl := &blockingForkTabController{
239 tabScopedActionController: newTabScopedActionController(),
240 path: filepath.Join(config.SessionDir(), "preserved-fork.jsonl"),
241 started: make(chan struct{}),
242 release: make(chan struct{}),
243 }
244 if err := os.MkdirAll(filepath.Dir(ctrl.path), 0o755); err != nil {
245 t.Fatal(err)
246 }
247 if err := os.WriteFile(ctrl.path, nil, 0o644); err != nil {
248 t.Fatal(err)
249 }
250 close(ctrl.release)
251 app := NewApp()
252 app.setTestCtrl(ctrl, "")
253 app.tabs["test"].Scope = "project"
254 app.tabs["test"].WorkspaceRoot = t.TempDir()
255 app.tabs["test"].TopicTitle = "Source topic"
256 hook := func() {
257 app.mu.Lock()
258 delete(app.tabs, "test")
259 app.removeTabOrderLocked("test")
260 if app.activeTabID == "test" {
261 app.activeTabID = ""
262 }
263 app.mu.Unlock()
264 }
265 forkTabBeforePublishHookForTest.Store(&hook)
266
267 result, err := app.ForkWorktreeForTab("test", 1)
268 if err == nil || result.Tab.ID != "" {
269 t.Fatalf("ForkWorktreeForTab result=%+v err=%v, want preserved attach failure", result, err)
270 }
271 if rollbackCalls != 0 {
272 t.Fatalf("rollback calls = %d, want 0 after BranchMeta references the worktree", rollbackCalls)
273 }
274 if _, err := os.Stat(worktreeRoot); err != nil {
275 t.Fatalf("referenced worktree was not preserved: %v", err)
276 }
277 meta, ok, err := agent.LoadBranchMeta(ctrl.path)
278 if err != nil || !ok {
279 t.Fatalf("LoadBranchMeta: ok=%v err=%v", ok, err)
280 }
281 if meta.WorkspaceRoot != isolatedRoot || meta.TopicID == "" {
282 t.Fatalf("fork metadata = %+v, want isolated root and topic", meta)
283 }
284 foundTopic := false
285 for _, project := range loadProjectsFile().Projects {
286 if sameProjectRoot(project.Root, isolatedRoot) {
287 foundTopic = containsDesktopString(project.Topics, meta.TopicID)
288 }
289 }
290 if !foundTopic {
291 t.Fatalf("preserved fork is not recoverable from Projects: %+v", loadProjectsFile().Projects)
292 }
293 if roots := loadWorkspaces(); len(roots) == 0 || !sameProjectRoot(roots[0], isolatedRoot) {
294 t.Fatalf("preserved workspace was not remembered: %v", roots)
295 }
296 reopened, err := app.OpenProjectTab(isolatedRoot, meta.TopicID)
297 if err != nil {
298 t.Fatalf("OpenProjectTab preserved fork: %v", err)
299 }
300 if !sameDesktopPath(reopened.SessionPath, ctrl.path) || !sameProjectRoot(reopened.WorkspaceRoot, isolatedRoot) {
301 t.Fatalf("reopened fork = %+v, want session %q in isolated project %q", reopened, ctrl.path, isolatedRoot)
302 }
303 }
304
304 lines GO