返回 DeepSeek-Reasonix
workspace_test.go
根目录 / desktop / workspace_test.go
1 package main
2
3 import (
4 "encoding/json"
5 "mime"
6 "net/http"
7 "net/http/httptest"
8 "os"
9 "os/exec"
10 "path/filepath"
11 "runtime"
12 "strings"
13 "testing"
14 "time"
15
16 "golang.org/x/text/encoding/simplifiedchinese"
17 "reasonix/internal/agent"
18 "reasonix/internal/checkpoint"
19 "reasonix/internal/config"
20 "reasonix/internal/control"
21 )
22
23 // --- workspaceStatePath ---
24
25 func TestWorkspaceStatePath(t *testing.T) {
26 // workspaceStatePath depends on config.MemoryUserDir() which needs a
27 // config dir. We just verify it returns a consistent path.
28 p1 := workspaceStatePath()
29 p2 := workspaceStatePath()
30 if p1 != p2 {
31 t.Errorf("workspaceStatePath not stable: %q vs %q", p1, p2)
32 }
33 if p1 != "" && filepath.Base(p1) != "desktop-workspace" {
34 t.Errorf("workspaceStatePath should end with desktop-workspace, got %q", p1)
35 }
36 }
37
38 // --- saveWorkspace / loadWorkspace round-trip ---
39
40 func TestSaveLoadWorkspaceRoundTrip(t *testing.T) {
41 // workspaceStatePath() resolves via os.UserConfigDir() (HOME on unix,
42 // %AppData% on Windows); isolate both so the round-trip exercises real
43 // persistence instead of no-opping or leaking into the dev config dir.
44 isolateDesktopUserDirs(t)
45 if workspaceStatePath() == "" {
46 t.Fatal("workspaceStatePath() is empty after isolating the user config dir")
47 }
48
49 dir := t.TempDir()
50 saveWorkspace(dir)
51 if got := loadWorkspace(); got != dir {
52 t.Errorf("loadWorkspace = %q, want %q", got, dir)
53 }
54 }
55
56 func TestSaveWorkspaceOnlyRemembersLastWorkspace(t *testing.T) {
57 isolateDesktopUserDirs(t)
58 first := t.TempDir()
59 second := t.TempDir()
60
61 saveWorkspace(first)
62 saveWorkspace(second)
63 saveWorkspace(first)
64
65 if got := loadWorkspace(); got != first {
66 t.Fatalf("loadWorkspace = %q, want %q", got, first)
67 }
68 if got := loadWorkspaces(); len(got) != 0 {
69 t.Fatalf("saveWorkspace should not maintain legacy workspace list, got %v", got)
70 }
71 }
72
73 func TestDesktopMCPMigrationRootsIncludesLegacyWorkspaces(t *testing.T) {
74 isolateDesktopUserDirs(t)
75 active := t.TempDir()
76 legacy := t.TempDir()
77 tabRoot := t.TempDir()
78 projectRoot := t.TempDir()
79
80 saveWorkspace(active)
81 if err := os.MkdirAll(filepath.Dir(workspaceListPath()), 0o755); err != nil {
82 t.Fatal(err)
83 }
84 b, err := json.Marshal([]string{legacy, active, legacy})
85 if err != nil {
86 t.Fatal(err)
87 }
88 if err := os.WriteFile(workspaceListPath(), b, 0o644); err != nil {
89 t.Fatal(err)
90 }
91 if err := saveProjectsFile(desktopProjectFile{Projects: []desktopProject{{Root: projectRoot}}}); err != nil {
92 t.Fatal(err)
93 }
94
95 roots := desktopMCPMigrationRoots(desktopTabsFile{
96 Tabs: []desktopTabEntry{{Scope: "project", WorkspaceRoot: tabRoot}},
97 })
98 want := []string{
99 normalizeProjectRoot(active),
100 normalizeProjectRoot(legacy),
101 normalizeProjectRoot(tabRoot),
102 normalizeProjectRoot(projectRoot),
103 }
104 if len(roots) != len(want) {
105 t.Fatalf("roots len = %d, want %d: %+v", len(roots), len(want), roots)
106 }
107 for i, root := range want {
108 if roots[i] != root {
109 t.Fatalf("roots[%d] = %q, want %q; roots=%+v", i, roots[i], root, roots)
110 }
111 }
112 }
113
114 func TestRecoverLegacyProjectSidebarRootsPreservesUpgradeProjects(t *testing.T) {
115 isolateDesktopUserDirs(t)
116 existing := t.TempDir()
117 active := t.TempDir()
118 legacy := t.TempDir()
119 tabRoot := t.TempDir()
120 missing := filepath.Join(t.TempDir(), "missing")
121
122 if err := saveProjectsFile(desktopProjectFile{Projects: []desktopProject{{Root: existing, Title: "Existing"}}}); err != nil {
123 t.Fatal(err)
124 }
125 saveWorkspace(active)
126 if err := os.MkdirAll(filepath.Dir(workspaceListPath()), 0o755); err != nil {
127 t.Fatal(err)
128 }
129 b, err := json.Marshal([]string{legacy, active, missing, legacy})
130 if err != nil {
131 t.Fatal(err)
132 }
133 if err := os.WriteFile(workspaceListPath(), b, 0o644); err != nil {
134 t.Fatal(err)
135 }
136
137 tabs := desktopTabsFile{
138 Tabs: []desktopTabEntry{
139 {Scope: "project", WorkspaceRoot: tabRoot},
140 {Scope: "project", WorkspaceRoot: missing},
141 {Scope: "global"},
142 },
143 }
144 changed, err := recoverLegacyProjectSidebarRoots(tabs)
145 if err != nil {
146 t.Fatal(err)
147 }
148 if !changed {
149 t.Fatal("recoverLegacyProjectSidebarRoots should add missing legacy projects")
150 }
151
152 projects := loadProjectsFile().Projects
153 want := []string{
154 normalizeProjectRoot(existing),
155 normalizeProjectRoot(active),
156 normalizeProjectRoot(legacy),
157 normalizeProjectRoot(tabRoot),
158 }
159 if len(projects) != len(want) {
160 t.Fatalf("project count = %d, want %d: %+v", len(projects), len(want), projects)
161 }
162 for i, root := range want {
163 if projects[i].Root != root {
164 t.Fatalf("projects[%d].Root = %q, want %q; projects=%+v", i, projects[i].Root, root, projects)
165 }
166 }
167 if _, err := os.Stat(filepath.Join(desktopConfigDir(), legacyProjectSidebarRecoveryMarker)); err != nil {
168 t.Fatalf("recovery marker was not written: %v", err)
169 }
170
171 if err := removeProject(legacy); err != nil {
172 t.Fatal(err)
173 }
174 changed, err = recoverLegacyProjectSidebarRoots(tabs)
175 if err != nil {
176 t.Fatal(err)
177 }
178 if changed {
179 t.Fatal("recovery should be one-shot after the marker is written")
180 }
181 for _, project := range loadProjectsFile().Projects {
182 if project.Root == normalizeProjectRoot(legacy) {
183 t.Fatalf("removed legacy project was restored after marker: %+v", loadProjectsFile().Projects)
184 }
185 if project.Root == normalizeProjectRoot(missing) {
186 t.Fatalf("missing legacy project should not be restored: %+v", loadProjectsFile().Projects)
187 }
188 }
189 }
190
191 func TestProjectFileUpdatesSerializeReadModifyWrite(t *testing.T) {
192 isolateDesktopUserDirs(t)
193 active := t.TempDir()
194 added := t.TempDir()
195
196 if err := saveProjectsFile(desktopProjectFile{Projects: []desktopProject{{Root: active, Title: "Active"}}}); err != nil {
197 t.Fatal(err)
198 }
199
200 entered := make(chan struct{})
201 release := make(chan struct{})
202 updateErr := make(chan error, 1)
203 go func() {
204 updateErr <- updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
205 close(entered)
206 <-release
207 for i, project := range f.Projects {
208 if project.Root == normalizeProjectRoot(active) {
209 f.Projects[i].Title = "Active edited"
210 return true, nil
211 }
212 }
213 return false, nil
214 })
215 }()
216 <-entered
217
218 addErr := make(chan error, 1)
219 go func() {
220 addErr <- addProject(added, "Added")
221 }()
222 select {
223 case err := <-addErr:
224 t.Fatalf("addProject completed while another project update was in progress: %v", err)
225 case <-time.After(10 * time.Millisecond):
226 }
227 close(release)
228 if err := <-updateErr; err != nil {
229 t.Fatal(err)
230 }
231 if err := <-addErr; err != nil {
232 t.Fatal(err)
233 }
234
235 projects := loadProjectsFile().Projects
236 if len(projects) != 2 {
237 t.Fatalf("project count = %d, want 2: %+v", len(projects), projects)
238 }
239 if projects[0].Root != normalizeProjectRoot(active) || projects[0].Title != "Active edited" {
240 t.Fatalf("active project was not preserved with edited title: %+v", projects)
241 }
242 if projects[1].Root != normalizeProjectRoot(added) || projects[1].Title != "Added" {
243 t.Fatalf("concurrent project add was lost: %+v", projects)
244 }
245
246 if err := addProject(active, ""); err != nil {
247 t.Fatal(err)
248 }
249 projects = loadProjectsFile().Projects
250 if len(projects) != 2 || projects[1].Root != normalizeProjectRoot(added) {
251 t.Fatalf("no-op addProject overwrote the added project: %+v", projects)
252 }
253 }
254
255 func TestNormalizeProjectsFileMergesEquivalentProjectRoots(t *testing.T) {
256 isolateDesktopUserDirs(t)
257 projectRoot := t.TempDir()
258 // A textually different spelling of the same folder. filepath.Join would
259 // clean the dot segment away and hand back the identical string, so build
260 // the spelling by hand.
261 equivalentRoot := projectRoot + string(filepath.Separator) + "."
262
263 f := normalizeProjectsFile(desktopProjectFile{
264 Projects: []desktopProject{
265 {Root: projectRoot, Title: "Project", Topics: []string{"topic_a"}},
266 {Root: equivalentRoot, Color: "blue", Topics: []string{"topic_b"}, PinnedTopics: []string{"topic_b"}},
267 },
268 PinnedProjects: []string{equivalentRoot},
269 SidebarOrder: []string{equivalentRoot, projectRoot},
270 })
271
272 if len(f.Projects) != 1 {
273 t.Fatalf("projects = %+v, want one merged project", f.Projects)
274 }
275 if f.Projects[0].Root != normalizeProjectRoot(projectRoot) {
276 t.Fatalf("merged root = %q, want %q", f.Projects[0].Root, normalizeProjectRoot(projectRoot))
277 }
278 if f.Projects[0].Title != "Project" || f.Projects[0].Color != "blue" {
279 t.Fatalf("merged metadata = %+v, want title and color preserved", f.Projects[0])
280 }
281 if got := f.Projects[0].Topics; len(got) != 2 || got[0] != "topic_a" || got[1] != "topic_b" {
282 t.Fatalf("merged topics = %v, want [topic_a topic_b]", got)
283 }
284 if len(f.PinnedProjects) != 1 || f.PinnedProjects[0] != f.Projects[0].Root {
285 t.Fatalf("pinned projects = %v, want canonical root %q", f.PinnedProjects, f.Projects[0].Root)
286 }
287 if len(f.SidebarOrder) != 1 || f.SidebarOrder[0] != f.Projects[0].Root {
288 t.Fatalf("sidebar order = %v, want canonical root %q", f.SidebarOrder, f.Projects[0].Root)
289 }
290 }
291
292 func TestSwitchWorkspaceReaddsRemovedProject(t *testing.T) {
293 isolateDesktopUserDirs(t)
294 projectRoot := t.TempDir()
295
296 if err := addProject(projectRoot, "Project"); err != nil {
297 t.Fatalf("add project: %v", err)
298 }
299 if err := removeProject(projectRoot); err != nil {
300 t.Fatalf("remove project: %v", err)
301 }
302 if got := loadProjectsFile().Projects; len(got) != 0 {
303 t.Fatalf("projects after remove = %+v, want none", got)
304 }
305
306 app := NewApp()
307 installNoopRuntimeEvents(app)
308 if got, err := app.SwitchWorkspace(projectRoot + string(filepath.Separator) + "."); err != nil {
309 t.Fatalf("switch workspace: %v", err)
310 } else if got != normalizeProjectRoot(projectRoot) {
311 t.Fatalf("SwitchWorkspace root = %q, want %q", got, normalizeProjectRoot(projectRoot))
312 }
313
314 projects := loadProjectsFile().Projects
315 if len(projects) != 1 || projects[0].Root != normalizeProjectRoot(projectRoot) {
316 t.Fatalf("projects after re-add = %+v, want %q", projects, normalizeProjectRoot(projectRoot))
317 }
318 if got := loadWorkspace(); got != normalizeProjectRoot(projectRoot) {
319 t.Fatalf("active workspace = %q, want %q", got, normalizeProjectRoot(projectRoot))
320 }
321 }
322
323 // flipPathASCIICase returns the path with the case of every ASCII letter
324 // swapped — on Windows an equivalent spelling of the same folder that
325 // normalizeProjectRoot cannot fold away.
326 func flipPathASCIICase(t *testing.T, path string) string {
327 t.Helper()
328 flipped := strings.Map(func(r rune) rune {
329 switch {
330 case r >= 'a' && r <= 'z':
331 return r - 'a' + 'A'
332 case r >= 'A' && r <= 'Z':
333 return r - 'A' + 'a'
334 }
335 return r
336 }, path)
337 if flipped == path {
338 t.Skipf("path %q contains no ASCII letters to flip", path)
339 }
340 return flipped
341 }
342
343 func TestNormalizeProjectsFileFoldsRootCaseOnWindows(t *testing.T) {
344 if runtime.GOOS != "windows" {
345 t.Skip("case-insensitive root matching only applies to Windows paths")
346 }
347 isolateDesktopUserDirs(t)
348 projectRoot := t.TempDir()
349 flipped := flipPathASCIICase(t, projectRoot)
350
351 f := normalizeProjectsFile(desktopProjectFile{
352 Projects: []desktopProject{
353 {Root: projectRoot, Title: "Project", Topics: []string{"topic_a"}},
354 {Root: flipped, Color: "blue", Topics: []string{"topic_b"}},
355 },
356 PinnedProjects: []string{flipped},
357 SidebarOrder: []string{flipped, projectRoot},
358 })
359
360 if len(f.Projects) != 1 {
361 t.Fatalf("projects = %+v, want case-equivalent roots merged", f.Projects)
362 }
363 canonical := f.Projects[0].Root
364 if canonical != normalizeProjectRoot(projectRoot) {
365 t.Fatalf("merged root = %q, want first spelling %q", canonical, normalizeProjectRoot(projectRoot))
366 }
367 if f.Projects[0].Title != "Project" || f.Projects[0].Color != "blue" {
368 t.Fatalf("merged metadata = %+v, want title and color preserved", f.Projects[0])
369 }
370 if got := f.Projects[0].Topics; len(got) != 2 || got[0] != "topic_a" || got[1] != "topic_b" {
371 t.Fatalf("merged topics = %v, want [topic_a topic_b]", got)
372 }
373 if len(f.PinnedProjects) != 1 || f.PinnedProjects[0] != canonical {
374 t.Fatalf("pinned projects = %v, want canonical root %q", f.PinnedProjects, canonical)
375 }
376 if len(f.SidebarOrder) != 1 || f.SidebarOrder[0] != canonical {
377 t.Fatalf("sidebar order = %v, want canonical root %q", f.SidebarOrder, canonical)
378 }
379 }
380
381 func TestProjectRootOpsFoldCaseOnWindows(t *testing.T) {
382 if runtime.GOOS != "windows" {
383 t.Skip("case-insensitive root matching only applies to Windows paths")
384 }
385 isolateDesktopUserDirs(t)
386 projectRoot := t.TempDir()
387 flipped := flipPathASCIICase(t, projectRoot)
388
389 if err := addProject(projectRoot, "Project"); err != nil {
390 t.Fatalf("add project: %v", err)
391 }
392 if err := addProject(flipped, ""); err != nil {
393 t.Fatalf("re-add project under flipped case: %v", err)
394 }
395 projects := loadProjectsFile().Projects
396 if len(projects) != 1 {
397 t.Fatalf("projects = %+v, want re-add under equivalent spelling to update in place", projects)
398 }
399 if projects[0].Root != normalizeProjectRoot(flipped) {
400 t.Fatalf("root = %q, want self-healed to latest spelling %q", projects[0].Root, normalizeProjectRoot(flipped))
401 }
402 if projects[0].Title != "Project" {
403 t.Fatalf("title = %q, want preserved across re-add", projects[0].Title)
404 }
405
406 if err := prependTopicInProjectsFile(projectRoot, "topic_a", true); err != nil {
407 t.Fatalf("prepend topic: %v", err)
408 }
409 projects = loadProjectsFile().Projects
410 if len(projects) != 1 {
411 t.Fatalf("projects = %+v, want topic prepend to reuse the case-equivalent entry", projects)
412 }
413 if got := projects[0].Topics; len(got) != 1 || got[0] != "topic_a" {
414 t.Fatalf("topics = %v, want [topic_a] on the merged entry", got)
415 }
416
417 if err := removeProject(projectRoot); err != nil {
418 t.Fatalf("remove project via original spelling: %v", err)
419 }
420 if got := loadProjectsFile().Projects; len(got) != 0 {
421 t.Fatalf("projects after remove = %+v, want none", got)
422 }
423 }
424
425 func TestSyncTabWorkspaceRootSpellingsOnWindows(t *testing.T) {
426 if runtime.GOOS != "windows" {
427 t.Skip("case-insensitive root matching only applies to Windows paths")
428 }
429 isolateDesktopUserDirs(t)
430 projectRoot := t.TempDir()
431 flipped := flipPathASCIICase(t, projectRoot)
432
433 if err := addProject(projectRoot, "Project"); err != nil {
434 t.Fatalf("add project: %v", err)
435 }
436
437 app := NewApp()
438 installNoopRuntimeEvents(app)
439 app.tabs["tab_case"] = &WorkspaceTab{
440 ID: "tab_case",
441 Scope: "project",
442 WorkspaceRoot: normalizeProjectRoot(projectRoot),
443 Ready: true,
444 disabledMCP: map[string]ServerView{},
445 }
446 app.tabOrder = []string{"tab_case"}
447
448 // Re-registering under the flipped spelling self-heals the registry root;
449 // open tabs must follow so the frontend keeps comparing one string form.
450 app.registerProjectRoot(flipped)
451
452 projects := loadProjectsFile().Projects
453 if len(projects) != 1 || projects[0].Root != normalizeProjectRoot(flipped) {
454 t.Fatalf("registry projects = %+v, want single root %q", projects, normalizeProjectRoot(flipped))
455 }
456 if got := app.tabs["tab_case"].WorkspaceRoot; got != projects[0].Root {
457 t.Fatalf("tab root = %q, want registry spelling %q", got, projects[0].Root)
458 }
459 }
460
461 func TestFindTopicSessionAfterCaseFlippedReaddOnWindows(t *testing.T) {
462 if runtime.GOOS != "windows" {
463 t.Skip("case-insensitive root matching only applies to Windows paths")
464 }
465 isolateDesktopUserDirs(t)
466 projectRoot := t.TempDir()
467 flipped := flipPathASCIICase(t, projectRoot)
468
469 // Register under original spelling.
470 if err := addProject(projectRoot, "Project"); err != nil {
471 t.Fatalf("add project: %v", err)
472 }
473 if err := prependTopicInProjectsFile(projectRoot, "topic_case", true); err != nil {
474 t.Fatalf("prepend topic: %v", err)
475 }
476
477 // Write a session file with the original root spelling in its meta.
478 sessionDir := desktopSessionDir(projectRoot)
479 if err := os.MkdirAll(sessionDir, 0o755); err != nil {
480 t.Fatalf("mkdir session dir: %v", err)
481 }
482 sessionPath := filepath.Join(sessionDir, "topic-case.jsonl")
483 if err := os.WriteFile(sessionPath, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o644); err != nil {
484 t.Fatalf("write session file: %v", err)
485 }
486 if err := agent.SaveBranchMeta(sessionPath, agent.BranchMeta{
487 TopicID: "topic_case",
488 Scope: "project",
489 WorkspaceRoot: projectRoot,
490 CreatedAt: time.Now(),
491 UpdatedAt: time.Now(),
492 }); err != nil {
493 t.Fatalf("save branch meta: %v", err)
494 }
495
496 // Re-add under the flipped-case spelling — simulates Windows Explorer
497 // or a different shell returning the same folder with different case.
498 app := NewApp()
499 installNoopRuntimeEvents(app)
500 app.registerProjectRoot(flipped)
501
502 // findTopicSessionForTarget must match the session whose meta carries
503 // the original case spelling against the registry's new (flipped) root.
504 path, _ := app.findTopicSessionForTarget("project", normalizeProjectRoot(flipped), "topic_case")
505 if path == "" {
506 t.Fatal("findTopicSessionForTarget returned empty path; session with old-case root should still match")
507 }
508 if path != sessionPath {
509 t.Fatalf("findTopicSessionForTarget = %q, want %q", path, sessionPath)
510 }
511 }
512
513 func TestDialogDefaultDirectoryFallsBackFromMissingWorkspace(t *testing.T) {
514 parent := t.TempDir()
515 missing := filepath.Join(parent, "deleted", "project")
516
517 if got := dialogDefaultDirectory(missing); got != parent {
518 t.Fatalf("dialogDefaultDirectory(%q) = %q, want %q", missing, got, parent)
519 }
520 }
521
522 func TestDialogDefaultDirectoryUsesFileParent(t *testing.T) {
523 dir := t.TempDir()
524 file := filepath.Join(dir, "reasonix.toml")
525 if err := os.WriteFile(file, []byte("default_model = \"x\"\n"), 0o644); err != nil {
526 t.Fatal(err)
527 }
528
529 if got := dialogDefaultDirectory(file); got != dir {
530 t.Fatalf("dialogDefaultDirectory(file) = %q, want %q", got, dir)
531 }
532 }
533
534 func TestDesktopSessionDirIsScopedByWorkspace(t *testing.T) {
535 t.Setenv("HOME", t.TempDir())
536 t.Setenv("USERPROFILE", t.TempDir())
537 t.Setenv("XDG_CONFIG_HOME", t.TempDir())
538
539 rootA := filepath.Join(t.TempDir(), "project-a")
540 rootB := filepath.Join(t.TempDir(), "project-b")
541 if err := os.MkdirAll(rootA, 0o755); err != nil {
542 t.Fatal(err)
543 }
544 if err := os.MkdirAll(rootB, 0o755); err != nil {
545 t.Fatal(err)
546 }
547
548 dirA := desktopSessionDir(rootA)
549 dirB := desktopSessionDir(rootB)
550 if dirA == "" || dirB == "" {
551 t.Fatalf("desktop session dirs should resolve: A=%q B=%q", dirA, dirB)
552 }
553 if dirA == dirB {
554 t.Fatalf("different workspaces must not share a desktop session dir: %q", dirA)
555 }
556 if dirA == config.SessionDir() || dirB == config.SessionDir() {
557 t.Fatalf("desktop workspace sessions should not use the global CLI session dir: A=%q B=%q global=%q", dirA, dirB, config.SessionDir())
558 }
559 wantPrefix := filepath.Join(config.MemoryUserDir(), "projects") + string(filepath.Separator)
560 if !strings.HasPrefix(dirA, wantPrefix) || filepath.Base(dirA) != "sessions" {
561 t.Fatalf("workspace session dir should live under the project state tree, got %q", dirA)
562 }
563 }
564
565 func BenchmarkDesktopSessionDir(b *testing.B) {
566 root := filepath.Join(b.TempDir(), "project")
567 if err := os.MkdirAll(root, 0o755); err != nil {
568 b.Fatal(err)
569 }
570 b.ReportAllocs()
571 for i := 0; i < b.N; i++ {
572 if desktopSessionDir(root) == "" {
573 b.Fatal("empty session dir")
574 }
575 }
576 }
577
578 // --- cwdWritable ---
579
580 func TestCwdWritable(t *testing.T) {
581 // In a normal test environment, cwd should be writable.
582 if !cwdWritable() {
583 t.Error("cwd should be writable in test environment")
584 }
585 }
586
587 func TestCwdWritableInTempDir(t *testing.T) {
588 orig, _ := os.Getwd()
589 defer os.Chdir(orig)
590
591 dir := t.TempDir()
592 os.Chdir(dir)
593 if !cwdWritable() {
594 t.Error("temp dir should be writable")
595 }
596 }
597
598 func TestReadFileTrimsPartialUTF8RuneAtPreviewBoundary(t *testing.T) {
599 orig, _ := os.Getwd()
600 defer os.Chdir(orig)
601
602 dir := t.TempDir()
603 if err := os.Chdir(dir); err != nil {
604 t.Fatal(err)
605 }
606
607 prefix := strings.Repeat("a", filePreviewLimit-1)
608 if err := os.WriteFile("large.md", []byte(prefix+"你tail"), 0o644); err != nil {
609 t.Fatal(err)
610 }
611
612 preview := (&App{}).ReadFile("large.md")
613 if preview.Err != "" {
614 t.Fatalf("ReadFile err = %q", preview.Err)
615 }
616 if preview.Binary {
617 t.Fatal("ReadFile marked valid truncated UTF-8 text as binary")
618 }
619 if !preview.Truncated {
620 t.Fatal("ReadFile did not mark oversized file as truncated")
621 }
622 if preview.Body != prefix {
623 t.Fatalf("ReadFile body len = %d, want %d", len(preview.Body), len(prefix))
624 }
625 }
626
627 func TestReadFilePreviewBinaryClassification(t *testing.T) {
628 orig, _ := os.Getwd()
629 defer os.Chdir(orig)
630
631 dir := t.TempDir()
632 if err := os.Chdir(dir); err != nil {
633 t.Fatal(err)
634 }
635
636 // NUL is the binary signal, matching the CLI read_file tool once GB18030
637 // decoding meant invalid UTF-8 alone no longer implies binary.
638 if err := os.WriteFile("binary.bin", append([]byte("data"), 0x00, 0x01, 0x02), 0o644); err != nil {
639 t.Fatal(err)
640 }
641 if p := (&App{}).ReadFile("binary.bin"); !p.Binary {
642 t.Errorf("NUL-containing file should be binary, got Body=%q", p.Body)
643 }
644
645 // Invalid UTF-8 without a NUL is decoded leniently and shown as text, with
646 // U+FFFD where bytes don't map — not hidden behind a binary classification.
647 if err := os.WriteFile("invalid.txt", append([]byte("hello"), 0xff, 'x', 'y'), 0o644); err != nil {
648 t.Fatal(err)
649 }
650 p := (&App{}).ReadFile("invalid.txt")
651 if p.Binary {
652 t.Error("invalid-but-NUL-free file should render as lossy text, not binary")
653 }
654 if !strings.ContainsRune(p.Body, '�') {
655 t.Errorf("lossy decode should mark undecodable bytes with U+FFFD, got %q", p.Body)
656 }
657 }
658
659 func TestReadFileMediaPreview(t *testing.T) {
660 orig, _ := os.Getwd()
661 defer os.Chdir(orig)
662
663 dir := t.TempDir()
664 if err := os.Chdir(dir); err != nil {
665 t.Fatal(err)
666 }
667
668 png := []byte("\x89PNG\r\n\x1a\npreview")
669 if err := os.WriteFile("shot.PNG", png, 0o644); err != nil {
670 t.Fatal(err)
671 }
672 var app App
673 image := app.ReadFile("shot.PNG")
674 if image.Err != "" {
675 t.Fatalf("ReadFile png err = %q", image.Err)
676 }
677 if image.Binary || image.Kind != "image" || image.Mime != "image/png" {
678 t.Fatalf("ReadFile png = %+v, want image preview", image)
679 }
680 if image.Body != "" {
681 t.Fatalf("media preview should have empty body, got %q", image.Body)
682 }
683 if !strings.HasPrefix(image.URL, "/__reasonix_workspace_media/") || !strings.HasSuffix(image.URL, "/shot.PNG") {
684 t.Fatalf("unexpected media URL: %q", image.URL)
685 }
686
687 if err := os.WriteFile("report.pdf", []byte("%PDF-1.4\npreview"), 0o644); err != nil {
688 t.Fatal(err)
689 }
690 pdf := app.ReadFile("report.pdf")
691 if pdf.Err != "" {
692 t.Fatalf("ReadFile pdf err = %q", pdf.Err)
693 }
694 if pdf.Binary || pdf.Kind != "pdf" || pdf.Mime != "application/pdf" {
695 t.Fatalf("ReadFile pdf = %+v, want pdf preview", pdf)
696 }
697 if pdf.Body != "" {
698 t.Fatalf("media preview should have empty body, got %q", pdf.Body)
699 }
700 if !strings.HasPrefix(pdf.URL, "/__reasonix_workspace_media/") || !strings.HasSuffix(pdf.URL, "/report.pdf") {
701 t.Fatalf("unexpected media URL: %q", pdf.URL)
702 }
703 }
704
705 func TestMediaTokenHandlerServesFile(t *testing.T) {
706 orig, _ := os.Getwd()
707 defer os.Chdir(orig)
708
709 dir := t.TempDir()
710 if err := os.Chdir(dir); err != nil {
711 t.Fatal(err)
712 }
713
714 png := []byte("\x89PNG\r\n\x1a\npreview-data")
715 if err := os.WriteFile("shot.PNG", png, 0o644); err != nil {
716 t.Fatal(err)
717 }
718
719 app := NewApp()
720 preview := app.ReadFile("shot.PNG")
721 if preview.URL == "" {
722 t.Fatal("expected URL in media preview")
723 }
724
725 mw := app.workspaceMediaMiddleware()
726 handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
727 t.Error("fallback handler should not be called for media URLs")
728 }))
729
730 req := httptest.NewRequest(http.MethodGet, preview.URL, nil)
731 rec := httptest.NewRecorder()
732 handler.ServeHTTP(rec, req)
733
734 if rec.Code != http.StatusOK {
735 t.Fatalf("expected 200, got %d", rec.Code)
736 }
737 if ct := rec.Header().Get("Content-Type"); ct != "image/png" {
738 t.Fatalf("expected Content-Type image/png, got %q", ct)
739 }
740 if cd := rec.Header().Get("Content-Disposition"); !strings.Contains(cd, "inline") {
741 t.Fatalf("expected inline Content-Disposition, got %q", cd)
742 }
743 if rec.Body.String() != string(png) {
744 t.Fatalf("body mismatch, got %q", rec.Body.String())
745 }
746 }
747
748 func TestMediaTokenHandlerEscapedFilename(t *testing.T) {
749 orig, _ := os.Getwd()
750 defer os.Chdir(orig)
751
752 dir := t.TempDir()
753 if err := os.Chdir(dir); err != nil {
754 t.Fatal(err)
755 }
756
757 name := `weird "file" name.png`
758 rawURLChars := []string{" ", `"`}
759 if runtime.GOOS == "windows" {
760 name = "weird #file name.png"
761 rawURLChars = []string{" ", "#"}
762 }
763 if err := os.WriteFile(name, []byte("png"), 0o644); err != nil {
764 t.Fatal(err)
765 }
766
767 app := NewApp()
768 preview := app.ReadFile(name)
769 for _, raw := range rawURLChars {
770 if strings.Contains(preview.URL, raw) {
771 t.Fatalf("media URL should path-escape %q in filename, got %q", raw, preview.URL)
772 }
773 }
774
775 handler := app.workspaceMediaMiddleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
776 t.Error("fallback handler should not be called")
777 }))
778 req := httptest.NewRequest(http.MethodGet, preview.URL, nil)
779 rec := httptest.NewRecorder()
780 handler.ServeHTTP(rec, req)
781
782 if rec.Code != http.StatusOK {
783 t.Fatalf("expected 200, got %d", rec.Code)
784 }
785 disposition, params, err := mime.ParseMediaType(rec.Header().Get("Content-Disposition"))
786 if err != nil {
787 t.Fatalf("Content-Disposition should parse: %v", err)
788 }
789 if disposition != "inline" || params["filename"] != name {
790 t.Fatalf("Content-Disposition = %q %#v, want inline filename %q", disposition, params, name)
791 }
792 }
793
794 func TestMediaTokenHandlerHead(t *testing.T) {
795 orig, _ := os.Getwd()
796 defer os.Chdir(orig)
797
798 dir := t.TempDir()
799 if err := os.Chdir(dir); err != nil {
800 t.Fatal(err)
801 }
802
803 if err := os.WriteFile("doc.pdf", []byte("%PDF-test"), 0o644); err != nil {
804 t.Fatal(err)
805 }
806
807 app := NewApp()
808 preview := app.ReadFile("doc.pdf")
809
810 mw := app.workspaceMediaMiddleware()
811 handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
812 t.Error("fallback handler should not be called")
813 }))
814
815 req := httptest.NewRequest(http.MethodHead, preview.URL, nil)
816 rec := httptest.NewRecorder()
817 handler.ServeHTTP(rec, req)
818
819 if rec.Code != http.StatusOK {
820 t.Fatalf("expected 200, got %d", rec.Code)
821 }
822 if ct := rec.Header().Get("Content-Type"); ct != "application/pdf" {
823 t.Fatalf("expected Content-Type application/pdf, got %q", ct)
824 }
825 }
826
827 func TestMediaTokenHandlerRangeRequest(t *testing.T) {
828 orig, _ := os.Getwd()
829 defer os.Chdir(orig)
830
831 dir := t.TempDir()
832 if err := os.Chdir(dir); err != nil {
833 t.Fatal(err)
834 }
835
836 data := []byte("0123456789ABCDEF")
837 if err := os.WriteFile("data.png", data, 0o644); err != nil {
838 t.Fatal(err)
839 }
840
841 app := NewApp()
842 preview := app.ReadFile("data.png")
843
844 mw := app.workspaceMediaMiddleware()
845 handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
846 t.Error("fallback should not be called")
847 }))
848
849 req := httptest.NewRequest(http.MethodGet, preview.URL, nil)
850 req.Header.Set("Range", "bytes=0-4")
851 rec := httptest.NewRecorder()
852 handler.ServeHTTP(rec, req)
853
854 if rec.Code != http.StatusPartialContent {
855 t.Fatalf("expected 206, got %d", rec.Code)
856 }
857 if rec.Body.String() != "01234" {
858 t.Fatalf("expected '01234', got %q", rec.Body.String())
859 }
860 }
861
862 func TestMediaTokenHandlerBadToken(t *testing.T) {
863 orig, _ := os.Getwd()
864 defer os.Chdir(orig)
865
866 dir := t.TempDir()
867 if err := os.Chdir(dir); err != nil {
868 t.Fatal(err)
869 }
870
871 app := NewApp()
872 mw := app.workspaceMediaMiddleware()
873 handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
874 w.WriteHeader(http.StatusOK)
875 }))
876
877 req := httptest.NewRequest(http.MethodGet, "/__reasonix_workspace_media/deadbeef/fake.png", nil)
878 rec := httptest.NewRecorder()
879 handler.ServeHTTP(rec, req)
880
881 if rec.Code != http.StatusNotFound {
882 t.Fatalf("expected 404 for bad token, got %d", rec.Code)
883 }
884 }
885
886 func TestMediaTokenHandlerNonGetHead(t *testing.T) {
887 orig, _ := os.Getwd()
888 defer os.Chdir(orig)
889
890 dir := t.TempDir()
891 if err := os.Chdir(dir); err != nil {
892 t.Fatal(err)
893 }
894
895 if err := os.WriteFile("test.png", []byte("png"), 0o644); err != nil {
896 t.Fatal(err)
897 }
898
899 app := NewApp()
900 preview := app.ReadFile("test.png")
901
902 mw := app.workspaceMediaMiddleware()
903 handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
904 t.Error("fallback should not be called")
905 }))
906
907 req := httptest.NewRequest(http.MethodPost, preview.URL, nil)
908 rec := httptest.NewRecorder()
909 handler.ServeHTTP(rec, req)
910
911 if rec.Code != http.StatusMethodNotAllowed {
912 t.Fatalf("expected 405 for POST, got %d", rec.Code)
913 }
914 }
915
916 func TestMediaTokenHandlerPassesUnrelatedPaths(t *testing.T) {
917 app := NewApp()
918 mw := app.workspaceMediaMiddleware()
919
920 fallbackCalled := false
921 handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
922 fallbackCalled = true
923 w.WriteHeader(http.StatusOK)
924 }))
925
926 req := httptest.NewRequest(http.MethodGet, "/index.html", nil)
927 rec := httptest.NewRecorder()
928 handler.ServeHTTP(rec, req)
929
930 if !fallbackCalled {
931 t.Fatal("expected fallback handler for non-media path")
932 }
933 }
934
935 func TestMediaTokenMaxEviction(t *testing.T) {
936 orig, _ := os.Getwd()
937 defer os.Chdir(orig)
938
939 dir := t.TempDir()
940 if err := os.Chdir(dir); err != nil {
941 t.Fatal(err)
942 }
943
944 if err := os.WriteFile("test.png", []byte("data"), 0o644); err != nil {
945 t.Fatal(err)
946 }
947
948 app := NewApp()
949 store := app.mediaTokens
950
951 // Fill beyond max to trigger eviction of oldest.
952 var oldestToken string
953 for i := 0; i < mediaTokenMax+1; i++ {
954 tok := store.create(dir+"/test.png", "test.png", "image/png", "image", 4, time.Time{})
955 if i == 0 {
956 oldestToken = tok
957 }
958 }
959
960 if store.get(oldestToken) != nil {
961 t.Fatal("oldest token should have been evicted")
962 }
963 if len(store.order) != mediaTokenMax {
964 t.Fatalf("expected %d tokens, got %d", mediaTokenMax, len(store.order))
965 }
966 }
967
968 func TestMediaTokenExpiry(t *testing.T) {
969 orig, _ := os.Getwd()
970 defer os.Chdir(orig)
971
972 dir := t.TempDir()
973 if err := os.Chdir(dir); err != nil {
974 t.Fatal(err)
975 }
976
977 if err := os.WriteFile("test.png", []byte("data"), 0o644); err != nil {
978 t.Fatal(err)
979 }
980
981 app := NewApp()
982 store := app.mediaTokens
983 tok := store.create(dir+"/test.png", "test.png", "image/png", "image", 4, time.Time{})
984
985 // Force expiry by rolling back the clock on the entry.
986 store.mu.Lock()
987 store.byTok[tok].expiresAt = time.Now().Add(-1 * time.Second)
988 store.mu.Unlock()
989
990 if e := store.get(tok); e != nil {
991 t.Fatal("expired token should return nil")
992 }
993 next := store.create(dir+"/test.png", "test.png", "image/png", "image", 4, time.Time{})
994 if next == "" || store.get(next) == nil {
995 t.Fatal("store should create and read a fresh token after expired get cleanup")
996 }
997 }
998
999 func TestReadFileTextUnchanged(t *testing.T) {
1000 orig, _ := os.Getwd()
1001 defer os.Chdir(orig)
1002
1003 dir := t.TempDir()
1004 if err := os.Chdir(dir); err != nil {
1005 t.Fatal(err)
1006 }
1007
1008 if err := os.WriteFile("hello.txt", []byte("hello world"), 0o644); err != nil {
1009 t.Fatal(err)
1010 }
1011
1012 app := NewApp()
1013 preview := app.ReadFile("hello.txt")
1014 if preview.Err != "" {
1015 t.Fatalf("ReadFile text err = %q", preview.Err)
1016 }
1017 if preview.Body != "hello world" {
1018 t.Fatalf("expected text body, got %q", preview.Body)
1019 }
1020 if preview.Kind != "" || preview.Mime != "" || preview.URL != "" {
1021 t.Fatalf("text preview should not have media fields")
1022 }
1023 }
1024
1025 func TestReadFileGB18030(t *testing.T) {
1026 orig, _ := os.Getwd()
1027 defer os.Chdir(orig)
1028
1029 dir := t.TempDir()
1030 if err := os.Chdir(dir); err != nil {
1031 t.Fatal(err)
1032 }
1033
1034 gb, _ := simplifiedchinese.GB18030.NewEncoder().String("你好世界")
1035 if err := os.WriteFile("gbk.txt", []byte(gb), 0o644); err != nil {
1036 t.Fatal(err)
1037 }
1038
1039 preview := (&App{}).ReadFile("gbk.txt")
1040 if preview.Err != "" {
1041 t.Fatalf("ReadFile err = %q", preview.Err)
1042 }
1043 if preview.Binary {
1044 t.Fatal("ReadFile should decode GB18030, not mark as binary")
1045 }
1046 if !strings.Contains(preview.Body, "你好世界") {
1047 t.Errorf("expected decoded Chinese text, got %q", preview.Body)
1048 }
1049 }
1050
1051 // --- RemoveWorkspace cleanup of active pointer ---
1052
1053 func TestRemoveWorkspaceClearsActivePointerWhenRemovingCurrentWorkspace(t *testing.T) {
1054 isolateDesktopUserDirs(t)
1055 if workspaceStatePath() == "" {
1056 t.Fatal("workspaceStatePath() is empty after isolating")
1057 }
1058
1059 dir := t.TempDir()
1060 saveWorkspace(dir)
1061 if got := loadWorkspace(); got != dir {
1062 t.Fatalf("precondition: loadWorkspace = %q, want %q", got, dir)
1063 }
1064
1065 // Simulate RemoveWorkspace's cleanup logic:
1066 // When the removed workspace equals the active one, clearWorkspace should fire.
1067 if loadWorkspace() == dir {
1068 clearWorkspace()
1069 }
1070
1071 if got := loadWorkspace(); got != "" {
1072 t.Errorf("loadWorkspace = %q after clearWorkspace, want empty", got)
1073 }
1074 }
1075
1076 func TestRemoveWorkspaceFallsBackToRemainingProject(t *testing.T) {
1077 isolateDesktopUserDirs(t)
1078
1079 // Set up two projects and make the first one active.
1080 first := t.TempDir()
1081 second := t.TempDir()
1082 saveWorkspace(first)
1083
1084 // Simulate: remove the active workspace, fall back to the other.
1085 if loadWorkspace() == first {
1086 // In the real code, loadProjectsFile() would return remaining projects.
1087 // Here we simulate falling back to the second project.
1088 saveWorkspace(second)
1089 }
1090
1091 if got := loadWorkspace(); got != second {
1092 t.Errorf("loadWorkspace = %q, want fallback to %q", got, second)
1093 }
1094 }
1095
1096 func TestClearWorkspace(t *testing.T) {
1097 isolateDesktopUserDirs(t)
1098 if workspaceStatePath() == "" {
1099 t.Fatal("workspaceStatePath() is empty after isolating")
1100 }
1101
1102 dir := t.TempDir()
1103 saveWorkspace(dir)
1104 if got := loadWorkspace(); got != dir {
1105 t.Fatalf("precondition failed: loadWorkspace = %q, want %q", got, dir)
1106 }
1107
1108 clearWorkspace()
1109 if got := loadWorkspace(); got != "" {
1110 t.Errorf("loadWorkspace after clearWorkspace = %q, want empty", got)
1111 }
1112 // Also verify the file is actually removed.
1113 if _, err := os.Stat(workspaceStatePath()); !os.IsNotExist(err) {
1114 t.Errorf("desktop-workspace file should be removed, stat err = %v", err)
1115 }
1116 }
1117
1118 // --- OpenProjectTab updates active workspace pointer ---
1119
1120 func TestOpenProjectTabUpdatesActiveWorkspacePointer(t *testing.T) {
1121 isolateDesktopUserDirs(t)
1122 if workspaceStatePath() == "" {
1123 t.Fatal("workspaceStatePath() is empty after isolating")
1124 }
1125
1126 projectRoot := t.TempDir()
1127 app := NewApp()
1128 topic, err := app.CreateTopic("project", projectRoot, "")
1129 if err != nil {
1130 t.Fatalf("create topic: %v", err)
1131 }
1132
1133 if _, err := app.OpenProjectTab(projectRoot, topic.ID); err != nil {
1134 t.Fatalf("open project tab: %v", err)
1135 }
1136
1137 if got := loadWorkspace(); got != projectRoot {
1138 t.Errorf("loadWorkspace = %q after OpenProjectTab, want %q", got, projectRoot)
1139 }
1140 }
1141
1142 func TestWindowsOpenWorkspacePathAvoidsCmdShell(t *testing.T) {
1143 src, err := os.ReadFile("open_workspace_windows.go")
1144 if err != nil {
1145 t.Fatal(err)
1146 }
1147 body := string(src)
1148 if !strings.Contains(body, "ShellExecute") {
1149 t.Fatal("Windows workspace opener should use ShellExecute")
1150 }
1151 if strings.Contains(body, "cmd") || strings.Contains(body, "/c") {
1152 t.Fatal("Windows workspace opener must not route paths through cmd.exe")
1153 }
1154 }
1155
1156 func TestParseGitStatusPorcelainZ(t *testing.T) {
1157 raw := []byte(" M changed.go\x00?? new.txt\x00R renamed.go\x00old.go\x00")
1158 got := parseGitStatusPorcelainZ(raw)
1159 if len(got) != 3 {
1160 t.Fatalf("entries = %d, want 3: %+v", len(got), got)
1161 }
1162 if got[0].Path != "changed.go" || got[0].Status != "M" {
1163 t.Fatalf("modified entry = %+v", got[0])
1164 }
1165 if got[1].Path != "new.txt" || got[1].Status != "??" {
1166 t.Fatalf("untracked entry = %+v", got[1])
1167 }
1168 if got[2].Path != "renamed.go" || got[2].OldPath != "old.go" || got[2].Status != "R" {
1169 t.Fatalf("rename entry = %+v", got[2])
1170 }
1171 }
1172
1173 func TestWorkspaceChangesNonGitDirectory(t *testing.T) {
1174 orig, _ := os.Getwd()
1175 defer os.Chdir(orig)
1176
1177 dir := t.TempDir()
1178 if err := os.Chdir(dir); err != nil {
1179 t.Fatal(err)
1180 }
1181
1182 got := (&App{}).WorkspaceChanges("")
1183 if got.GitAvailable {
1184 t.Fatal("non-git directory should mark git unavailable")
1185 }
1186 if len(got.Files) != 0 {
1187 t.Fatalf("files = %+v, want none", got.Files)
1188 }
1189 }
1190
1191 func TestWorkspaceChangesUsesRequestedTabCheckpoints(t *testing.T) {
1192 workspace := t.TempDir()
1193 sessionDir := t.TempDir()
1194 sessionA := filepath.Join(sessionDir, "a.jsonl")
1195 sessionB := filepath.Join(sessionDir, "b.jsonl")
1196 content := "old"
1197 afterExists := true
1198 now := time.Now()
1199
1200 for _, tc := range []struct {
1201 session string
1202 path string
1203 prompt string
1204 schemaVersion int
1205 afterExisted *bool
1206 afterSHA256 string
1207 }{
1208 {sessionA, "a.txt", "edit a", checkpoint.SchemaV2, &afterExists, checkpoint.Digest([]byte("new"))},
1209 {sessionB, "b.txt", "edit b", 0, nil, ""},
1210 } {
1211 ckptDir := strings.TrimSuffix(tc.session, ".jsonl") + ".ckpt"
1212 if err := os.MkdirAll(ckptDir, 0o755); err != nil {
1213 t.Fatal(err)
1214 }
1215 seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{
1216 SchemaVersion: tc.schemaVersion,
1217 Turn: 0,
1218 Time: now,
1219 Prompt: tc.prompt,
1220 Files: []checkpoint.FileSnap{{
1221 Path: tc.path, Content: &content,
1222 AfterExisted: tc.afterExisted, AfterSHA256: tc.afterSHA256,
1223 }},
1224 })
1225 }
1226
1227 ctrlA := control.New(control.Options{SessionDir: sessionDir, SessionPath: sessionA, Label: "a"})
1228 ctrlB := control.New(control.Options{SessionDir: sessionDir, SessionPath: sessionB, Label: "b"})
1229 app := &App{
1230 tabs: map[string]*WorkspaceTab{
1231 "a": {ID: "a", Scope: "project", WorkspaceRoot: workspace, Ctrl: ctrlA, Ready: true},
1232 "b": {ID: "b", Scope: "project", WorkspaceRoot: workspace, Ctrl: ctrlB, Ready: true},
1233 },
1234 activeTabID: "a",
1235 }
1236
1237 got := app.WorkspaceChanges("b")
1238 byPath := map[string]WorkspaceChangeView{}
1239 for _, file := range got.Files {
1240 byPath[file.Path] = file
1241 }
1242 if _, ok := byPath["a.txt"]; ok {
1243 t.Fatalf("requested tab b included active tab a changes: %+v", got.Files)
1244 }
1245 if byPath["b.txt"].LatestPrompt != "edit b" {
1246 t.Fatalf("requested tab b changes = %+v, want b.txt from tab b", got.Files)
1247 }
1248 if byPath["b.txt"].CanSessionRevert {
1249 t.Fatalf("legacy checkpoint must not enable destructive one-click revert: %+v", byPath["b.txt"])
1250 }
1251 gotA := app.WorkspaceChanges("a")
1252 if len(gotA.Files) != 1 || !gotA.Files[0].CanSessionRevert {
1253 t.Fatalf("verified v2 checkpoint should enable session revert: %+v", gotA.Files)
1254 }
1255 }
1256
1257 func TestWorkspaceChangesGitStatus(t *testing.T) {
1258 if _, err := exec.LookPath("git"); err != nil {
1259 t.Skip("git not installed")
1260 }
1261 orig, _ := os.Getwd()
1262 defer os.Chdir(orig)
1263
1264 dir := t.TempDir()
1265 if err := os.Chdir(dir); err != nil {
1266 t.Fatal(err)
1267 }
1268 runGit(t, "init")
1269 runGit(t, "checkout", "-b", "feature/test")
1270 if err := os.WriteFile("tracked.txt", []byte("v1\n"), 0o644); err != nil {
1271 t.Fatal(err)
1272 }
1273 runGit(t, "add", "tracked.txt")
1274 if err := os.WriteFile("tracked.txt", []byte("v2\n"), 0o644); err != nil {
1275 t.Fatal(err)
1276 }
1277 if err := os.WriteFile("untracked.txt", []byte("new\n"), 0o644); err != nil {
1278 t.Fatal(err)
1279 }
1280
1281 got := (&App{}).WorkspaceChanges("")
1282 if !got.GitAvailable {
1283 t.Fatalf("git unavailable: %s", got.GitErr)
1284 }
1285 if got.GitBranch != "feature/test" {
1286 t.Fatalf("git branch = %q, want feature/test", got.GitBranch)
1287 }
1288 byPath := map[string]WorkspaceChangeView{}
1289 for _, file := range got.Files {
1290 byPath[file.Path] = file
1291 }
1292 if byPath["tracked.txt"].GitStatus == "" {
1293 t.Fatalf("tracked.txt missing git status: %+v", got.Files)
1294 }
1295 if byPath["untracked.txt"].GitStatus != "??" {
1296 t.Fatalf("untracked.txt = %+v", byPath["untracked.txt"])
1297 }
1298 }
1299
1300 func TestWorkspaceChangesGitStatusFromRepoSubdirectory(t *testing.T) {
1301 if _, err := exec.LookPath("git"); err != nil {
1302 t.Skip("git not installed")
1303 }
1304 orig, _ := os.Getwd()
1305 defer os.Chdir(orig)
1306
1307 repo := t.TempDir()
1308 if err := os.Chdir(repo); err != nil {
1309 t.Fatal(err)
1310 }
1311 runGit(t, "init")
1312 if err := os.MkdirAll("sub", 0o755); err != nil {
1313 t.Fatal(err)
1314 }
1315 if err := os.WriteFile(filepath.Join("sub", "tracked.txt"), []byte("v1\n"), 0o644); err != nil {
1316 t.Fatal(err)
1317 }
1318 runGit(t, "add", filepath.Join("sub", "tracked.txt"))
1319 if err := os.WriteFile(filepath.Join("sub", "tracked.txt"), []byte("v2\n"), 0o644); err != nil {
1320 t.Fatal(err)
1321 }
1322 if err := os.WriteFile(filepath.Join("sub", "untracked.txt"), []byte("new\n"), 0o644); err != nil {
1323 t.Fatal(err)
1324 }
1325 if err := os.WriteFile("outside.txt", []byte("outside\n"), 0o644); err != nil {
1326 t.Fatal(err)
1327 }
1328 if err := os.Chdir(filepath.Join(repo, "sub")); err != nil {
1329 t.Fatal(err)
1330 }
1331
1332 got := (&App{}).WorkspaceChanges("")
1333 if !got.GitAvailable {
1334 t.Fatalf("git unavailable: %s", got.GitErr)
1335 }
1336 byPath := map[string]WorkspaceChangeView{}
1337 for _, file := range got.Files {
1338 byPath[file.Path] = file
1339 }
1340 if byPath["tracked.txt"].GitStatus == "" {
1341 t.Fatalf("tracked.txt missing git status: %+v", got.Files)
1342 }
1343 if byPath["untracked.txt"].GitStatus != "??" {
1344 t.Fatalf("untracked.txt = %+v", byPath["untracked.txt"])
1345 }
1346 if _, ok := byPath["sub/tracked.txt"]; ok {
1347 t.Fatalf("git status path should be workspace-relative, got %+v", got.Files)
1348 }
1349 if _, ok := byPath["outside.txt"]; ok {
1350 t.Fatalf("changes outside the opened workspace should be hidden: %+v", got.Files)
1351 }
1352 }
1353
1354 func TestWorkspaceChangesUntrackedDirectoryListsFiles(t *testing.T) {
1355 if _, err := exec.LookPath("git"); err != nil {
1356 t.Skip("git not installed")
1357 }
1358 orig, _ := os.Getwd()
1359 defer os.Chdir(orig)
1360
1361 dir := t.TempDir()
1362 if err := os.Chdir(dir); err != nil {
1363 t.Fatal(err)
1364 }
1365 runGit(t, "init")
1366 if err := os.MkdirAll(filepath.Join("newdir", "nested"), 0o755); err != nil {
1367 t.Fatal(err)
1368 }
1369 if err := os.WriteFile(filepath.Join("newdir", "nested", "file.txt"), []byte("new\n"), 0o644); err != nil {
1370 t.Fatal(err)
1371 }
1372
1373 got := (&App{}).WorkspaceChanges("")
1374 byPath := map[string]WorkspaceChangeView{}
1375 for _, file := range got.Files {
1376 byPath[file.Path] = file
1377 }
1378 if byPath["newdir/"].GitStatus != "" {
1379 t.Fatalf("directory should not be listed as a changed file: %+v", got.Files)
1380 }
1381 if byPath["newdir/nested/file.txt"].GitStatus != "??" {
1382 t.Fatalf("untracked file missing from directory: %+v", got.Files)
1383 }
1384 }
1385
1386 func TestWorkspaceChangesGitBranchDetachedHead(t *testing.T) {
1387 if _, err := exec.LookPath("git"); err != nil {
1388 t.Skip("git not installed")
1389 }
1390 orig, _ := os.Getwd()
1391 defer os.Chdir(orig)
1392
1393 dir := t.TempDir()
1394 if err := os.Chdir(dir); err != nil {
1395 t.Fatal(err)
1396 }
1397 runGit(t, "init")
1398 runGit(t, "config", "user.email", "test@example.com")
1399 runGit(t, "config", "user.name", "Test User")
1400 if err := os.WriteFile("tracked.txt", []byte("v1\n"), 0o644); err != nil {
1401 t.Fatal(err)
1402 }
1403 runGit(t, "add", "tracked.txt")
1404 runGit(t, "commit", "-m", "init")
1405 short := gitOutput(t, "rev-parse", "--short", "HEAD")
1406 runGit(t, "checkout", "--detach", "HEAD")
1407
1408 got := (&App{}).WorkspaceChanges("")
1409 if !got.GitAvailable {
1410 t.Fatalf("git unavailable: %s", got.GitErr)
1411 }
1412 if got.GitBranch != "@"+short {
1413 t.Fatalf("git branch = %q, want @%s", got.GitBranch, short)
1414 }
1415 }
1416
1417 func TestWorkspaceChangeDetailIncludesStagedAndUnstagedChanges(t *testing.T) {
1418 if _, err := exec.LookPath("git"); err != nil {
1419 t.Skip("git not installed")
1420 }
1421 repo := t.TempDir()
1422 runGitIn(t, repo, "init")
1423 runGitIn(t, repo, "config", "user.email", "test@example.com")
1424 runGitIn(t, repo, "config", "user.name", "Test User")
1425 path := filepath.Join(repo, "tracked.txt")
1426 if err := os.WriteFile(path, []byte("v1\nkeep\n"), 0o644); err != nil {
1427 t.Fatal(err)
1428 }
1429 runGitIn(t, repo, "add", "tracked.txt")
1430 runGitIn(t, repo, "commit", "-m", "initial")
1431 if err := os.WriteFile(path, []byte("v2\nkeep\n"), 0o644); err != nil {
1432 t.Fatal(err)
1433 }
1434 runGitIn(t, repo, "add", "tracked.txt")
1435 if err := os.WriteFile(path, []byte("v3\nkeep\nnew\n"), 0o644); err != nil {
1436 t.Fatal(err)
1437 }
1438
1439 app := &App{tabs: map[string]*WorkspaceTab{"tab": {ID: "tab", WorkspaceRoot: repo}}}
1440 detail, err := app.WorkspaceChangeDetail("tab", "tracked.txt")
1441 if err != nil {
1442 t.Fatal(err)
1443 }
1444 if detail.Source != "git" || detail.Diff == nil {
1445 t.Fatalf("detail = %+v, want git patch", detail)
1446 }
1447 if !strings.Contains(*detail.Diff, "-v1") || !strings.Contains(*detail.Diff, "+v3") || strings.Contains(*detail.Diff, "+v2") {
1448 t.Fatalf("patch should describe HEAD to current worktree, got:\n%s", *detail.Diff)
1449 }
1450 if detail.Added != 2 || detail.Removed != 1 {
1451 t.Fatalf("tallies = +%d/-%d, want +2/-1", detail.Added, detail.Removed)
1452 }
1453 }
1454
1455 func TestWorkspaceChangeDetailSynthesizesUntrackedFile(t *testing.T) {
1456 if _, err := exec.LookPath("git"); err != nil {
1457 t.Skip("git not installed")
1458 }
1459 repo := t.TempDir()
1460 runGitIn(t, repo, "init")
1461 if err := os.WriteFile(filepath.Join(repo, "new.txt"), []byte("one\ntwo\n"), 0o644); err != nil {
1462 t.Fatal(err)
1463 }
1464 app := &App{tabs: map[string]*WorkspaceTab{"tab": {ID: "tab", WorkspaceRoot: repo}}}
1465 detail, err := app.WorkspaceChangeDetail("tab", "new.txt")
1466 if err != nil {
1467 t.Fatal(err)
1468 }
1469 if detail.Source != "git" || detail.Diff == nil || !strings.Contains(*detail.Diff, "+one") {
1470 t.Fatalf("untracked detail = %+v", detail)
1471 }
1472 if detail.Added != 2 || detail.Removed != 0 {
1473 t.Fatalf("untracked tallies = +%d/-%d, want +2/-0", detail.Added, detail.Removed)
1474 }
1475 }
1476
1477 func TestWorkspaceChangeDetailBoundsTrackedPatch(t *testing.T) {
1478 if _, err := exec.LookPath("git"); err != nil {
1479 t.Skip("git not installed")
1480 }
1481 repo := t.TempDir()
1482 runGitIn(t, repo, "init")
1483 runGitIn(t, repo, "config", "user.email", "test@example.com")
1484 runGitIn(t, repo, "config", "user.name", "Test User")
1485 path := filepath.Join(repo, "large.txt")
1486 if err := os.WriteFile(path, []byte(strings.Repeat("a", workspaceChangeDetailLimit+128)), 0o644); err != nil {
1487 t.Fatal(err)
1488 }
1489 runGitIn(t, repo, "add", "large.txt")
1490 runGitIn(t, repo, "commit", "-m", "initial")
1491 if err := os.WriteFile(path, []byte(strings.Repeat("b", workspaceChangeDetailLimit+128)), 0o644); err != nil {
1492 t.Fatal(err)
1493 }
1494
1495 app := &App{tabs: map[string]*WorkspaceTab{"tab": {ID: "tab", WorkspaceRoot: repo}}}
1496 detail, err := app.WorkspaceChangeDetail("tab", "large.txt")
1497 if err != nil {
1498 t.Fatal(err)
1499 }
1500 if detail.Source != "git" || !detail.Truncated || detail.Diff != nil {
1501 t.Fatalf("large tracked detail = %+v, want bounded git result", detail)
1502 }
1503 }
1504
1505 func TestWorkspaceChangeDetailBoundsUntrackedFile(t *testing.T) {
1506 if _, err := exec.LookPath("git"); err != nil {
1507 t.Skip("git not installed")
1508 }
1509 repo := t.TempDir()
1510 runGitIn(t, repo, "init")
1511 if err := os.WriteFile(filepath.Join(repo, "large.txt"), []byte(strings.Repeat("x", workspaceChangeDetailLimit+1)), 0o644); err != nil {
1512 t.Fatal(err)
1513 }
1514
1515 app := &App{tabs: map[string]*WorkspaceTab{"tab": {ID: "tab", WorkspaceRoot: repo}}}
1516 detail, err := app.WorkspaceChangeDetail("tab", "large.txt")
1517 if err != nil {
1518 t.Fatal(err)
1519 }
1520 if detail.Source != "git" || !detail.Truncated || detail.Diff != nil {
1521 t.Fatalf("large untracked detail = %+v, want bounded git result", detail)
1522 }
1523 }
1524
1525 func TestWorkspaceChangeDetailBoundsCheckpointSnapshot(t *testing.T) {
1526 workspace := t.TempDir()
1527 sessionDir := t.TempDir()
1528 sessionPath := filepath.Join(sessionDir, "session.jsonl")
1529 checkpointDir := strings.TrimSuffix(sessionPath, ".jsonl") + ".ckpt"
1530 if err := os.MkdirAll(checkpointDir, 0o755); err != nil {
1531 t.Fatal(err)
1532 }
1533 original := strings.Repeat("before", workspaceChangeDetailLimit/6+1)
1534 seedCheckpoint(t, checkpointDir, checkpoint.Checkpoint{
1535 Turn: 0,
1536 Time: time.Now(),
1537 Files: []checkpoint.FileSnap{{Path: filepath.Join(workspace, "large.txt"), Content: &original}},
1538 })
1539 if err := os.WriteFile(filepath.Join(workspace, "large.txt"), []byte("after\n"), 0o644); err != nil {
1540 t.Fatal(err)
1541 }
1542 ctrl := control.New(control.Options{
1543 SessionDir: sessionDir, SessionPath: sessionPath, WorkspaceRoot: workspace, Label: "session",
1544 })
1545 app := &App{tabs: map[string]*WorkspaceTab{"tab": {ID: "tab", WorkspaceRoot: workspace, Ctrl: ctrl}}}
1546 detail, err := app.WorkspaceChangeDetail("tab", "large.txt")
1547 if err != nil {
1548 t.Fatal(err)
1549 }
1550 if detail.Source != "session" || !detail.Truncated || detail.Diff != nil {
1551 t.Fatalf("large checkpoint detail = %+v, want bounded session result", detail)
1552 }
1553 }
1554
1555 func TestWorkspaceChangeDetailFallsBackToRequestedTabCheckpoint(t *testing.T) {
1556 workspace := t.TempDir()
1557 sessionDir := t.TempDir()
1558 sessionPath := filepath.Join(sessionDir, "session.jsonl")
1559 checkpointDir := strings.TrimSuffix(sessionPath, ".jsonl") + ".ckpt"
1560 if err := os.MkdirAll(checkpointDir, 0o755); err != nil {
1561 t.Fatal(err)
1562 }
1563 original := "before\n"
1564 seedCheckpoint(t, checkpointDir, checkpoint.Checkpoint{
1565 Turn: 0,
1566 Time: time.Now(),
1567 Files: []checkpoint.FileSnap{{Path: filepath.Join(workspace, "file.txt"), Content: &original}},
1568 })
1569 if err := os.WriteFile(filepath.Join(workspace, "file.txt"), []byte("after\n"), 0o644); err != nil {
1570 t.Fatal(err)
1571 }
1572 ctrl := control.New(control.Options{
1573 SessionDir: sessionDir, SessionPath: sessionPath, WorkspaceRoot: workspace, Label: "session",
1574 })
1575 app := &App{tabs: map[string]*WorkspaceTab{"tab": {ID: "tab", WorkspaceRoot: workspace, Ctrl: ctrl}}}
1576 detail, err := app.WorkspaceChangeDetail("tab", "file.txt")
1577 if err != nil {
1578 t.Fatal(err)
1579 }
1580 if detail.Source != "session" || detail.Diff == nil || !strings.Contains(*detail.Diff, "-before") || !strings.Contains(*detail.Diff, "+after") {
1581 t.Fatalf("checkpoint detail = %+v", detail)
1582 }
1583 if _, err := app.WorkspaceChangeDetail("tab", "../outside.txt"); err == nil {
1584 t.Fatal("WorkspaceChangeDetail accepted a path outside the workspace")
1585 }
1586 }
1587
1588 func TestWorkspaceGitHistory(t *testing.T) {
1589 if _, err := exec.LookPath("git"); err != nil {
1590 t.Skip("git not installed")
1591 }
1592 orig, _ := os.Getwd()
1593 defer os.Chdir(orig)
1594
1595 dir := t.TempDir()
1596 if err := os.Chdir(dir); err != nil {
1597 t.Fatal(err)
1598 }
1599
1600 runGit(t, "init")
1601 runGit(t, "config", "user.email", "test@example.com")
1602 runGit(t, "config", "user.name", "Test User")
1603
1604 if err := os.WriteFile("file1.txt", []byte("v1\n"), 0o644); err != nil {
1605 t.Fatal(err)
1606 }
1607 runGit(t, "add", "file1.txt")
1608 runGit(t, "commit", "-m", "init file1")
1609
1610 if err := os.WriteFile("file2.txt", []byte("v1\n"), 0o644); err != nil {
1611 t.Fatal(err)
1612 }
1613 runGit(t, "add", "file2.txt")
1614 runGit(t, "commit", "-m", "init file2")
1615
1616 app := &App{}
1617 history, err := app.WorkspaceGitHistory("", "")
1618 if err != nil {
1619 t.Fatalf("WorkspaceGitHistory err = %v", err)
1620 }
1621 if len(history) != 2 {
1622 t.Fatalf("expected 2 commits, got %d", len(history))
1623 }
1624 if history[0].Message != "init file2" {
1625 t.Errorf("expected latest commit message 'init file2', got %q", history[0].Message)
1626 }
1627 if history[1].Message != "init file1" {
1628 t.Errorf("expected older commit message 'init file1', got %q", history[1].Message)
1629 }
1630
1631 // Test history for specific file
1632 history, err = app.WorkspaceGitHistory("", "file1.txt")
1633 if err != nil {
1634 t.Fatalf("WorkspaceGitHistory err = %v", err)
1635 }
1636 if len(history) != 1 {
1637 t.Fatalf("expected 1 commit for file1.txt, got %d", len(history))
1638 }
1639 if history[0].Message != "init file1" {
1640 t.Errorf("expected commit message 'init file1', got %q", history[0].Message)
1641 }
1642 }
1643
1644 func TestEmptyGitArrayResultsAreNonNil(t *testing.T) {
1645 if _, err := exec.LookPath("git"); err != nil {
1646 t.Skip("git not installed")
1647 }
1648 orig, _ := os.Getwd()
1649 defer os.Chdir(orig)
1650
1651 dir := t.TempDir()
1652 if err := os.Chdir(dir); err != nil {
1653 t.Fatal(err)
1654 }
1655 runGit(t, "init")
1656 runGit(t, "config", "user.email", "test@example.com")
1657 runGit(t, "config", "user.name", "Test User")
1658
1659 app := &App{}
1660 branches, err := app.GitBranches()
1661 if err != nil {
1662 t.Fatalf("GitBranches err = %v", err)
1663 }
1664 if branches == nil {
1665 t.Fatal("GitBranches returned nil for an unborn repository; frontend expects []")
1666 }
1667
1668 if err := os.WriteFile("tracked.txt", []byte("content\n"), 0o644); err != nil {
1669 t.Fatal(err)
1670 }
1671 runGit(t, "add", "tracked.txt")
1672 runGit(t, "commit", "-m", "initial")
1673
1674 history, err := app.WorkspaceGitHistory("", "never-existed.txt")
1675 if err != nil {
1676 t.Fatalf("WorkspaceGitHistory empty path err = %v", err)
1677 }
1678 if history == nil {
1679 t.Fatal("WorkspaceGitHistory returned nil for a path without commits; frontend expects []")
1680 }
1681 }
1682
1683 func TestWorkspaceGitHistoryUsesRequestedTabWorkspace(t *testing.T) {
1684 if _, err := exec.LookPath("git"); err != nil {
1685 t.Skip("git not installed")
1686 }
1687 orig, _ := os.Getwd()
1688 defer os.Chdir(orig)
1689
1690 makeRepo := func(name, message string) string {
1691 t.Helper()
1692 dir := filepath.Join(t.TempDir(), name)
1693 if err := os.MkdirAll(dir, 0o755); err != nil {
1694 t.Fatal(err)
1695 }
1696 if err := os.Chdir(dir); err != nil {
1697 t.Fatal(err)
1698 }
1699 runGit(t, "init")
1700 runGit(t, "config", "user.email", "test@example.com")
1701 runGit(t, "config", "user.name", "Test User")
1702 if err := os.WriteFile("file.txt", []byte(message+"\n"), 0o644); err != nil {
1703 t.Fatal(err)
1704 }
1705 runGit(t, "add", "file.txt")
1706 runGit(t, "commit", "-m", message)
1707 return dir
1708 }
1709
1710 repoA := makeRepo("a", "repo a commit")
1711 repoB := makeRepo("b", "repo b commit")
1712 app := &App{
1713 tabs: map[string]*WorkspaceTab{
1714 "a": {ID: "a", Scope: "project", WorkspaceRoot: repoA, Ready: true},
1715 "b": {ID: "b", Scope: "project", WorkspaceRoot: repoB, Ready: true},
1716 },
1717 activeTabID: "a",
1718 }
1719
1720 history, err := app.WorkspaceGitHistory("b", "")
1721 if err != nil {
1722 t.Fatalf("WorkspaceGitHistory err = %v", err)
1723 }
1724 if len(history) != 1 || history[0].Message != "repo b commit" {
1725 t.Fatalf("history for requested tab = %+v, want repo b commit", history)
1726 }
1727 }
1728
1729 func TestWorkspaceGitCommitDetail(t *testing.T) {
1730 if _, err := exec.LookPath("git"); err != nil {
1731 t.Skip("git not installed")
1732 }
1733 orig, _ := os.Getwd()
1734 defer os.Chdir(orig)
1735
1736 dir := t.TempDir()
1737 if err := os.Chdir(dir); err != nil {
1738 t.Fatal(err)
1739 }
1740
1741 runGit(t, "init")
1742 runGit(t, "config", "user.email", "test@example.com")
1743 runGit(t, "config", "user.name", "Test User")
1744
1745 if err := os.WriteFile("file1.txt", []byte("v1\n"), 0o644); err != nil {
1746 t.Fatal(err)
1747 }
1748 runGit(t, "add", "file1.txt")
1749 runGit(t, "commit", "-m", "init file1")
1750
1751 if err := os.WriteFile("file1.txt", []byte("v2\n"), 0o644); err != nil {
1752 t.Fatal(err)
1753 }
1754 runGit(t, "add", "file1.txt")
1755 runGit(t, "commit", "-m", "update file1")
1756
1757 hash := gitOutput(t, "rev-parse", "HEAD")
1758
1759 app := &App{}
1760
1761 // Test project level detail
1762 detail, err := app.WorkspaceGitCommitDetail("", hash, "")
1763 if err != nil {
1764 t.Fatalf("WorkspaceGitCommitDetail err = %v", err)
1765 }
1766 if len(detail.Files) != 1 || detail.Files[0] != "file1.txt" {
1767 t.Fatalf("expected files [file1.txt], got %v", detail.Files)
1768 }
1769 if detail.Diff != nil {
1770 t.Fatal("expected nil diff for project level")
1771 }
1772
1773 // Test file level detail
1774 detail, err = app.WorkspaceGitCommitDetail("", hash, "file1.txt")
1775 if err != nil {
1776 t.Fatalf("WorkspaceGitCommitDetail err = %v", err)
1777 }
1778 if len(detail.Files) != 0 {
1779 t.Fatalf("expected no files for file level, got %v", detail.Files)
1780 }
1781 if detail.Diff == nil || !strings.Contains(*detail.Diff, "+v2") {
1782 t.Fatalf("expected diff to contain '+v2', got %v", detail.Diff)
1783 }
1784 }
1785
1786 func runGit(t *testing.T, args ...string) {
1787 t.Helper()
1788 cmd := exec.Command("git", args...)
1789 out, err := cmd.CombinedOutput()
1790 if err != nil {
1791 t.Fatalf("git %v: %v\n%s", args, err, out)
1792 }
1793 }
1794
1795 func runGitIn(t *testing.T, dir string, args ...string) {
1796 t.Helper()
1797 cmd := exec.Command("git", args...)
1798 cmd.Dir = dir
1799 out, err := cmd.CombinedOutput()
1800 if err != nil {
1801 t.Fatalf("git %v in %s: %v\n%s", args, dir, err, out)
1802 }
1803 }
1804
1805 func gitOutput(t *testing.T, args ...string) string {
1806 t.Helper()
1807 cmd := exec.Command("git", args...)
1808 out, err := cmd.CombinedOutput()
1809 if err != nil {
1810 t.Fatalf("git %v: %v\n%s", args, err, out)
1811 }
1812 return strings.TrimSpace(string(out))
1813 }
1814
1815 // --- settings_app.go helpers ---
1816 // These are unexported but in the same package, so we can test them.
1817
1818 func TestOrDefault(t *testing.T) {
1819 if orDefault("", "fallback") != "fallback" {
1820 t.Error("empty should return default")
1821 }
1822 if orDefault("value", "fallback") != "value" {
1823 t.Error("non-empty should return value")
1824 }
1825 }
1826
1827 func TestTrimList(t *testing.T) {
1828 got := trimList([]string{" a ", "", " b ", " "})
1829 if len(got) != 2 || got[0] != "a" || got[1] != "b" {
1830 t.Errorf("trimList = %v", got)
1831 }
1832 }
1833
1834 func TestTrimListEmpty(t *testing.T) {
1835 got := trimList(nil)
1836 if len(got) != 0 {
1837 t.Errorf("nil = %v, want empty", got)
1838 }
1839 }
1840
1841 func TestNonNil(t *testing.T) {
1842 if got := nonNil(nil); got == nil || len(got) != 0 {
1843 t.Errorf("nonNil(nil) = %v, want empty non-nil", got)
1844 }
1845 s := []string{"a"}
1846 if got := nonNil(s); got[0] != "a" {
1847 t.Errorf("nonNil should pass through")
1848 }
1849 }
1850
1850 lines GO