diff --git a/.gitignore b/.gitignore index 31837d77..d0d72309 100644 --- a/.gitignore +++ b/.gitignore @@ -44,8 +44,6 @@ coverage.html tmux-*.log .playwright-mcp/.mcp.json -# Spotlight mode -.spotlight-active .playwright-mcp/ .mcp.json .gstack/ diff --git a/desktop/capabilities.json b/desktop/capabilities.json index 0f5d454f..2e8f61b4 100644 --- a/desktop/capabilities.json +++ b/desktop/capabilities.json @@ -134,9 +134,6 @@ "Settings": { "note": "s, \u2318,, titlebar gear" }, - "Spotlight": { - "note": "f opens the fuzzy task palette" - }, "ToggleDangerous": { "note": "! cycles permission mode for new executions" }, diff --git a/internal/config/keybindings.go b/internal/config/keybindings.go index c9dd54cd..0e2dce31 100644 --- a/internal/config/keybindings.go +++ b/internal/config/keybindings.go @@ -53,8 +53,6 @@ type KeybindingsConfig struct { CollapseDone *KeybindingConfig `yaml:"collapse_done,omitempty"` OpenBrowser *KeybindingConfig `yaml:"open_browser,omitempty"` OpenPR *KeybindingConfig `yaml:"open_pr,omitempty"` - Spotlight *KeybindingConfig `yaml:"spotlight,omitempty"` - SpotlightSync *KeybindingConfig `yaml:"spotlight_sync,omitempty"` } // DefaultKeybindingsConfigPath returns the default path for the keybindings config file. @@ -260,14 +258,5 @@ open_browser: open_pr: keys: ["G"] help: "open PR" - -# Spotlight mode -spotlight: - keys: ["f"] - help: "spotlight" - -spotlight_sync: - keys: ["F"] - help: "spotlight sync" ` } diff --git a/internal/executor/executor.go b/internal/executor/executor.go index 2a6e908a..700fdd91 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -4396,7 +4396,6 @@ func writeWorkflowMCPConfig(worktreePath string, taskID int64, configDir string) "taskyou_list_tasks", "taskyou_get_project_context", "taskyou_set_project_context", - "taskyou_spotlight", }, } diff --git a/internal/executor/executor_test.go b/internal/executor/executor_test.go index dd29dd14..a023875a 100644 --- a/internal/executor/executor_test.go +++ b/internal/executor/executor_test.go @@ -1522,7 +1522,6 @@ func TestWriteWorkflowMCPConfig(t *testing.T) { "taskyou_list_tasks", "taskyou_get_project_context", "taskyou_set_project_context", - "taskyou_spotlight", } if len(autoApprove) != len(expectedTools) { t.Errorf("autoApprove has %d items, want %d", len(autoApprove), len(expectedTools)) @@ -1729,31 +1728,6 @@ func TestWriteWorkflowMCPConfig(t *testing.T) { } }) - t.Run("auto-approves taskyou_spotlight", func(t *testing.T) { - configPath := setupTempConfigDir(t) - worktreePath := t.TempDir() - - if err := writeWorkflowMCPConfig(worktreePath, 1, ""); err != nil { - t.Fatalf("unexpected error: %v", err) - } - - taskyou := readWorkflowConfig(t, configPath, worktreePath) - autoApprove, ok := taskyou["autoApprove"].([]interface{}) - if !ok { - t.Fatal("expected autoApprove array") - } - var found bool - for _, v := range autoApprove { - if v == "taskyou_spotlight" { - found = true - break - } - } - if !found { - t.Errorf("taskyou_spotlight missing from autoApprove list: %v", autoApprove) - } - }) - t.Run("honors per-project CLAUDE_CONFIG_DIR override", func(t *testing.T) { // Project with a custom claude config dir must get its MCP config // written there, not to the default ~/.claude.json. Otherwise Claude diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 507aafc3..0de499b5 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -16,7 +16,6 @@ import ( "github.com/bborn/workflow/internal/db" "github.com/bborn/workflow/internal/github" - "github.com/bborn/workflow/internal/spotlight" "github.com/bborn/workflow/internal/tasksummary" ) @@ -300,21 +299,6 @@ func (s *Server) handleRequest(req *jsonRPCRequest) { "required": []string{"context"}, }, }, - { - Name: "taskyou_spotlight", - Description: "Enable spotlight mode to sync worktree changes back to the main repository for testing. This bridges the gap between isolated task development and application runtime by syncing git-tracked files to where your app runs. Use 'start' to enable, 'stop' to restore original state, 'sync' for manual sync, or 'status' to check current state.", - InputSchema: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "action": map[string]interface{}{ - "type": "string", - "enum": []string{"start", "stop", "sync", "status"}, - "description": "Action to perform: 'start' enables spotlight mode and syncs files, 'stop' disables and restores original state, 'sync' manually syncs files (while active), 'status' shows current spotlight state", - }, - }, - "required": []string{"action"}, - }, - }, }, }) @@ -715,72 +699,6 @@ This saves future tasks from re-exploring the codebase.`}, }, }) - case "taskyou_spotlight": - action, _ := params.Arguments["action"].(string) - if action == "" { - s.sendError(id, -32602, "action is required") - return - } - - // Get current task - task, err := s.db.GetTask(s.taskID) - if err != nil || task == nil { - s.sendError(id, -32603, "Failed to get current task") - return - } - - if task.WorktreePath == "" { - s.sendError(id, -32602, "Task has no worktree (spotlight requires a worktree)") - return - } - - // Get the project directory (main repo) - project, err := s.db.GetProjectByName(task.Project) - if err != nil || project == nil { - s.sendError(id, -32603, "Failed to get project directory") - return - } - mainRepoDir := project.Path - - // Spotlight requires worktree isolation - it syncs changes between a worktree and the main repo. - // For non-worktree projects, the task already runs in the project directory. - if !project.UsesWorktrees() { - s.sendError(id, -32602, "Spotlight is not available for non-worktree projects (task already runs in project directory)") - return - } - - // Handle spotlight actions - var result string - switch action { - case "start": - result, err = spotlight.Start(task.WorktreePath, mainRepoDir) - case "stop": - result, err = spotlight.Stop(task.WorktreePath, mainRepoDir) - case "sync": - if !spotlight.IsActive(task.WorktreePath) { - s.sendError(id, -32602, "Spotlight mode is not active. Use 'start' to enable spotlight before syncing") - return - } - result, err = spotlight.Sync(task.WorktreePath, mainRepoDir) - case "status": - result, err = spotlight.Status(task.WorktreePath, mainRepoDir) - default: - s.sendError(id, -32602, fmt.Sprintf("Unknown spotlight action: %s", action)) - return - } - if err != nil { - s.sendError(id, -32603, err.Error()) - return - } - - s.db.AppendTaskLog(s.taskID, "system", fmt.Sprintf("Spotlight %s: %s", action, result)) - - s.sendResult(id, toolCallResult{ - Content: []contentBlock{ - {Type: "text", Text: result}, - }, - }) - default: s.sendError(id, -32602, fmt.Sprintf("Unknown tool: %s", params.Name)) } diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go index 6be8ccbd..964a469b 100644 --- a/internal/mcp/server_test.go +++ b/internal/mcp/server_test.go @@ -5,7 +5,6 @@ import ( "bytes" "encoding/json" "os" - "os/exec" "path/filepath" "strings" "testing" @@ -105,7 +104,6 @@ func TestToolsList(t *testing.T) { "taskyou_list_tasks": false, "taskyou_get_project_context": false, "taskyou_set_project_context": false, - "taskyou_spotlight": false, } for _, toolI := range tools { tool, ok := toolI.(map[string]interface{}) @@ -121,6 +119,10 @@ func TestToolsList(t *testing.T) { if name == "taskyou_screenshot" { t.Error("taskyou_screenshot should no longer be advertised — it was removed") } + // taskyou_spotlight was removed — the worktree-sync feature was unused. + if name == "taskyou_spotlight" { + t.Error("taskyou_spotlight should no longer be advertised — it was removed") + } } for name, found := range want { if !found { @@ -940,538 +942,3 @@ func TestNoReminderWhenContextSaved(t *testing.T) { t.Log("No reminder when context saved works correctly!") } - -// TestSpotlightStatus tests the spotlight status action when inactive -func TestSpotlightStatus(t *testing.T) { - database := testDB(t) - - // Create temp directories for worktree and main repo - worktreeDir := t.TempDir() - mainRepoDir := t.TempDir() - - // Initialize git repos - runGit(t, mainRepoDir, "init") - runGit(t, mainRepoDir, "config", "user.email", "test@test.com") - runGit(t, mainRepoDir, "config", "user.name", "Test") - os.WriteFile(filepath.Join(mainRepoDir, "README.md"), []byte("# Test"), 0644) - runGit(t, mainRepoDir, "add", ".") - runGit(t, mainRepoDir, "commit", "-m", "initial") - - runGit(t, worktreeDir, "init") - runGit(t, worktreeDir, "config", "user.email", "test@test.com") - runGit(t, worktreeDir, "config", "user.name", "Test") - os.WriteFile(filepath.Join(worktreeDir, "README.md"), []byte("# Test"), 0644) - runGit(t, worktreeDir, "add", ".") - runGit(t, worktreeDir, "commit", "-m", "initial") - - // Create a project - if err := database.CreateProject(&db.Project{Name: "spotlight-test", Path: mainRepoDir, UseWorktrees: true}); err != nil { - t.Fatalf("failed to create project: %v", err) - } - - // Create a task - task := &db.Task{ - Title: "Spotlight Test Task", - Status: db.StatusProcessing, - Project: "spotlight-test", - } - if err := database.CreateTask(task); err != nil { - t.Fatalf("failed to create task: %v", err) - } - - // Set the worktree path (normally done by executor) - task.WorktreePath = worktreeDir - if err := database.UpdateTask(task); err != nil { - t.Fatalf("failed to update task with worktree: %v", err) - } - - request := map[string]interface{}{ - "jsonrpc": "2.0", - "id": 1, - "method": "tools/call", - "params": map[string]interface{}{ - "name": "taskyou_spotlight", - "arguments": map[string]interface{}{ - "action": "status", - }, - }, - } - reqBytes, _ := json.Marshal(request) - reqBytes = append(reqBytes, '\n') - - server, output := testServer(database, task.ID, string(reqBytes)) - server.Run() - - var resp jsonRPCResponse - if err := json.Unmarshal(output.Bytes(), &resp); err != nil { - t.Fatalf("failed to parse response: %v", err) - } - - if resp.Error != nil { - t.Fatalf("unexpected error: %s", resp.Error.Message) - } - - result := resp.Result.(map[string]interface{}) - content := result["content"].([]interface{}) - text := content[0].(map[string]interface{})["text"].(string) - - if !strings.Contains(text, "INACTIVE") { - t.Errorf("expected spotlight status to be INACTIVE, got: %s", text) - } -} - -// TestSpotlightStartStopFlow tests the full start/stop flow -func TestSpotlightStartStopFlow(t *testing.T) { - database := testDB(t) - - // Create temp directories for worktree and main repo - worktreeDir := t.TempDir() - mainRepoDir := t.TempDir() - - // Initialize main repo - runGit(t, mainRepoDir, "init") - runGit(t, mainRepoDir, "config", "user.email", "test@test.com") - runGit(t, mainRepoDir, "config", "user.name", "Test") - os.WriteFile(filepath.Join(mainRepoDir, "README.md"), []byte("# Main Repo"), 0644) - runGit(t, mainRepoDir, "add", ".") - runGit(t, mainRepoDir, "commit", "-m", "initial") - - // Initialize worktree with same structure - runGit(t, worktreeDir, "init") - runGit(t, worktreeDir, "config", "user.email", "test@test.com") - runGit(t, worktreeDir, "config", "user.name", "Test") - os.WriteFile(filepath.Join(worktreeDir, "README.md"), []byte("# Worktree Changes"), 0644) - os.WriteFile(filepath.Join(worktreeDir, "newfile.txt"), []byte("new content"), 0644) - runGit(t, worktreeDir, "add", "README.md") - runGit(t, worktreeDir, "commit", "-m", "initial") - - // Create project - if err := database.CreateProject(&db.Project{Name: "spotlight-flow-test", Path: mainRepoDir, UseWorktrees: true}); err != nil { - t.Fatalf("failed to create project: %v", err) - } - - // Create task - task := &db.Task{ - Title: "Spotlight Flow Test", - Status: db.StatusProcessing, - Project: "spotlight-flow-test", - } - if err := database.CreateTask(task); err != nil { - t.Fatalf("failed to create task: %v", err) - } - - // Set the worktree path (normally done by executor) - task.WorktreePath = worktreeDir - if err := database.UpdateTask(task); err != nil { - t.Fatalf("failed to update task with worktree: %v", err) - } - - // Step 1: Start spotlight - startReq := map[string]interface{}{ - "jsonrpc": "2.0", - "id": 1, - "method": "tools/call", - "params": map[string]interface{}{ - "name": "taskyou_spotlight", - "arguments": map[string]interface{}{ - "action": "start", - }, - }, - } - reqBytes, _ := json.Marshal(startReq) - reqBytes = append(reqBytes, '\n') - - server, output := testServer(database, task.ID, string(reqBytes)) - server.Run() - - var resp jsonRPCResponse - if err := json.Unmarshal(output.Bytes(), &resp); err != nil { - t.Fatalf("failed to parse start response: %v", err) - } - - if resp.Error != nil { - t.Fatalf("start failed: %s", resp.Error.Message) - } - - result := resp.Result.(map[string]interface{}) - content := result["content"].([]interface{}) - text := content[0].(map[string]interface{})["text"].(string) - - if !strings.Contains(text, "enabled") { - t.Errorf("expected spotlight to be enabled, got: %s", text) - } - - // Verify state file was created - stateFile := filepath.Join(worktreeDir, ".spotlight-active") - if _, err := os.Stat(stateFile); os.IsNotExist(err) { - t.Error("spotlight state file not created") - } - - // Verify files were synced - mainReadme, err := os.ReadFile(filepath.Join(mainRepoDir, "README.md")) - if err != nil { - t.Fatalf("failed to read main repo README: %v", err) - } - if string(mainReadme) != "# Worktree Changes" { - t.Errorf("README not synced, got: %s", string(mainReadme)) - } - - // Step 2: Check status (should be active) - statusReq := map[string]interface{}{ - "jsonrpc": "2.0", - "id": 2, - "method": "tools/call", - "params": map[string]interface{}{ - "name": "taskyou_spotlight", - "arguments": map[string]interface{}{ - "action": "status", - }, - }, - } - reqBytes, _ = json.Marshal(statusReq) - reqBytes = append(reqBytes, '\n') - - server, output = testServer(database, task.ID, string(reqBytes)) - server.Run() - - if err := json.Unmarshal(output.Bytes(), &resp); err != nil { - t.Fatalf("failed to parse status response: %v", err) - } - - result = resp.Result.(map[string]interface{}) - content = result["content"].([]interface{}) - text = content[0].(map[string]interface{})["text"].(string) - - if !strings.Contains(text, "ACTIVE") { - t.Errorf("expected spotlight to be ACTIVE, got: %s", text) - } - - // Step 3: Stop spotlight - stopReq := map[string]interface{}{ - "jsonrpc": "2.0", - "id": 3, - "method": "tools/call", - "params": map[string]interface{}{ - "name": "taskyou_spotlight", - "arguments": map[string]interface{}{ - "action": "stop", - }, - }, - } - reqBytes, _ = json.Marshal(stopReq) - reqBytes = append(reqBytes, '\n') - - server, output = testServer(database, task.ID, string(reqBytes)) - server.Run() - - if err := json.Unmarshal(output.Bytes(), &resp); err != nil { - t.Fatalf("failed to parse stop response: %v", err) - } - - if resp.Error != nil { - t.Fatalf("stop failed: %s", resp.Error.Message) - } - - result = resp.Result.(map[string]interface{}) - content = result["content"].([]interface{}) - text = content[0].(map[string]interface{})["text"].(string) - - if !strings.Contains(text, "disabled") { - t.Errorf("expected spotlight to be disabled, got: %s", text) - } - - // Verify state file was removed - if _, err := os.Stat(stateFile); !os.IsNotExist(err) { - t.Error("spotlight state file should be removed") - } - - // Verify main repo was restored - mainReadme, err = os.ReadFile(filepath.Join(mainRepoDir, "README.md")) - if err != nil { - t.Fatalf("failed to read main repo README: %v", err) - } - if string(mainReadme) != "# Main Repo" { - t.Errorf("README not restored, got: %s", string(mainReadme)) - } -} - -// TestSpotlightRequiresWorktree tests that spotlight fails without worktree -func TestSpotlightRequiresWorktree(t *testing.T) { - database := testDB(t) - task := createTestTask(t, database) - - // Task has no worktree path set - request := map[string]interface{}{ - "jsonrpc": "2.0", - "id": 1, - "method": "tools/call", - "params": map[string]interface{}{ - "name": "taskyou_spotlight", - "arguments": map[string]interface{}{ - "action": "status", - }, - }, - } - reqBytes, _ := json.Marshal(request) - reqBytes = append(reqBytes, '\n') - - server, output := testServer(database, task.ID, string(reqBytes)) - server.Run() - - var resp jsonRPCResponse - if err := json.Unmarshal(output.Bytes(), &resp); err != nil { - t.Fatalf("failed to parse response: %v", err) - } - - if resp.Error == nil { - t.Fatal("expected error for task without worktree") - } - - if !strings.Contains(resp.Error.Message, "worktree") { - t.Errorf("expected error about worktree, got: %s", resp.Error.Message) - } -} - -// TestSpotlightRejectsNonWorktreeProject tests that spotlight returns an error for non-worktree projects -func TestSpotlightRejectsNonWorktreeProject(t *testing.T) { - database := testDB(t) - - // Create a non-worktree project - if err := database.CreateProject(&db.Project{Name: "no-wt-project", Path: t.TempDir(), UseWorktrees: false}); err != nil { - t.Fatalf("failed to create project: %v", err) - } - - task := &db.Task{ - Title: "Non-Worktree Task", - Status: db.StatusProcessing, - Project: "no-wt-project", - } - if err := database.CreateTask(task); err != nil { - t.Fatalf("failed to create task: %v", err) - } - // Set worktree path via update (CreateTask doesn't store it) - task.WorktreePath = "/some/path" - if err := database.UpdateTask(task); err != nil { - t.Fatalf("failed to update task: %v", err) - } - - request := map[string]interface{}{ - "jsonrpc": "2.0", - "id": 1, - "method": "tools/call", - "params": map[string]interface{}{ - "name": "taskyou_spotlight", - "arguments": map[string]interface{}{ - "action": "status", - }, - }, - } - reqBytes, _ := json.Marshal(request) - reqBytes = append(reqBytes, '\n') - - server, output := testServer(database, task.ID, string(reqBytes)) - server.Run() - - var resp jsonRPCResponse - if err := json.Unmarshal(output.Bytes(), &resp); err != nil { - t.Fatalf("failed to parse response: %v", err) - } - - if resp.Error == nil { - t.Fatal("expected error for non-worktree project") - } - - if !strings.Contains(resp.Error.Message, "non-worktree") { - t.Errorf("expected error about non-worktree projects, got: %s", resp.Error.Message) - } -} - -// TestSpotlightSyncRequiresActive tests that sync fails when spotlight is not active -func TestSpotlightSyncRequiresActive(t *testing.T) { - database := testDB(t) - - // Create temp directories - worktreeDir := t.TempDir() - mainRepoDir := t.TempDir() - - // Initialize repos - runGit(t, mainRepoDir, "init") - runGit(t, mainRepoDir, "config", "user.email", "test@test.com") - runGit(t, mainRepoDir, "config", "user.name", "Test") - os.WriteFile(filepath.Join(mainRepoDir, "file.txt"), []byte("original"), 0644) - runGit(t, mainRepoDir, "add", ".") - runGit(t, mainRepoDir, "commit", "-m", "initial") - - runGit(t, worktreeDir, "init") - runGit(t, worktreeDir, "config", "user.email", "test@test.com") - runGit(t, worktreeDir, "config", "user.name", "Test") - os.WriteFile(filepath.Join(worktreeDir, "file.txt"), []byte("modified"), 0644) - runGit(t, worktreeDir, "add", ".") - runGit(t, worktreeDir, "commit", "-m", "initial") - - // Create project - if err := database.CreateProject(&db.Project{Name: "spotlight-sync-inactive-test", Path: mainRepoDir, UseWorktrees: true}); err != nil { - t.Fatalf("failed to create project: %v", err) - } - - // Create task - task := &db.Task{ - Title: "Spotlight Sync Inactive Test", - Status: db.StatusProcessing, - Project: "spotlight-sync-inactive-test", - } - if err := database.CreateTask(task); err != nil { - t.Fatalf("failed to create task: %v", err) - } - - task.WorktreePath = worktreeDir - if err := database.UpdateTask(task); err != nil { - t.Fatalf("failed to update task with worktree: %v", err) - } - - // Try to sync WITHOUT starting spotlight first - syncReq := map[string]interface{}{ - "jsonrpc": "2.0", - "id": 1, - "method": "tools/call", - "params": map[string]interface{}{ - "name": "taskyou_spotlight", - "arguments": map[string]interface{}{ - "action": "sync", - }, - }, - } - reqBytes, _ := json.Marshal(syncReq) - reqBytes = append(reqBytes, '\n') - - server, output := testServer(database, task.ID, string(reqBytes)) - server.Run() - - var resp jsonRPCResponse - if err := json.Unmarshal(output.Bytes(), &resp); err != nil { - t.Fatalf("failed to parse response: %v", err) - } - - // Should fail because spotlight is not active - if resp.Error == nil { - t.Fatal("expected error when syncing without spotlight active") - } - - if !strings.Contains(resp.Error.Message, "not active") { - t.Errorf("expected error about spotlight not being active, got: %s", resp.Error.Message) - } -} - -// TestSpotlightSync tests the sync action -func TestSpotlightSync(t *testing.T) { - database := testDB(t) - - // Create temp directories - worktreeDir := t.TempDir() - mainRepoDir := t.TempDir() - - // Initialize repos - runGit(t, mainRepoDir, "init") - runGit(t, mainRepoDir, "config", "user.email", "test@test.com") - runGit(t, mainRepoDir, "config", "user.name", "Test") - os.WriteFile(filepath.Join(mainRepoDir, "file.txt"), []byte("original"), 0644) - runGit(t, mainRepoDir, "add", ".") - runGit(t, mainRepoDir, "commit", "-m", "initial") - - runGit(t, worktreeDir, "init") - runGit(t, worktreeDir, "config", "user.email", "test@test.com") - runGit(t, worktreeDir, "config", "user.name", "Test") - os.WriteFile(filepath.Join(worktreeDir, "file.txt"), []byte("modified"), 0644) - runGit(t, worktreeDir, "add", ".") - runGit(t, worktreeDir, "commit", "-m", "initial") - - // Create project - if err := database.CreateProject(&db.Project{Name: "spotlight-sync-test", Path: mainRepoDir, UseWorktrees: true}); err != nil { - t.Fatalf("failed to create project: %v", err) - } - - // Create task - task := &db.Task{ - Title: "Spotlight Sync Test", - Status: db.StatusProcessing, - Project: "spotlight-sync-test", - } - if err := database.CreateTask(task); err != nil { - t.Fatalf("failed to create task: %v", err) - } - - // Set the worktree path (normally done by executor) - task.WorktreePath = worktreeDir - if err := database.UpdateTask(task); err != nil { - t.Fatalf("failed to update task with worktree: %v", err) - } - - // First start spotlight - startReq := map[string]interface{}{ - "jsonrpc": "2.0", - "id": 1, - "method": "tools/call", - "params": map[string]interface{}{ - "name": "taskyou_spotlight", - "arguments": map[string]interface{}{ - "action": "start", - }, - }, - } - reqBytes, _ := json.Marshal(startReq) - reqBytes = append(reqBytes, '\n') - - server, output := testServer(database, task.ID, string(reqBytes)) - server.Run() - _ = output // first response not needed - - // Now modify the worktree file - os.WriteFile(filepath.Join(worktreeDir, "file.txt"), []byte("modified again"), 0644) - - // Sync changes - syncReq := map[string]interface{}{ - "jsonrpc": "2.0", - "id": 2, - "method": "tools/call", - "params": map[string]interface{}{ - "name": "taskyou_spotlight", - "arguments": map[string]interface{}{ - "action": "sync", - }, - }, - } - reqBytes, _ = json.Marshal(syncReq) - reqBytes = append(reqBytes, '\n') - - server, output = testServer(database, task.ID, string(reqBytes)) - server.Run() - - var resp jsonRPCResponse - if err := json.Unmarshal(output.Bytes(), &resp); err != nil { - t.Fatalf("failed to parse response: %v", err) - } - - if resp.Error != nil { - t.Fatalf("sync failed: %s", resp.Error.Message) - } - - // Verify file was synced - mainContent, err := os.ReadFile(filepath.Join(mainRepoDir, "file.txt")) - if err != nil { - t.Fatalf("failed to read main repo file: %v", err) - } - if string(mainContent) != "modified again" { - t.Errorf("file not synced, got: %s", string(mainContent)) - } -} - -// runGit is a helper to run git commands in tests -func runGit(t *testing.T, dir string, args ...string) { - t.Helper() - cmd := exec.Command("git", args...) - cmd.Dir = dir - output, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("git %v failed: %v\n%s", args, err, string(output)) - } -} diff --git a/internal/spotlight/spotlight.go b/internal/spotlight/spotlight.go deleted file mode 100644 index 93b21ffc..00000000 --- a/internal/spotlight/spotlight.go +++ /dev/null @@ -1,324 +0,0 @@ -// Package spotlight syncs worktree changes to the main repo for testing. -package spotlight - -import ( - "bytes" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - "time" -) - -// StateFile returns the path to the spotlight state file in the worktree. -func StateFile(worktreePath string) string { - return filepath.Join(worktreePath, ".spotlight-active") -} - -// IsActive checks if spotlight mode is currently active for the worktree. -func IsActive(worktreePath string) bool { - _, err := os.Stat(StateFile(worktreePath)) - return err == nil -} - -// Start enables spotlight mode and performs initial sync. -func Start(worktreePath, mainRepoDir string) (string, error) { - if IsActive(worktreePath) { - return "Spotlight mode is already active. Use 'sync' to sync changes or 'stop' to disable.", nil - } - - // Check if main repo has uncommitted changes using git diff --quiet (more reliable than parsing output) - hasChanges := false - diffCmd := exec.Command("git", "diff", "--quiet") - diffCmd.Dir = mainRepoDir - if err := diffCmd.Run(); err != nil { - hasChanges = true // non-zero exit means changes exist - } - diffCachedCmd := exec.Command("git", "diff", "--cached", "--quiet") - diffCachedCmd.Dir = mainRepoDir - if err := diffCachedCmd.Run(); err != nil { - hasChanges = true // staged changes exist - } - - // Stash changes if any exist — fail if stash fails, since Stop() - // will run git checkout . which would destroy uncommitted work. - stashCreated := false - if hasChanges { - stashCmd := exec.Command("git", "stash", "push", "-m", "spotlight-backup-"+time.Now().Format("20060102-150405")) - stashCmd.Dir = mainRepoDir - if output, err := stashCmd.CombinedOutput(); err != nil { - return "", fmt.Errorf("failed to stash main repo changes (aborting to protect uncommitted work): %s", strings.TrimSpace(string(output))) - } - stashCreated = true - } - - // Create the state file to track that spotlight is active - stateContent := fmt.Sprintf("started=%s\nstash_created=%t\n", time.Now().Format(time.RFC3339), stashCreated) - if err := os.WriteFile(StateFile(worktreePath), []byte(stateContent), 0644); err != nil { - return "", fmt.Errorf("failed to create spotlight state file: %w", err) - } - - // Perform initial sync - syncResult, err := Sync(worktreePath, mainRepoDir) - if err != nil { - // Clean up state file if sync failed - os.Remove(StateFile(worktreePath)) - return "", err - } - - msg := "šŸ”¦ Spotlight mode enabled!\n\n" - if stashCreated { - msg += "āœ“ Main repo changes stashed (will be restored on stop)\n" - } - msg += syncResult - msg += "\n\nTip: Your main repo now has the worktree changes. Run your app from there for testing." - msg += "\nUse 'sync' to push more changes or 'stop' when done." - - return msg, nil -} - -// Stop disables spotlight mode and restores the main repo. -func Stop(worktreePath, mainRepoDir string) (string, error) { - if !IsActive(worktreePath) { - return "Spotlight mode is not active.", nil - } - - // Read state file to check if we created a stash - stateData, _ := os.ReadFile(StateFile(worktreePath)) - stashCreated := strings.Contains(string(stateData), "stash_created=true") - - // Restore the main repo to its original state - // First, discard any uncommitted changes from spotlight - checkoutCmd := exec.Command("git", "checkout", ".") - checkoutCmd.Dir = mainRepoDir - if output, err := checkoutCmd.CombinedOutput(); err != nil { - return "", fmt.Errorf("failed to restore main repo (git checkout): %s", strings.TrimSpace(string(output))) - } - - // Clean any untracked files that were added - cleanCmd := exec.Command("git", "clean", "-fd") - cleanCmd.Dir = mainRepoDir - if output, err := cleanCmd.CombinedOutput(); err != nil { - return "", fmt.Errorf("failed to clean main repo (git clean): %s", strings.TrimSpace(string(output))) - } - - // Pop the stash if we created one - var stashMsg string - stashPopFailed := false - if stashCreated { - stashPopCmd := exec.Command("git", "stash", "pop") - stashPopCmd.Dir = mainRepoDir - if output, err := stashPopCmd.CombinedOutput(); err != nil { - stashPopFailed = true - stashMsg = fmt.Sprintf("āš ļø Failed to restore stash: %s\n Run 'git stash list' in %s to see available stashes.", strings.TrimSpace(string(output)), mainRepoDir) - } else { - stashMsg = "āœ“ Original main repo changes restored from stash" - } - } - - // Only remove state file if restoration succeeded (including stash pop) - if !stashPopFailed { - os.Remove(StateFile(worktreePath)) - } - - msg := "šŸ”¦ Spotlight mode disabled!\n\n" - msg += "āœ“ Main repo restored to original state\n" - if stashMsg != "" { - msg += stashMsg + "\n" - } - if stashPopFailed { - msg += "\nāš ļø State file preserved due to stash pop failure. Run 'stop' again after resolving." - } - - return msg, nil -} - -// Sync syncs git-tracked files from worktree to main repo. -// It compares files between the worktree and main repo, copying any that differ. -// Also handles file deletions by detecting files that exist in main but not in worktree. -func Sync(worktreePath, mainRepoDir string) (string, error) { - // Get list of all git-tracked files in the worktree - lsFilesCmd := exec.Command("git", "ls-files") - lsFilesCmd.Dir = worktreePath - lsFilesOutput, err := lsFilesCmd.Output() - if err != nil { - return "", fmt.Errorf("failed to list tracked files: %w", err) - } - - // Also get untracked files (new files not yet added) - untrackedCmd := exec.Command("git", "ls-files", "--others", "--exclude-standard") - untrackedCmd.Dir = worktreePath - untrackedOutput, _ := untrackedCmd.Output() - - // Get deleted files (tracked but removed from worktree) - deletedCmd := exec.Command("git", "diff", "--name-only", "--diff-filter=D", "HEAD") - deletedCmd.Dir = worktreePath - deletedOutput, _ := deletedCmd.Output() - - // Build set of all files to sync - fileSet := make(map[string]bool) - for _, file := range strings.Split(strings.TrimSpace(string(lsFilesOutput)), "\n") { - if file != "" { - fileSet[file] = true - } - } - for _, file := range strings.Split(strings.TrimSpace(string(untrackedOutput)), "\n") { - if file != "" { - fileSet[file] = true - } - } - - // Build set of deleted files - deletedSet := make(map[string]bool) - for _, file := range strings.Split(strings.TrimSpace(string(deletedOutput)), "\n") { - if file != "" { - deletedSet[file] = true - } - } - - // Clean paths for validation - cleanWorktree := filepath.Clean(worktreePath) - cleanMainRepo := filepath.Clean(mainRepoDir) - - // Copy files that differ between worktree and main repo - var synced, unchanged, deleted, failed int - for file := range fileSet { - if file == ".spotlight-active" || file == "" { - continue - } - - // Validate path to prevent path traversal attacks - cleanFile := filepath.Clean(file) - if cleanFile == ".." || strings.HasPrefix(cleanFile, ".."+string(os.PathSeparator)) || filepath.IsAbs(cleanFile) { - failed++ - continue - } - - srcPath := filepath.Join(cleanWorktree, cleanFile) - dstPath := filepath.Join(cleanMainRepo, cleanFile) - - // Ensure destination is within mainRepoDir - if !strings.HasPrefix(filepath.Clean(dstPath), cleanMainRepo+string(os.PathSeparator)) && filepath.Clean(dstPath) != cleanMainRepo { - failed++ - continue - } - - // Check if source exists - srcInfo, err := os.Stat(srcPath) - if err != nil { - if os.IsNotExist(err) { - // File tracked but doesn't exist - skip - continue - } - failed++ - continue - } - - // Skip directories - if srcInfo.IsDir() { - continue - } - - // Read source file - srcData, err := os.ReadFile(srcPath) - if err != nil { - failed++ - continue - } - - // Check if destination exists and is the same (use bytes.Equal for efficiency) - dstData, err := os.ReadFile(dstPath) - if err == nil && bytes.Equal(srcData, dstData) { - unchanged++ - continue - } - - // Ensure destination directory exists - dstDir := filepath.Dir(dstPath) - if err := os.MkdirAll(dstDir, 0755); err != nil { - failed++ - continue - } - - // Copy the file - if err := os.WriteFile(dstPath, srcData, srcInfo.Mode()); err != nil { - failed++ - continue - } - - synced++ - } - - // Handle deleted files - remove them from main repo - for file := range deletedSet { - if file == "" { - continue - } - - cleanFile := filepath.Clean(file) - if cleanFile == ".." || strings.HasPrefix(cleanFile, ".."+string(os.PathSeparator)) || filepath.IsAbs(cleanFile) { - continue - } - - dstPath := filepath.Join(cleanMainRepo, cleanFile) - if !strings.HasPrefix(filepath.Clean(dstPath), cleanMainRepo+string(os.PathSeparator)) { - continue - } - - if err := os.Remove(dstPath); err == nil { - deleted++ - } - } - - if synced == 0 && deleted == 0 && failed == 0 { - return "No changes to sync (worktree matches main repo).", nil - } - - result := fmt.Sprintf("āœ“ Synced %d file(s) from worktree to main repo", synced) - if deleted > 0 { - result += fmt.Sprintf(", deleted %d", deleted) - } - if unchanged > 0 { - result += fmt.Sprintf(" (%d unchanged)", unchanged) - } - if failed > 0 { - result += fmt.Sprintf(" (%d failed)", failed) - } - - return result, nil -} - -// Status returns the current spotlight status. -func Status(worktreePath, mainRepoDir string) (string, error) { - if !IsActive(worktreePath) { - return "šŸ”¦ Spotlight mode: INACTIVE\n\nUse 'start' to enable spotlight mode and sync worktree changes to the main repo for testing.", nil - } - - // Read state file for details - stateData, _ := os.ReadFile(StateFile(worktreePath)) - - // Count pending changes - diffCmd := exec.Command("git", "diff", "--name-only", "HEAD") - diffCmd.Dir = worktreePath - diffOutput, _ := diffCmd.Output() - changedCount := len(strings.Split(strings.TrimSpace(string(diffOutput)), "\n")) - if strings.TrimSpace(string(diffOutput)) == "" { - changedCount = 0 - } - - msg := "šŸ”¦ Spotlight mode: ACTIVE\n\n" - msg += fmt.Sprintf("Worktree: %s\n", worktreePath) - msg += fmt.Sprintf("Main repo: %s\n", mainRepoDir) - if len(stateData) > 0 { - for _, line := range strings.Split(string(stateData), "\n") { - if strings.HasPrefix(line, "started=") { - msg += fmt.Sprintf("Started: %s\n", strings.TrimPrefix(line, "started=")) - } - } - } - msg += fmt.Sprintf("\nPending changes: %d file(s)\n", changedCount) - msg += "\nUse 'sync' to push changes or 'stop' to disable and restore main repo." - - return msg, nil -} diff --git a/internal/spotlight/spotlight_test.go b/internal/spotlight/spotlight_test.go deleted file mode 100644 index 4537b9ed..00000000 --- a/internal/spotlight/spotlight_test.go +++ /dev/null @@ -1,718 +0,0 @@ -package spotlight - -import ( - "os" - "os/exec" - "path/filepath" - "strings" - "testing" -) - -// initGitRepo creates a git repo in dir with an initial commit. -func initGitRepo(t *testing.T, dir string) { - t.Helper() - run := func(args ...string) { - cmd := exec.Command("git", args...) - cmd.Dir = dir - cmd.Env = append(os.Environ(), - "GIT_AUTHOR_NAME=Test", - "GIT_AUTHOR_EMAIL=test@test.com", - "GIT_COMMITTER_NAME=Test", - "GIT_COMMITTER_EMAIL=test@test.com", - ) - out, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("git %s failed: %v\n%s", strings.Join(args, " "), err, out) - } - } - run("init") - run("config", "user.email", "test@test.com") - run("config", "user.name", "Test") - os.WriteFile(filepath.Join(dir, ".gitkeep"), []byte(""), 0644) - run("add", ".") - run("commit", "-m", "initial") -} - -// --- StateFile / IsActive --- - -func TestStateFile(t *testing.T) { - got := StateFile("/some/worktree") - want := filepath.Join("/some/worktree", ".spotlight-active") - if got != want { - t.Errorf("StateFile = %q, want %q", got, want) - } -} - -func TestIsActive_NoFile(t *testing.T) { - dir := t.TempDir() - if IsActive(dir) { - t.Error("IsActive should be false when no state file exists") - } -} - -func TestIsActive_WithFile(t *testing.T) { - dir := t.TempDir() - os.WriteFile(StateFile(dir), []byte("started=now\n"), 0644) - if !IsActive(dir) { - t.Error("IsActive should be true when state file exists") - } -} - -// --- Sync --- - -func TestSync_CopiesChangedFiles(t *testing.T) { - worktree := t.TempDir() - mainRepo := t.TempDir() - - initGitRepo(t, worktree) - initGitRepo(t, mainRepo) - - // Write different content in worktree - os.WriteFile(filepath.Join(worktree, "file.txt"), []byte("worktree content"), 0644) - cmd := exec.Command("git", "add", "file.txt") - cmd.Dir = worktree - cmd.Run() - cmd = exec.Command("git", "commit", "-m", "add file") - cmd.Dir = worktree - cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=Test", "GIT_AUTHOR_EMAIL=test@test.com", "GIT_COMMITTER_NAME=Test", "GIT_COMMITTER_EMAIL=test@test.com") - cmd.Run() - - result, err := Sync(worktree, mainRepo) - if err != nil { - t.Fatalf("Sync failed: %v", err) - } - - if !strings.Contains(result, "Synced") { - t.Errorf("expected sync result to mention synced files, got: %s", result) - } - - // Verify file was copied - data, err := os.ReadFile(filepath.Join(mainRepo, "file.txt")) - if err != nil { - t.Fatalf("file not copied to main repo: %v", err) - } - if string(data) != "worktree content" { - t.Errorf("file content = %q, want %q", string(data), "worktree content") - } -} - -func TestSync_SkipsUnchangedFiles(t *testing.T) { - worktree := t.TempDir() - mainRepo := t.TempDir() - - initGitRepo(t, worktree) - initGitRepo(t, mainRepo) - - // Write same content in both - content := []byte("same content") - os.WriteFile(filepath.Join(worktree, "same.txt"), content, 0644) - os.WriteFile(filepath.Join(mainRepo, "same.txt"), content, 0644) - cmd := exec.Command("git", "add", "same.txt") - cmd.Dir = worktree - cmd.Run() - cmd = exec.Command("git", "commit", "-m", "add same") - cmd.Dir = worktree - cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=Test", "GIT_AUTHOR_EMAIL=test@test.com", "GIT_COMMITTER_NAME=Test", "GIT_COMMITTER_EMAIL=test@test.com") - cmd.Run() - - result, err := Sync(worktree, mainRepo) - if err != nil { - t.Fatalf("Sync failed: %v", err) - } - - if !strings.Contains(result, "No changes") { - t.Errorf("expected no changes message, got: %s", result) - } -} - -func TestSync_CreatesSubdirectories(t *testing.T) { - worktree := t.TempDir() - mainRepo := t.TempDir() - - initGitRepo(t, worktree) - initGitRepo(t, mainRepo) - - // Create nested file in worktree - os.MkdirAll(filepath.Join(worktree, "src", "pkg"), 0755) - os.WriteFile(filepath.Join(worktree, "src", "pkg", "main.go"), []byte("package main"), 0644) - cmd := exec.Command("git", "add", ".") - cmd.Dir = worktree - cmd.Run() - cmd = exec.Command("git", "commit", "-m", "add nested") - cmd.Dir = worktree - cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=Test", "GIT_AUTHOR_EMAIL=test@test.com", "GIT_COMMITTER_NAME=Test", "GIT_COMMITTER_EMAIL=test@test.com") - cmd.Run() - - _, err := Sync(worktree, mainRepo) - if err != nil { - t.Fatalf("Sync failed: %v", err) - } - - data, err := os.ReadFile(filepath.Join(mainRepo, "src", "pkg", "main.go")) - if err != nil { - t.Fatalf("nested file not created: %v", err) - } - if string(data) != "package main" { - t.Errorf("nested file content = %q, want %q", string(data), "package main") - } -} - -func TestSync_SkipsSpotlightStateFile(t *testing.T) { - worktree := t.TempDir() - mainRepo := t.TempDir() - - initGitRepo(t, worktree) - initGitRepo(t, mainRepo) - - // Create the state file in worktree (should NOT be synced) - os.WriteFile(StateFile(worktree), []byte("started=now\n"), 0644) - - result, err := Sync(worktree, mainRepo) - if err != nil { - t.Fatalf("Sync failed: %v", err) - } - - // State file should not be copied to main repo - if _, err := os.Stat(StateFile(mainRepo)); !os.IsNotExist(err) { - t.Error("spotlight state file should not be synced to main repo") - } - - if !strings.Contains(result, "No changes") { - t.Errorf("expected no changes (state file skipped), got: %s", result) - } -} - -func TestSync_HandlesDeletedFiles(t *testing.T) { - worktree := t.TempDir() - mainRepo := t.TempDir() - - initGitRepo(t, worktree) - initGitRepo(t, mainRepo) - - // Create a file, commit, then delete it in worktree - os.WriteFile(filepath.Join(worktree, "deleteme.txt"), []byte("gone soon"), 0644) - cmd := exec.Command("git", "add", "deleteme.txt") - cmd.Dir = worktree - cmd.Run() - cmd = exec.Command("git", "commit", "-m", "add deleteme") - cmd.Dir = worktree - cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=Test", "GIT_AUTHOR_EMAIL=test@test.com", "GIT_COMMITTER_NAME=Test", "GIT_COMMITTER_EMAIL=test@test.com") - cmd.Run() - - // Put same file in main repo - os.WriteFile(filepath.Join(mainRepo, "deleteme.txt"), []byte("gone soon"), 0644) - - // Delete from worktree (but don't commit the delete — git diff HEAD detects it) - os.Remove(filepath.Join(worktree, "deleteme.txt")) - - result, err := Sync(worktree, mainRepo) - if err != nil { - t.Fatalf("Sync failed: %v", err) - } - - if !strings.Contains(result, "deleted") { - t.Errorf("expected deleted file count, got: %s", result) - } - - // File should be removed from main repo - if _, err := os.Stat(filepath.Join(mainRepo, "deleteme.txt")); !os.IsNotExist(err) { - t.Error("deleted file should be removed from main repo") - } -} - -func TestSync_RejectsPathTraversal(t *testing.T) { - worktree := t.TempDir() - mainRepo := t.TempDir() - - initGitRepo(t, worktree) - initGitRepo(t, mainRepo) - - // Sync should not crash or write outside mainRepo even if somehow - // a traversal path got into the file list. We can't easily inject - // traversal paths via git ls-files, but we verify the validation - // logic is present by checking that absolute paths and .. paths - // would be rejected. - - // This test is more of a smoke test — the real protection is in the code. - result, err := Sync(worktree, mainRepo) - if err != nil { - t.Fatalf("Sync failed: %v", err) - } - _ = result -} - -func TestSync_IncludesUntrackedFiles(t *testing.T) { - worktree := t.TempDir() - mainRepo := t.TempDir() - - initGitRepo(t, worktree) - initGitRepo(t, mainRepo) - - // Create a new file in worktree but don't git add it - os.WriteFile(filepath.Join(worktree, "untracked.txt"), []byte("new file"), 0644) - - result, err := Sync(worktree, mainRepo) - if err != nil { - t.Fatalf("Sync failed: %v", err) - } - - if !strings.Contains(result, "Synced") { - t.Errorf("expected sync to include untracked file, got: %s", result) - } - - data, err := os.ReadFile(filepath.Join(mainRepo, "untracked.txt")) - if err != nil { - t.Fatalf("untracked file not synced: %v", err) - } - if string(data) != "new file" { - t.Errorf("untracked file content = %q, want %q", string(data), "new file") - } -} - -// --- Start / Stop --- - -func TestStart_CreatesStateFile(t *testing.T) { - worktree := t.TempDir() - mainRepo := t.TempDir() - - initGitRepo(t, worktree) - initGitRepo(t, mainRepo) - - result, err := Start(worktree, mainRepo) - if err != nil { - t.Fatalf("Start failed: %v", err) - } - - if !strings.Contains(result, "enabled") { - t.Errorf("expected enabled message, got: %s", result) - } - - if !IsActive(worktree) { - t.Error("spotlight should be active after Start") - } - - // Verify state file content - data, err := os.ReadFile(StateFile(worktree)) - if err != nil { - t.Fatalf("state file not readable: %v", err) - } - if !strings.Contains(string(data), "started=") { - t.Error("state file should contain started timestamp") - } -} - -func TestStart_AlreadyActive(t *testing.T) { - worktree := t.TempDir() - mainRepo := t.TempDir() - - initGitRepo(t, worktree) - initGitRepo(t, mainRepo) - - // Start first time - _, err := Start(worktree, mainRepo) - if err != nil { - t.Fatalf("first Start failed: %v", err) - } - - // Start again should return message, not error - result, err := Start(worktree, mainRepo) - if err != nil { - t.Fatalf("second Start failed: %v", err) - } - if !strings.Contains(result, "already active") { - t.Errorf("expected already active message, got: %s", result) - } -} - -func TestStart_StashesMainRepoChanges(t *testing.T) { - worktree := t.TempDir() - mainRepo := t.TempDir() - - initGitRepo(t, worktree) - initGitRepo(t, mainRepo) - - // Make uncommitted changes in main repo - os.WriteFile(filepath.Join(mainRepo, "dirty.txt"), []byte("uncommitted"), 0644) - cmd := exec.Command("git", "add", "dirty.txt") - cmd.Dir = mainRepo - cmd.Run() - - result, err := Start(worktree, mainRepo) - if err != nil { - t.Fatalf("Start failed: %v", err) - } - - if !strings.Contains(result, "stashed") { - t.Errorf("expected stash message, got: %s", result) - } - - // State file should record stash_created=true - data, _ := os.ReadFile(StateFile(worktree)) - if !strings.Contains(string(data), "stash_created=true") { - t.Error("state file should record stash_created=true") - } -} - -func TestStop_RestoresMainRepo(t *testing.T) { - worktree := t.TempDir() - mainRepo := t.TempDir() - - initGitRepo(t, worktree) - initGitRepo(t, mainRepo) - - // Create different content in worktree - os.WriteFile(filepath.Join(worktree, "file.txt"), []byte("worktree"), 0644) - cmd := exec.Command("git", "add", "file.txt") - cmd.Dir = worktree - cmd.Run() - cmd = exec.Command("git", "commit", "-m", "add file") - cmd.Dir = worktree - cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=Test", "GIT_AUTHOR_EMAIL=test@test.com", "GIT_COMMITTER_NAME=Test", "GIT_COMMITTER_EMAIL=test@test.com") - cmd.Run() - - // Start spotlight (syncs worktree → main) - _, err := Start(worktree, mainRepo) - if err != nil { - t.Fatalf("Start failed: %v", err) - } - - // Verify file was synced to main - data, _ := os.ReadFile(filepath.Join(mainRepo, "file.txt")) - if string(data) != "worktree" { - t.Fatalf("file not synced during Start") - } - - // Stop spotlight - result, err := Stop(worktree, mainRepo) - if err != nil { - t.Fatalf("Stop failed: %v", err) - } - - if !strings.Contains(result, "disabled") { - t.Errorf("expected disabled message, got: %s", result) - } - - // File should be cleaned from main repo (it wasn't there originally) - if _, err := os.Stat(filepath.Join(mainRepo, "file.txt")); !os.IsNotExist(err) { - t.Error("synced file should be cleaned from main repo after Stop") - } - - // State file should be removed - if IsActive(worktree) { - t.Error("spotlight should be inactive after Stop") - } -} - -func TestStop_RestoresStashedChanges(t *testing.T) { - worktree := t.TempDir() - mainRepo := t.TempDir() - - initGitRepo(t, worktree) - initGitRepo(t, mainRepo) - - // Make uncommitted changes in main repo - os.WriteFile(filepath.Join(mainRepo, "mywork.txt"), []byte("important work"), 0644) - cmd := exec.Command("git", "add", "mywork.txt") - cmd.Dir = mainRepo - cmd.Run() - - // Start spotlight (should stash the changes) - _, err := Start(worktree, mainRepo) - if err != nil { - t.Fatalf("Start failed: %v", err) - } - - // mywork.txt should be gone (stashed) - if _, err := os.Stat(filepath.Join(mainRepo, "mywork.txt")); !os.IsNotExist(err) { - t.Error("stashed file should not exist during spotlight") - } - - // Stop spotlight (should restore stash) - result, err := Stop(worktree, mainRepo) - if err != nil { - t.Fatalf("Stop failed: %v", err) - } - - if !strings.Contains(result, "restored from stash") { - t.Errorf("expected stash restore message, got: %s", result) - } - - // mywork.txt should be back - data, err := os.ReadFile(filepath.Join(mainRepo, "mywork.txt")) - if err != nil { - t.Fatalf("stashed file not restored: %v", err) - } - if string(data) != "important work" { - t.Errorf("stashed file content = %q, want %q", string(data), "important work") - } -} - -func TestStop_NotActive(t *testing.T) { - worktree := t.TempDir() - mainRepo := t.TempDir() - - result, err := Stop(worktree, mainRepo) - if err != nil { - t.Fatalf("Stop failed: %v", err) - } - if !strings.Contains(result, "not active") { - t.Errorf("expected not active message, got: %s", result) - } -} - -func TestStop_PreservesStateOnStashPopFailure(t *testing.T) { - worktree := t.TempDir() - mainRepo := t.TempDir() - - initGitRepo(t, worktree) - initGitRepo(t, mainRepo) - - // Manually create state file that claims a stash was created - // but don't actually create a stash — so stash pop will fail - os.WriteFile(StateFile(worktree), []byte("started=now\nstash_created=true\n"), 0644) - - result, err := Stop(worktree, mainRepo) - if err != nil { - t.Fatalf("Stop failed: %v", err) - } - - if !strings.Contains(result, "Failed to restore stash") { - t.Errorf("expected stash pop failure warning, got: %s", result) - } - - // State file should be preserved so user can investigate - if !IsActive(worktree) { - t.Error("state file should be preserved when stash pop fails") - } -} - -// --- Status --- - -func TestStatus_Inactive(t *testing.T) { - worktree := t.TempDir() - mainRepo := t.TempDir() - - initGitRepo(t, worktree) - - result, err := Status(worktree, mainRepo) - if err != nil { - t.Fatalf("Status failed: %v", err) - } - if !strings.Contains(result, "INACTIVE") { - t.Errorf("expected INACTIVE, got: %s", result) - } -} - -func TestStatus_Active(t *testing.T) { - worktree := t.TempDir() - mainRepo := t.TempDir() - - initGitRepo(t, worktree) - initGitRepo(t, mainRepo) - - _, err := Start(worktree, mainRepo) - if err != nil { - t.Fatalf("Start failed: %v", err) - } - - result, err := Status(worktree, mainRepo) - if err != nil { - t.Fatalf("Status failed: %v", err) - } - if !strings.Contains(result, "ACTIVE") { - t.Errorf("expected ACTIVE, got: %s", result) - } - if !strings.Contains(result, worktree) { - t.Errorf("expected worktree path in status, got: %s", result) - } - if !strings.Contains(result, mainRepo) { - t.Errorf("expected main repo path in status, got: %s", result) - } -} - -// --- Destructive operation safety --- - -func TestStop_DestructiveOps_DoNotWipeUntrackedMainRepoFiles(t *testing.T) { - // This test documents the KNOWN ISSUE: Stop() runs git clean -fd - // which WILL delete untracked files in the main repo that the user - // may have created while spotlight was active. - // - // This is a design trade-off: spotlight needs to clean up files it - // synced, but git clean -fd is a blunt instrument. - - worktree := t.TempDir() - mainRepo := t.TempDir() - - initGitRepo(t, worktree) - initGitRepo(t, mainRepo) - - // Start spotlight - _, err := Start(worktree, mainRepo) - if err != nil { - t.Fatalf("Start failed: %v", err) - } - - // Simulate user creating a new file in main repo while spotlight is active - // (e.g., IDE generated file, debug log, etc.) - os.WriteFile(filepath.Join(mainRepo, "user-created.txt"), []byte("user's file"), 0644) - - // Stop spotlight — this runs git clean -fd - _, err = Stop(worktree, mainRepo) - if err != nil { - t.Fatalf("Stop failed: %v", err) - } - - // KNOWN ISSUE: git clean -fd deletes ALL untracked files, including - // ones the user created independently of spotlight. - // This test documents the behavior rather than asserting it's correct. - _, userFileErr := os.Stat(filepath.Join(mainRepo, "user-created.txt")) - if os.IsNotExist(userFileErr) { - t.Log("KNOWN ISSUE: Stop() git clean -fd deleted user-created untracked file in main repo") - } -} - -func TestStartStop_FullRoundtrip_PreservesMainRepoState(t *testing.T) { - worktree := t.TempDir() - mainRepo := t.TempDir() - - initGitRepo(t, worktree) - initGitRepo(t, mainRepo) - - // Create committed content in main repo - os.WriteFile(filepath.Join(mainRepo, "committed.txt"), []byte("committed content"), 0644) - cmd := exec.Command("git", "add", "committed.txt") - cmd.Dir = mainRepo - cmd.Run() - cmd = exec.Command("git", "commit", "-m", "add committed file") - cmd.Dir = mainRepo - cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=Test", "GIT_AUTHOR_EMAIL=test@test.com", "GIT_COMMITTER_NAME=Test", "GIT_COMMITTER_EMAIL=test@test.com") - cmd.Run() - - // Create different content in worktree - os.WriteFile(filepath.Join(worktree, "committed.txt"), []byte("worktree version"), 0644) - os.WriteFile(filepath.Join(worktree, "newfile.txt"), []byte("new from worktree"), 0644) - cmd = exec.Command("git", "add", ".") - cmd.Dir = worktree - cmd.Run() - cmd = exec.Command("git", "commit", "-m", "worktree changes") - cmd.Dir = worktree - cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=Test", "GIT_AUTHOR_EMAIL=test@test.com", "GIT_COMMITTER_NAME=Test", "GIT_COMMITTER_EMAIL=test@test.com") - cmd.Run() - - // Start spotlight - _, err := Start(worktree, mainRepo) - if err != nil { - t.Fatalf("Start failed: %v", err) - } - - // Verify worktree content was synced - data, _ := os.ReadFile(filepath.Join(mainRepo, "committed.txt")) - if string(data) != "worktree version" { - t.Fatalf("file not synced, got: %s", string(data)) - } - - // Stop spotlight - _, err = Stop(worktree, mainRepo) - if err != nil { - t.Fatalf("Stop failed: %v", err) - } - - // Main repo should be restored to committed state - data, err = os.ReadFile(filepath.Join(mainRepo, "committed.txt")) - if err != nil { - t.Fatalf("committed file missing after Stop: %v", err) - } - if string(data) != "committed content" { - t.Errorf("committed file not restored, got: %q, want %q", string(data), "committed content") - } - - // New file from worktree should be cleaned - if _, err := os.Stat(filepath.Join(mainRepo, "newfile.txt")); !os.IsNotExist(err) { - t.Error("worktree-only file should be cleaned from main repo after Stop") - } -} - -// --- Edge cases --- - -func TestSync_EmptyWorktree(t *testing.T) { - worktree := t.TempDir() - mainRepo := t.TempDir() - - initGitRepo(t, worktree) - initGitRepo(t, mainRepo) - - // Only .gitkeep exists (from initGitRepo), same in both - os.WriteFile(filepath.Join(mainRepo, ".gitkeep"), []byte(""), 0644) - - result, err := Sync(worktree, mainRepo) - if err != nil { - t.Fatalf("Sync failed: %v", err) - } - - if !strings.Contains(result, "No changes") { - t.Errorf("expected no changes for identical repos, got: %s", result) - } -} - -func TestSync_PreservesFilePermissions(t *testing.T) { - worktree := t.TempDir() - mainRepo := t.TempDir() - - initGitRepo(t, worktree) - initGitRepo(t, mainRepo) - - // Create executable file - os.WriteFile(filepath.Join(worktree, "script.sh"), []byte("#!/bin/bash\necho hi"), 0755) - cmd := exec.Command("git", "add", "script.sh") - cmd.Dir = worktree - cmd.Run() - cmd = exec.Command("git", "commit", "-m", "add script") - cmd.Dir = worktree - cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=Test", "GIT_AUTHOR_EMAIL=test@test.com", "GIT_COMMITTER_NAME=Test", "GIT_COMMITTER_EMAIL=test@test.com") - cmd.Run() - - _, err := Sync(worktree, mainRepo) - if err != nil { - t.Fatalf("Sync failed: %v", err) - } - - info, err := os.Stat(filepath.Join(mainRepo, "script.sh")) - if err != nil { - t.Fatalf("script not synced: %v", err) - } - - // Check that execute bit is preserved - if info.Mode()&0111 == 0 { - t.Error("execute permission not preserved on synced file") - } -} - -func TestStart_StashFailureSilentlySwallowed(t *testing.T) { - // This test documents a potential issue: if git stash push fails - // but the main repo has uncommitted changes, Start() proceeds - // without error. When Stop() later runs git checkout ., those - // changes are lost. - // - // Currently this is unlikely in practice because stash failures - // are rare, but the behavior should be documented. - - worktree := t.TempDir() - mainRepo := t.TempDir() - - initGitRepo(t, worktree) - initGitRepo(t, mainRepo) - - // Start with clean main repo — no stash needed - result, err := Start(worktree, mainRepo) - if err != nil { - t.Fatalf("Start failed: %v", err) - } - - // State file should record stash_created=false - data, _ := os.ReadFile(StateFile(worktree)) - if strings.Contains(string(data), "stash_created=true") { - t.Error("stash_created should be false when no changes existed") - } - - _ = result -} diff --git a/internal/ui/app.go b/internal/ui/app.go index f9ab1c84..a2d2d6c0 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -25,7 +25,6 @@ import ( "github.com/bborn/workflow/internal/db" "github.com/bborn/workflow/internal/executor" "github.com/bborn/workflow/internal/github" - "github.com/bborn/workflow/internal/spotlight" "github.com/bborn/workflow/internal/tasksummary" ) @@ -98,9 +97,6 @@ type KeyMap struct { OpenBrowser key.Binding // Open PR OpenPR key.Binding - // Spotlight mode - Spotlight key.Binding - SpotlightSync key.Binding } // ShortHelp returns key bindings to show in the mini help. @@ -115,7 +111,7 @@ func (k KeyMap) FullHelp() [][]key.Binding { {k.JumpToPinned, k.JumpToUnpinned}, {k.FocusBacklog, k.FocusInProgress, k.FocusBlocked, k.FocusDone, k.CollapseBacklog, k.CollapseDone}, {k.Enter, k.New, k.Queue, k.QueueDangerous, k.Close}, - {k.Retry, k.Archive, k.Delete, k.OpenWorktree, k.OpenBrowser, k.Spotlight}, + {k.Retry, k.Archive, k.Delete, k.OpenWorktree, k.OpenBrowser}, {k.Filter, k.CommandPalette, k.Settings, k.Routines}, {k.ChangeStatus, k.TogglePin, k.Refresh, k.Help}, {k.Quit}, @@ -273,14 +269,6 @@ func DefaultKeyMap() KeyMap { key.WithKeys("G"), key.WithHelp("G", "open PR"), ), - Spotlight: key.NewBinding( - key.WithKeys("f"), - key.WithHelp("f", "spotlight"), - ), - SpotlightSync: key.NewBinding( - key.WithKeys("F"), - key.WithHelp("F", "spotlight sync"), - ), } } @@ -345,8 +333,6 @@ func ApplyKeybindingsConfig(km KeyMap, cfg *config.KeybindingsConfig) KeyMap { km.CollapseDone = applyBinding(km.CollapseDone, cfg.CollapseDone) km.OpenBrowser = applyBinding(km.OpenBrowser, cfg.OpenBrowser) km.OpenPR = applyBinding(km.OpenPR, cfg.OpenPR) - km.Spotlight = applyBinding(km.Spotlight, cfg.Spotlight) - km.SpotlightSync = applyBinding(km.SpotlightSync, cfg.SpotlightSync) return km } @@ -1283,14 +1269,6 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.notifyUntil = time.Now().Add(3 * time.Second) } - case spotlightMsg: - if msg.err != nil { - m.notification = fmt.Sprintf("%s Spotlight: %s", IconBlocked(), msg.err.Error()) - } else { - m.notification = fmt.Sprintf("šŸ”¦ %s", msg.message) - } - m.notifyUntil = time.Now().Add(5 * time.Second) - case taskEventMsg: // Real-time task update from executor event := msg.event @@ -2006,16 +1984,6 @@ func (m *AppModel) updateDashboard(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, m.openTaskDirectory(task) } - case key.Matches(msg, m.keys.Spotlight): - if task := m.kanban.SelectedTask(); task != nil && task.WorktreePath != "" { - return m, m.toggleSpotlight(task) - } - - case key.Matches(msg, m.keys.SpotlightSync): - if task := m.kanban.SelectedTask(); task != nil && task.WorktreePath != "" { - return m, m.syncSpotlight(task) - } - case key.Matches(msg, m.keys.Settings): m.settingsView = NewSettingsModel(m.db, m.width, m.height) m.previousView = m.currentView @@ -2546,12 +2514,6 @@ func (m *AppModel) updateDetail(msg tea.Msg) (tea.Model, tea.Cmd) { if key.Matches(keyMsg, m.keys.OpenPR) && m.selectedTask != nil && m.selectedTask.PRURL != "" { return m, m.openPR(m.selectedTask) } - if key.Matches(keyMsg, m.keys.Spotlight) && m.selectedTask != nil && m.selectedTask.WorktreePath != "" { - return m, m.toggleSpotlight(m.selectedTask) - } - if key.Matches(keyMsg, m.keys.SpotlightSync) && m.selectedTask != nil && m.selectedTask.WorktreePath != "" { - return m, m.syncSpotlight(m.selectedTask) - } if key.Matches(keyMsg, m.keys.ToggleShellPane) && m.detailView != nil { m.detailView.ToggleShellPane() return m, nil @@ -4460,55 +4422,6 @@ func (m *AppModel) openPR(task *db.Task) tea.Cmd { } } -// spotlightMsg is returned after a spotlight action completes. -type spotlightMsg struct { - action string // "start", "stop", "sync" - message string - err error -} - -// toggleSpotlight starts or stops spotlight mode for the given task. -func (m *AppModel) toggleSpotlight(task *db.Task) tea.Cmd { - return func() tea.Msg { - if task.WorktreePath == "" { - return spotlightMsg{err: fmt.Errorf("no worktree for task #%d", task.ID)} - } - - project, err := m.db.GetProjectByName(task.Project) - if err != nil || project == nil { - return spotlightMsg{err: fmt.Errorf("failed to get project directory")} - } - - if spotlight.IsActive(task.WorktreePath) { - result, err := spotlight.Stop(task.WorktreePath, project.Path) - return spotlightMsg{action: "stop", message: result, err: err} - } - result, err := spotlight.Start(task.WorktreePath, project.Path) - return spotlightMsg{action: "start", message: result, err: err} - } -} - -// syncSpotlight syncs worktree changes to the main repo. -func (m *AppModel) syncSpotlight(task *db.Task) tea.Cmd { - return func() tea.Msg { - if task.WorktreePath == "" { - return spotlightMsg{err: fmt.Errorf("no worktree for task #%d", task.ID)} - } - - project, err := m.db.GetProjectByName(task.Project) - if err != nil || project == nil { - return spotlightMsg{err: fmt.Errorf("failed to get project directory")} - } - - if !spotlight.IsActive(task.WorktreePath) { - return spotlightMsg{err: fmt.Errorf("spotlight not active — press f to start")} - } - - result, err := spotlight.Sync(task.WorktreePath, project.Path) - return spotlightMsg{action: "sync", message: result, err: err} - } -} - // latestChoicePrompt checks whether a task has a pending permission/choice prompt // by reading recent DB logs written by the notification hook. Returns the prompt // message if still pending, or "" if resolved (e.g. "Agent resumed working", diff --git a/internal/ui/detail.go b/internal/ui/detail.go index e60e563e..52a7f491 100644 --- a/internal/ui/detail.go +++ b/internal/ui/detail.go @@ -22,7 +22,6 @@ import ( "github.com/bborn/workflow/internal/executor" "github.com/bborn/workflow/internal/github" "github.com/bborn/workflow/internal/qmd" - "github.com/bborn/workflow/internal/spotlight" ) // shouldSkipAutoExecutor returns true if the task should NOT automatically @@ -2656,25 +2655,6 @@ func (m *DetailModel) renderHeader() string { meta.WriteString(" ") } - // Spotlight badge - if t.WorktreePath != "" && spotlight.IsActive(t.WorktreePath) { - var spotlightStyle lipgloss.Style - if m.focused { - spotlightStyle = lipgloss.NewStyle(). - Padding(0, 1). - Background(lipgloss.Color("214")). // Amber/yellow - Foreground(lipgloss.Color("#000000")). - Bold(true) - } else { - spotlightStyle = lipgloss.NewStyle(). - Padding(0, 1). - Background(dimmedBg). - Foreground(dimmedFg) - } - meta.WriteString(spotlightStyle.Render("šŸ”¦ SPOTLIGHT")) - meta.WriteString(" ") - } - if t.Pinned { var pinStyle lipgloss.Style if m.focused { @@ -3187,16 +3167,6 @@ func (m *DetailModel) renderHelp() string { keys = append(keys, helpKey{"\\", toggleDesc, false}) } - // Spotlight mode - if m.task != nil && m.task.WorktreePath != "" { - if spotlight.IsActive(m.task.WorktreePath) { - keys = append(keys, helpKey{"f", "spotlight off", false}) - keys = append(keys, helpKey{"F", "sync", false}) - } else { - keys = append(keys, helpKey{"f", "spotlight", false}) - } - } - // Open PR shortcut (only when task has a PR) if m.task != nil && m.task.PRURL != "" { keys = append(keys, helpKey{"G", "open PR", false}) diff --git a/parity-ignore.json b/parity-ignore.json index 8406d11a..0967ef42 100644 --- a/parity-ignore.json +++ b/parity-ignore.json @@ -1,3 +1 @@ -{ - "SpotlightSync": "macOS Spotlight metadata export is a local-indexing concern; intentionally TUI/CLI-only." -} +{} diff --git a/skills/taskyou/SKILL.md b/skills/taskyou/SKILL.md index 789ef646..92de410e 100644 --- a/skills/taskyou/SKILL.md +++ b/skills/taskyou/SKILL.md @@ -249,10 +249,6 @@ the worktree, not the executor inside it. - `taskyou_create_task` — Create a follow-up task. - `taskyou_list_tasks` — See other active tasks in this project. -**Worktree sync:** -- `taskyou_spotlight` — Sync worktree changes to the main repo so you can - exercise the running app against your changes (`start`/`stop`/`sync`/`status`). - ## Best Practices 1. **Always check the board first** — Understand context before acting