Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,6 @@ bin/
# Tmux
tmux-*.log
.playwright-mcp/.mcp.json

# Spotlight mode
.spotlight-active
311 changes: 311 additions & 0 deletions internal/mcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
Expand Down Expand Up @@ -280,6 +281,21 @@ 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"},
},
},
},
})

Expand Down Expand Up @@ -683,6 +699,48 @@ 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

// Handle spotlight actions
result, err := s.handleSpotlight(action, task.WorktreePath, mainRepoDir)
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))
}
Expand Down Expand Up @@ -715,3 +773,256 @@ func (s *Server) send(resp jsonRPCResponse) {
s.writer.Write(data)
s.writer.Write([]byte("\n"))
}

// spotlightStateFile returns the path to the spotlight state file in the worktree.
func spotlightStateFile(worktreePath string) string {
return filepath.Join(worktreePath, ".spotlight-active")
}

// isSpotlightActive checks if spotlight mode is currently active for the worktree.
func isSpotlightActive(worktreePath string) bool {
_, err := os.Stat(spotlightStateFile(worktreePath))
return err == nil
}

// handleSpotlight handles spotlight mode operations.
func (s *Server) handleSpotlight(action, worktreePath, mainRepoDir string) (string, error) {
switch action {
case "start":
return s.spotlightStart(worktreePath, mainRepoDir)
case "stop":
return s.spotlightStop(worktreePath, mainRepoDir)
case "sync":
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 'sync' action doesn't check if spotlight mode is active before syncing. This could lead to unexpected behavior where files are synced to the main repo without a stash being created first, potentially overwriting uncommitted changes. Consider adding a check like the one in 'stop' action to ensure spotlight is active before syncing.

Suggested change
case "sync":
case "sync":
if !isSpotlightActive(worktreePath) {
return "", fmt.Errorf("spotlight mode is not active. Use 'start' to enable spotlight before syncing")
}

Copilot uses AI. Check for mistakes.
return s.spotlightSync(worktreePath, mainRepoDir)
case "status":
return s.spotlightStatus(worktreePath, mainRepoDir)
default:
return "", fmt.Errorf("unknown spotlight action: %s", action)
}
}
Comment on lines +789 to +806
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The spotlight operations don't validate that worktreePath and mainRepoDir are valid git repositories before executing git commands. If either path is not a git repository, the git commands will fail with potentially confusing error messages. Consider adding validation (e.g., checking for .git directory or running 'git rev-parse --git-dir') before performing operations to provide clearer error messages to users.

Copilot uses AI. Check for mistakes.
Comment on lines +789 to +806
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The spotlight operations are not protected against concurrent access. If multiple spotlight operations run simultaneously on the same worktree (e.g., sync and stop), there could be race conditions with the state file and git operations. While this may be unlikely in practice (single MCP server per task), consider adding mutex protection around spotlight operations or documenting that they should not be called concurrently.

Copilot uses AI. Check for mistakes.

