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