package process import ( "context" "os" "os/exec" "path/filepath" "strings" "sync" "time" "github.com/kandev/kandev/internal/agentctl/types" "github.com/kandev/kandev/internal/common/logger" "go.uber.org/zap" ) // DefaultGitPollInterval is the default interval for polling git status const DefaultGitPollInterval = 3 * time.Second // fileStatus constants for FileInfo.Status values. const ( fileStatusDeleted = "deleted" fileStatusModified = "modified" fileStatusUntracked = "untracked" ) // WorkspaceTracker monitors workspace changes and provides real-time updates. // It uses git status polling instead of fsnotify to avoid file descriptor exhaustion // on macOS (where kqueue opens a file descriptor for every watched file). type WorkspaceTracker struct { workDir string gitIndexPath string // Cached, validated path to git index file (works with worktrees) logger *logger.Logger // Current state currentStatus types.GitStatusUpdate currentFiles types.FileListUpdate mu sync.RWMutex // Cached git state for detecting manual operations cachedHeadSHA string cachedBranchName string // Current branch name for detecting branch switches cachedIndexHash string // Hash of git status porcelain output to detect staging changes gitStateMu sync.RWMutex // Unified workspace stream subscribers workspaceStreamSubscribers map[types.WorkspaceStreamSubscriber]struct{} workspaceSubMu sync.RWMutex // Polling intervals (used in PollModeFast) filePollInterval time.Duration gitPollInterval time.Duration // pollMode is the current rate at which the polling loops scan git state. // Default at construction is PollModeSlow — safe fallback before the gateway // pushes a focus signal. Mutate via SetPollMode (never directly) so loops // receive the wake-up notification on transitions. pollMode PollMode pollModeMu sync.RWMutex // One channel per loop because we need both loops to wake up on a mode // change. A single shared channel would let the first reader steal the // signal so only one loop wakes. monitorModeChanged chan struct{} // buffered(1); read by monitorLoop gitPollModeChanged chan struct{} // buffered(1); read by pollGitChanges // Overlap guards: prevent tick pile-up when git commands take longer than the poll interval. monitorRunning int32 // atomic; 1 if monitorLoop tick is in progress gitPollRunning int32 // atomic; 1 if pollGitChanges tick is in progress // updateMu prevents concurrent updateGitStatus calls from the two polling loops. // Polling loops use TryLock (skip if busy); RefreshGitStatus uses Lock (always completes). updateMu sync.Mutex // Control stopCh chan struct{} wg sync.WaitGroup started bool stopOnce sync.Once initialScanDone chan struct{} // closed after monitorLoop's first getWorkspaceState; tests wait on it tickDone chan struct{} // buffered(1); signalled after each monitorTick completes; tests wait on it cancelCtx context.Context // Cancellable context for killing in-flight git commands on Stop cancelFunc context.CancelFunc // Cancel function called during Stop } // NewWorkspaceTracker creates a new workspace tracker func NewWorkspaceTracker(workDir string, log *logger.Logger) *WorkspaceTracker { resolvedWorkDir := resolveExistingWorkDir(workDir, log.WithFields(zap.String("component", "workspace-tracker"))) // Cache validated git index path (works with worktrees where .git is a file) gitIndexPath := resolveGitIndexPath(resolvedWorkDir) ctx, cancel := context.WithCancel(context.Background()) return &WorkspaceTracker{ workDir: resolvedWorkDir, gitIndexPath: gitIndexPath, logger: log.WithFields(zap.String("component", "workspace-tracker")), workspaceStreamSubscribers: make(map[types.WorkspaceStreamSubscriber]struct{}), filePollInterval: DefaultFilePollInterval, gitPollInterval: DefaultGitPollInterval, // Default to fast polling — matches pre-PR behavior so newly-created // agentctl instances don't have a startup window where changes go // undetected for up to 30s. The gateway pushes slow/paused once it // knows no client is actively watching this workspace; until then, // fast is the safe default (a freshly-spawned instance was always // about to be used by someone, historically). Retained-task CPU // savings still apply because those instances eventually receive a // slow or paused mode push. pollMode: PollModeFast, monitorModeChanged: make(chan struct{}, 1), gitPollModeChanged: make(chan struct{}, 1), stopCh: make(chan struct{}), initialScanDone: make(chan struct{}), tickDone: make(chan struct{}, 1), cancelCtx: ctx, cancelFunc: cancel, } } // resolveGitIndexPath returns the validated path to the git index file. // Returns empty string if the path cannot be resolved or validated. // This handles worktrees where .git is a file pointing elsewhere. func resolveGitIndexPath(workDir string) string { cmd := exec.Command("git", "rev-parse", "--git-dir") cmd.Dir = workDir out, err := cmd.Output() if err != nil { return "" } gitDir := strings.TrimSpace(string(out)) if !filepath.IsAbs(gitDir) { gitDir = filepath.Join(workDir, gitDir) } // Clean and construct the index path gitDir = filepath.Clean(gitDir) indexPath := filepath.Join(gitDir, "index") // Validate the index file exists (this proves gitDir is a valid git directory) info, err := os.Stat(indexPath) if err != nil || info.IsDir() { return "" } return indexPath } // workDirExists checks whether the workspace directory still exists on disk. // Used to detect deleted worktrees and stop polling loops gracefully. // The workDir is validated at construction time via resolveExistingWorkDir, // so this only checks for subsequent deletion (e.g., worktree cleanup). func (wt *WorkspaceTracker) workDirExists() bool { _, err := os.Stat(wt.workDir) //nolint:gosec // workDir is validated at construction via resolveExistingWorkDir return !os.IsNotExist(err) } // Start begins monitoring the workspace using polling (no fsnotify). // File changes are detected via git status polling, which is efficient and // doesn't consume file descriptors like fsnotify/kqueue does on macOS. // The passed context is ignored — the tracker uses its own cancellable context // so that Stop() can kill in-flight git commands immediately. func (wt *WorkspaceTracker) Start(_ context.Context) { wt.mu.Lock() if wt.started { wt.mu.Unlock() wt.logger.Debug("workspace tracker already started, skipping") return } wt.started = true wt.mu.Unlock() // Start file change monitoring (uses git status polling) wt.wg.Add(1) go wt.monitorLoop(wt.cancelCtx) // Start git polling for detecting manual git operations (commits, resets, etc.) wt.wg.Add(1) go wt.pollGitChanges(wt.cancelCtx) } // stopTimeout is the maximum time Stop() will wait for goroutines to exit. const stopTimeout = 5 * time.Second // Stop stops the workspace tracker. It cancels in-flight git commands and waits // up to 5 seconds for goroutines to exit before proceeding. func (wt *WorkspaceTracker) Stop() { wt.stopOnce.Do(func() { wt.cancelFunc() // Kill in-flight git commands immediately close(wt.stopCh) done := make(chan struct{}) go func() { wt.wg.Wait() close(done) }() select { case <-done: // Clean shutdown case <-time.After(stopTimeout): wt.logger.Warn("workspace tracker stop timed out, proceeding anyway", zap.Duration("timeout", stopTimeout)) } wt.logger.Info("workspace tracker stopped") }) }