// spotlightStart enables spotlight mode and performs initial sync.
func (s *Server) spotlightStart(worktreePath, mainRepoDir string) (string, error) {
if isSpotlightActive(worktreePath) {
return "Spotlight mode is already active. Use 'sync' to sync changes or 'stop' to disable.", nil
}

// First, stash any uncommitted changes in the main repo to preserve them
stashCmd := exec.Command("git", "stash", "push", "-m", "spotlight-backup-"+time.Now().Format("20060102-150405"))
stashCmd.Dir = mainRepoDir
stashOutput, _ := stashCmd.CombinedOutput()
stashCreated := !strings.Contains(string(stashOutput), "No local changes")

Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The stash detection logic relies on checking for the substring "No local changes" in the output. This approach is fragile because git output messages can vary across different git versions, locales, or if git changes its output format. A more reliable approach would be to check the exit code of the stash command (exit code 0 with specific output) or use 'git diff --quiet' to detect if there are uncommitted changes before attempting to stash.

Suggested change
// First, stash any uncommitted changes in the main repo to preserve them
stashCmd := exec.Command("git", "stash", "push", "-m", "spotlight-backup-"+time.Now().Format("20060102-150405"))
stashCmd.Dir = mainRepoDir
stashOutput, _ := stashCmd.CombinedOutput()
stashCreated := !strings.Contains(string(stashOutput), "No local changes")
// First, stash any uncommitted changes in the main repo to preserve them.
// Use `git diff --quiet` to robustly detect changes instead of parsing git's output.
hasChanges := false
// Check for unstaged changes
diffCmd := exec.Command("git", "diff", "--quiet")
diffCmd.Dir = mainRepoDir
if err := diffCmd.Run(); err != nil {
// Non-zero exit status indicates there are changes; treat other errors the same
hasChanges = true
}
// Check for staged changes
diffCachedCmd := exec.Command("git", "diff", "--cached", "--quiet")
diffCachedCmd.Dir = mainRepoDir
if err := diffCachedCmd.Run(); err != nil {
hasChanges = true
}
stashCreated := false
if hasChanges {
stashCmd := exec.Command("git", "stash", "push", "-m", "spotlight-backup-"+time.Now().Format("20060102-150405"))
stashCmd.Dir = mainRepoDir
// Consider the stash "created" only if the command succeeds.
if err := stashCmd.Run(); err == nil {
stashCreated = true
}
}

Copilot uses AI. Check for mistakes.
// 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(spotlightStateFile(worktreePath), []byte(stateContent), 0644); err != nil {
return "", fmt.Errorf("failed to create spotlight state file: %w", err)
}

// Perform initial sync
syncResult, err := s.spotlightSync(worktreePath, mainRepoDir)
if err != nil {
// Clean up state file if sync failed
os.Remove(spotlightStateFile(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
}

// spotlightStop disables spotlight mode and restores the main repo.
func (s *Server) spotlightStop(worktreePath, mainRepoDir string) (string, error) {
if !isSpotlightActive(worktreePath) {
return "Spotlight mode is not active.", nil
}

// Read state file to check if we created a stash
stateData, _ := os.ReadFile(spotlightStateFile(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
checkoutCmd.CombinedOutput()

// Clean any untracked files that were added
cleanCmd := exec.Command("git", "clean", "-fd")
cleanCmd.Dir = mainRepoDir
cleanCmd.CombinedOutput()
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The git commands 'git checkout .' and 'git clean -fd' are executed without checking their errors. If these commands fail, the main repository might not be properly restored, but the user would still receive a success message. The error should be captured and handled appropriately, potentially warning the user if restoration fails.

Copilot uses AI. Check for mistakes.

// Pop the stash if we created one
var stashMsg string
if stashCreated {
stashPopCmd := exec.Command("git", "stash", "pop")
stashPopCmd.Dir = mainRepoDir
if output, err := stashPopCmd.CombinedOutput(); err != nil {
stashMsg = fmt.Sprintf("⚠️ Failed to restore stash: %s", string(output))
} else {
stashMsg = "✓ Original main repo changes restored from stash"
}
}

// Remove the state file
os.Remove(spotlightStateFile(worktreePath))
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the stash pop fails, the spotlight state file is still removed. This means spotlight mode will appear to be inactive, but the main repo wasn't properly restored. Consider not removing the state file if stash pop fails, or at least providing a more prominent warning that manual intervention may be needed.

Suggested change
if stashCreated {
stashPopCmd := exec.Command("git", "stash", "pop")
stashPopCmd.Dir = mainRepoDir
if output, err := stashPopCmd.CombinedOutput(); err != nil {
stashMsg = fmt.Sprintf("⚠️ Failed to restore stash: %s", string(output))
} else {
stashMsg = "✓ Original main repo changes restored from stash"
}
}
// Remove the state file
os.Remove(spotlightStateFile(worktreePath))
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 The main repo may not be fully restored. Please inspect the git state in %s and resolve manually.", strings.TrimSpace(string(output)), mainRepoDir)
} else {
stashMsg = "✓ Original main repo changes restored from stash"
}
}
// Remove the state file only if restoration (including stash pop) succeeded
if !stashPopFailed {
os.Remove(spotlightStateFile(worktreePath))
}

Copilot uses AI. Check for mistakes.

msg := "🔦 Spotlight mode disabled!\n\n"
msg += "✓ Main repo restored to original state\n"
if stashMsg != "" {
msg += stashMsg + "\n"
}

return msg, nil
}

// spotlightSync syncs git-tracked files from worktree to main repo.
// It compares files between the worktree and main repo, copying any that differ.
func (s *Server) spotlightSync(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()

// Also get uncommitted changes
diffCmd := exec.Command("git", "diff", "--name-only", "HEAD")
diffCmd.Dir = worktreePath
diffOutput, _ := diffCmd.Output()

// Build set of all files to check
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
}
}
for _, file := range strings.Split(strings.TrimSpace(string(diffOutput)), "\n") {
if file != "" {
fileSet[file] = true
}
}
Comment on lines +918 to +959
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The sync operation only copies files from the worktree to the main repo but doesn't handle file deletions. If a file is deleted in the worktree, it won't be deleted in the main repo during sync. This could lead to discrepancies where the main repo has files that were removed from the worktree. Consider using 'git diff --name-status' to detect deletions and remove those files from the main repo as well.

Copilot uses AI. Check for mistakes.

// Copy files that differ between worktree and main repo
var synced, unchanged, failed int
for file := range fileSet {
if file == ".spotlight-active" || file == "" {
continue
}

srcPath := filepath.Join(worktreePath, file)
dstPath := filepath.Join(mainRepoDir, file)

Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The file sync operation doesn't validate or sanitize file paths before copying. If a git repository contains files with path traversal sequences (e.g., '../../../etc/passwd'), the filepath.Join could potentially write files outside the intended main repo directory. While git typically prevents such files from being tracked, consider adding path validation to ensure all destination paths are within the mainRepoDir using filepath.Clean and checking that the result starts with mainRepoDir.

Suggested change
srcPath := filepath.Join(worktreePath, file)
dstPath := filepath.Join(mainRepoDir, file)
// Clean and validate relative path to prevent path traversal
cleanFile := filepath.Clean(file)
if cleanFile == ".." || strings.HasPrefix(cleanFile, ".."+string(os.PathSeparator)) || filepath.IsAbs(cleanFile) {
failed++
continue
}
srcPath := filepath.Join(worktreePath, cleanFile)
dstPath := filepath.Join(mainRepoDir, cleanFile)
dstPath = filepath.Clean(dstPath)
// Ensure destination path is within mainRepoDir
prefix := mainRepoDir
if prefix != "" {
prefix = filepath.Clean(prefix)
}
if dstPath != prefix && !strings.HasPrefix(dstPath, prefix+string(os.PathSeparator)) {
failed++
continue
}

Copilot uses AI. Check for mistakes.
// 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
dstData, err := os.ReadFile(dstPath)
if err == nil && string(srcData) == string(dstData) {
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comparing file contents using byte-to-byte string comparison (string(srcData) == string(dstData)) may not be the most efficient approach for large files. While functionally correct, consider using bytes.Equal(srcData, dstData) which is more idiomatic and can be more efficient as it doesn't require converting to strings.

Copilot uses AI. Check for mistakes.
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++
}

if synced == 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 unchanged > 0 {
result += fmt.Sprintf(" (%d unchanged)", unchanged)
}
if failed > 0 {
result += fmt.Sprintf(" (%d failed)", failed)
}

return result, nil
}

// spotlightStatus returns the current spotlight status.
func (s *Server) spotlightStatus(worktreePath, mainRepoDir string) (string, error) {
if !isSpotlightActive(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(spotlightStateFile(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
}
Loading
Loading