d83c1dd982
* feat(diff): use stored base_branch for diff stats + per-task compare picker
Task-card additions/deletions and the Changes panel were computed against a
hardcoded origin/main → master priority list inside agentctl. For tasks
branched off a release/staging branch this inflated the counts by every
commit since the actual fork point, and the Changes panel grouped unrelated
files alongside the task's real work.
This wires the per-repo task_repositories.base_branch end-to-end into the
agentctl WorkspaceTracker so each repo's diff stats resolve against the
recorded base instead of the integration-branch fallback. A picker on the
Changes panel branch hover card lets the user change the base mid-task; the
new value is persisted via PATCH /tasks/:id/repositories/:repo_id, pushed
live to the running agentctl instance, and the session's cached
base_commit_sha is cleared so the commits panel + cumulative diff refetch
against the new base without a session restart.
Falls back to the existing origin/main → master list for legacy tasks
(empty base_branch) and to the branch tip when no merge-base exists with
HEAD (unrelated histories — local rebase backups, etc.) so the stats and
commits panels stay anchored on the same SHA in every failure mode.
* fix(agentctl): sanitize user-controlled base_branch refs before git exec
CodeQL flagged the picker's user-controlled base_branch payload reaching
exec.Command("git", ..., baseBranch) inside workspace_git_cmd.go as a
command-built-from-user-controlled-source vulnerability. A malicious caller
could smuggle a leading-"-" argument like "-upload-pack=..." that git
itself reinterprets as a flag, even though exec.Command keeps args
separate from the shell.
Adds an IsSafeGitRef sanitizer at every boundary the value crosses on its
way to git: the service write path, the agentctl HTTP handlers
(/workspace/base-branches, /git/log, /git/cumulative-diff), and
WorkspaceTracker.SetBaseBranch itself. Unsafe values are rejected at the
service tier and silently downgraded to the no-override fallback inside
agentctl so a misbehaving caller can't half-break the tracker.
* fix(diff): address PR review blockers and CI lint
- Split UpdateRepositoryBaseBranch into validate/load/apply helpers to drop
cyclomatic complexity below the cyclop limit that gated Run Backend Tests.
- Replace direct m.cfg.BaseBranches reads/writes with getBaseBranches /
setBaseBranches under a dedicated RWMutex. UpdateBaseBranches used to
write under repoTrackersMu while rescan + lazy-subpath callers read under
workspaceTrackersMu, leaving an unsynchronized data race on the map.
- Drop the false 'PR target is auto-wired' claim from the MCP tool
description — GitOperator.CreatePR takes baseBranch as an explicit arg
and never reads the tracker's value.
- Picker no-op guard now compares against currentBase instead of storedBase
so selecting the displayed fallback (legacy task before any picker
change) is treated as no-op instead of triggering a spurious save.
- HTTP handler now maps non-validation service errors to 500 instead of
400, classifying via the existing isValidationError helper plus the new
unsafe-git-ref message.
* fix(diff): regex-based ref sanitiser at every git subprocess sink
CodeQL kept flagging workspace_git_cmd.go:43 even after SetBaseBranch
rejected unsafe values — the taint tracker doesn't follow sanitised stores
through struct fields, so it still saw user input flowing into
exec.Command("git", ..., baseBranch).
Switches IsSafeGitRef to a single regex check (a pattern CodeQL recognises
as a sanitiser barrier) and adds SanitizeGitRef calls inline at every
agentctl call site that splices a user-controlled ref into a git arg:
- workspace_git_status.resolveStoredRef (before the rev-parse probes)
- workspace_git_status.computeBaseCommit (before merge-base/rev-parse)
- workspace_git_status.getAheadBehindCounts (before rev-list)
- agentctl/server/api/git.go runGitLogForRepo + handleGitCumulativeDiff
(before computeMergeBase/GetRevParse on the HTTP TargetBranch)
Behaviour is unchanged for valid refs — only narrows the allowlist (now
[A-Za-z0-9_./-]) so the sanitiser barrier is recognisable to static
analysis. SetBaseBranch's field-store sanitiser stays as defence in depth.
* fix(diff): transform sanitiser at every map read for CodeQL taint flow
CodeQL still flagged workspace_git_cmd.go:43 because:
- handleSetBaseBranches used an IsSafeGitRef bool guard before a verbatim
map[k] = v copy. CodeQL recognises regex-transforming sanitisers but not
bool-guarded passthroughs.
- lookupBaseBranch returned m.cfg.BaseBranches[k] verbatim. Even though
the map was sanitised on write, taint flow across map stores + field
stores loses the sanitised state in the analyser.
Switches both sites to the transforming SanitizeGitRef helper so every
read produces a freshly-sanitised value before it flows to a downstream
git argument. Behaviour is unchanged for valid refs.
* fix(diff): regex transformer sanitiser + guard live push from empty map
CodeQL still flagged the previous SanitizeGitRef because it returned the
input string unchanged on success — an identity passthrough that the taint
tracker doesn't recognise as a sanitiser barrier.
Switches to safeGitRefPattern.FindString so the output comes from the
regex match (not the parameter). IsSafeGitRef now wraps SanitizeGitRef so
the predicate and the transformer share one allowlist.
Also addresses two Claude review blockers:
- collectTaskBaseBranches returns a nil/empty map when the task has no
recorded base_branches; pushing that to agentctl wipes every tracker's
override. Now skipped — the DB row is the source of truth for the next
session launch.
- applyBaseBranchSideEffects now runs on context.WithoutCancel so an HTTP
/ WS request cancel after the task_repositories UPDATE commits can't
leave session.base_commit_sha stale + the live agentctl tracker map
un-pushed, drifting from the persisted row.
* fix(diff): share securityutil.IsValidBranchName + harden error classification
CodeQL still flagged the picker's regex sanitiser as taint passthrough.
Refactors SanitizeGitRef / isSafeBaseBranchRef to delegate to the existing
securityutil.IsValidBranchName helper that agentctl's Rebase / Merge /
RenameBranch already use without being flagged. The shared allowlist also
fixes a Claude review blocker: 'feature/@2024' used to pass the service
validator but get dropped by the agentctl-side check at push time,
silently wiping every tracker's override.
Plus the two error-classification blockers Claude raised on the previous
HEAD:
- WS handler (task_ws_handlers.go wsUpdateTaskRepository) now distinguishes
validation errors (surfaced verbatim) from internal faults (opaque
500-style message). HTTP path already did this via the existing
isValidationError helper.
- MCP handler (handlers.handleUpdateRepositoryBaseBranch) gets the same
treatment via a new isValidationError local to the mcp/handlers
package so raw service error strings stop leaking across the MCP
boundary.
* fix(diff): inline securityutil.IsValidBranchName at every git subprocess sink
CodeQL's interprocedural taint tracking wasn't recognising SanitizeGitRef
as a sanitiser even after it delegated to securityutil.IsValidBranchName
— the wrapper helper + map/field hops between source and sink kept the
flow tainted in the analyser.
Inlines the IsValidBranchName allowlist check (already used inline by
Rebase/Merge in git.go without being flagged) at every call site that
splices a user-controlled ref into a git subprocess argument:
- workspace_git_status.resolveStoredRef (before rev-parse --verify)
- workspace_git_status.computeBaseCommit (before merge-base / rev-parse)
- workspace_git_status.getAheadBehindCounts (before rev-list)
- agentctl/server/api/git.go runGitLogForRepo + handleGitCumulativeDiff
- agentctl/server/api/workspace_base_branches.go handleSetBaseBranches
Behaviour unchanged: same allowlist as Rebase / Merge / RenameBranch
already use. SanitizeGitRef / IsSafeGitRef survive as defence-in-depth
helpers for callers that want a one-line check; they delegate to the same
securityutil helper so the rules stay aligned.
* fix(diff): inline regexp.MatchString at every git subprocess sink
CodeQL still flagged workspace_git_cmd.go:43 even with the inline
securityutil.IsValidBranchName helper — the taint tracker apparently does
not follow into helper functions for the go/command-injection rule on
this path.
Hoists the regex to package-scope vars (safeBranchRefPattern in both
process and api packages) and calls regexp.MatchString directly at each
sink site. Behaviour is identical to securityutil.IsValidBranchName; only
the call form changes.
* fix(diff): SHA-shape allowlist on BaseCommit before downstream git diff
CodeQL still flagged workspace_git_cmd.go:43 because update.BaseCommit
is the OUTPUT of an earlier merge-base / rev-parse call whose argument
list contained a user-controlled branch ref — the analyser treats that
command's output as still tainted even when the input ref was sanitised.
Adds an inline 40-hex SHA-shape allowlist check in enrichWithBranchDiff
right before `git diff --numstat <update.BaseCommit>` so the sanitiser
barrier sits in the same function as the next subprocess call. Behaviour
is unchanged: merge-base / rev-parse already produce SHA-shaped output.
* fix(diff): async tracker refresh + tighten init spread order
Two CodeRabbit/Claude review fixes:
- UpdateBaseBranches stamps each tracker's new baseBranch synchronously
then runs RefreshGitStatus on a background goroutine instead of inside
the picker-save HTTP handler. Per-tracker git subprocesses can each
take ~3-5 calls; serialising them on the request goroutine made the
PATCH response wait for every refresh to finish. The picker UI re-fetches
via the existing WS stream once Refresh emits, so the handler doesn't
need to block.
- updateTaskRepositoryBaseBranch now spreads options.init BEFORE the
required method/body fields so an upstream caller's init overrides
can't replace the PATCH method or the base_branch payload. Matches
the convention deleteTask / moveTask already follow.
* fix(diff): narrow mcp isValidationError + opaque 500 + post-sanitize log count
Three final Claude review threads on the latest commit:
- mcp/handlers isValidationError drops the broad 'invalid' substring so
DB / IO errors whose messages happen to contain 'invalid' don't
misclassify as ErrorCodeValidation.
- task_http_handlers httpUpdateTaskRepository now logs the raw service
error server-side and returns an opaque 'failed to update task
repository' on the 500 path. Stops potential DB / IO detail leakage
through the HTTP boundary.
- workspace_base_branches debug log reports accepted + rejected counts
(post-sanitisation) instead of the pre-filter total so the metric
actually reflects what hit UpdateBaseBranches.
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
398 lines
16 KiB
Go
398 lines
16 KiB
Go
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"
|
|
"github.com/kandev/kandev/internal/common/securityutil"
|
|
"github.com/kandev/kandev/internal/common/subproc"
|
|
"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
|
|
// repositoryName identifies the repository this tracker covers when the
|
|
// agent's workspace is a multi-repo task root. Stamped onto every emitted
|
|
// GitStatusUpdate / FileListUpdate so the frontend can key per-repo state.
|
|
// Empty for the single-repo case.
|
|
repositoryName string
|
|
|
|
// baseBranch is the task-specific base branch used to compute diff stats
|
|
// (BaseCommit, Ahead/Behind). When set, it takes precedence over the
|
|
// hardcoded origin/main → master fallback list in workspace_git_status.go.
|
|
// Sourced from task_repositories.base_branch on the kandev backend.
|
|
// Empty for legacy tasks or external branches with no recorded base.
|
|
baseBranch string
|
|
|
|
// 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 for a single git
|
|
// repository (or a single workspace root that may or may not be a git repo).
|
|
// For multi-repo task roots use NewWorkspaceTrackerForRepo per repo subdir.
|
|
func NewWorkspaceTracker(workDir string, log *logger.Logger) *WorkspaceTracker {
|
|
tlog := log.WithFields(zap.String("component", "workspace-tracker"))
|
|
resolvedWorkDir := resolveExistingWorkDir(workDir, tlog)
|
|
// Multi-repo task roots are plain directories that hold one git worktree
|
|
// per repository as siblings. The git poller can't monitor a non-git path,
|
|
// so when the configured workDir isn't a git repo, fall back to the first
|
|
// child subdirectory that is. This preserves single-repo behavior for
|
|
// callers using NewWorkspaceTracker; multi-repo callers should use
|
|
// NewWorkspaceTrackerForRepo per repo subdir to get per-repo events.
|
|
resolvedWorkDir = preferGitRepoChildIfRootIsBare(resolvedWorkDir, tlog)
|
|
return newWorkspaceTracker(resolvedWorkDir, "", log)
|
|
}
|
|
|
|
// RepositoryName returns the repository name tag applied to events emitted by
|
|
// this tracker. Empty for the bare task-root / single-repo tracker; non-empty
|
|
// for per-repo trackers built via NewWorkspaceTrackerForRepo. Used by the
|
|
// rescan path to decide whether a discovered subdir already has a tracker.
|
|
func (wt *WorkspaceTracker) RepositoryName() string {
|
|
return wt.repositoryName
|
|
}
|
|
|
|
// SetBaseBranch records the task's stored base branch for this repository.
|
|
// Called once after construction by the process manager; subsequent git
|
|
// status updates use this value as the first candidate when resolving
|
|
// BaseCommit / Ahead / Behind. Empty disables the override and falls back
|
|
// to the hardcoded origin/main → master priority list.
|
|
//
|
|
// Unsafe ref names (leading "-", whitespace, shell metacharacters, ".." or
|
|
// other format violations) are rejected at this boundary because the value
|
|
// is interpolated into `exec.Command("git", …, baseBranch)` downstream and
|
|
// git itself interprets leading "-" as a flag. Rejection downgrades to the
|
|
// no-override fallback rather than erroring — keeps the tracker functional
|
|
// when an untrusted caller supplies garbage.
|
|
func (wt *WorkspaceTracker) SetBaseBranch(baseBranch string) {
|
|
wt.mu.Lock()
|
|
defer wt.mu.Unlock()
|
|
if !IsSafeGitRef(baseBranch) {
|
|
wt.baseBranch = ""
|
|
return
|
|
}
|
|
wt.baseBranch = baseBranch
|
|
}
|
|
|
|
// IsSafeGitRef reports whether ref is safe to splice into a `git`
|
|
// subprocess argument list. Empty input returns true so callers can treat
|
|
// "" as "no override" without an extra branch. Delegates to the
|
|
// `securityutil.IsValidBranchName` sanitiser that the rest of the agentctl
|
|
// git operations (Rebase, Merge, RenameBranch, …) already use — keeps one
|
|
// canonical allowlist across the package and inherits the CodeQL
|
|
// taint-tracking recognition that pattern carries.
|
|
//
|
|
// `origin/<name>` refs are split before the underlying check because the
|
|
// shared validator rejects "/" in the first character class.
|
|
func IsSafeGitRef(ref string) bool {
|
|
if ref == "" {
|
|
return true
|
|
}
|
|
return SanitizeGitRef(ref) != ""
|
|
}
|
|
|
|
// SanitizeGitRef returns ref when securityutil.IsValidBranchName accepts
|
|
// it (after handling the `origin/<name>` prefix), else the empty string.
|
|
// Use this at the call site immediately before passing a user-controlled
|
|
// ref name into a `git` subprocess argument so the sanitiser barrier sits
|
|
// inline with the sink.
|
|
func SanitizeGitRef(ref string) string {
|
|
if ref == "" || ref[len(ref)-1] == '/' {
|
|
return ""
|
|
}
|
|
if rest, ok := strings.CutPrefix(ref, "origin/"); ok {
|
|
if securityutil.IsValidBranchName(rest) {
|
|
return ref
|
|
}
|
|
return ""
|
|
}
|
|
if securityutil.IsValidBranchName(ref) {
|
|
return ref
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// BaseBranch returns the recorded base branch override, if any. Exposed for
|
|
// tests; production callers read it indirectly through the git-status loops.
|
|
func (wt *WorkspaceTracker) BaseBranch() string {
|
|
wt.mu.RLock()
|
|
defer wt.mu.RUnlock()
|
|
return wt.baseBranch
|
|
}
|
|
|
|
// NewWorkspaceTrackerForRepo creates a tracker scoped to a specific repository
|
|
// subdirectory. The repositoryName is stamped onto every emitted event so the
|
|
// frontend can route updates per repo for multi-repo task roots.
|
|
func NewWorkspaceTrackerForRepo(workDir, repositoryName string, log *logger.Logger) *WorkspaceTracker {
|
|
tlog := log.WithFields(
|
|
zap.String("component", "workspace-tracker"),
|
|
zap.String("repository_name", repositoryName),
|
|
)
|
|
resolvedWorkDir := resolveExistingWorkDir(workDir, tlog)
|
|
return newWorkspaceTracker(resolvedWorkDir, repositoryName, log)
|
|
}
|
|
|
|
func newWorkspaceTracker(resolvedWorkDir, repositoryName string, log *logger.Logger) *WorkspaceTracker {
|
|
logFields := []zap.Field{zap.String("component", "workspace-tracker")}
|
|
if repositoryName != "" {
|
|
logFields = append(logFields, zap.String("repository_name", repositoryName))
|
|
}
|
|
|
|
// 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,
|
|
repositoryName: repositoryName,
|
|
logger: log.WithFields(logFields...),
|
|
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,
|
|
}
|
|
}
|
|
|
|
// preferGitRepoChildIfRootIsBare returns workDir unchanged when it's already a
|
|
// git repo, otherwise returns the path of the first immediate child directory
|
|
// that is a git repo (or worktree). Used to make the workspace tracker work
|
|
// for multi-repo task roots, which are plain holder directories with each
|
|
// repo's worktree as a child.
|
|
//
|
|
// Returns workDir unchanged when no git child is found — the tracker will
|
|
// then run with no git data, matching the pre-multi-repo behavior for
|
|
// repo-less tasks (e.g. quick chat).
|
|
func preferGitRepoChildIfRootIsBare(workDir string, log *logger.Logger) string {
|
|
if workDir == "" || resolveGitIndexPath(workDir) != "" {
|
|
return workDir
|
|
}
|
|
entries, err := os.ReadDir(workDir)
|
|
if err != nil {
|
|
return workDir
|
|
}
|
|
for _, entry := range entries {
|
|
if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
|
|
continue
|
|
}
|
|
candidate := filepath.Join(workDir, entry.Name())
|
|
if resolveGitIndexPath(candidate) != "" {
|
|
log.Info("workspace tracker falling back to repo subdirectory (multi-repo task root is not itself a git repo)",
|
|
zap.String("task_root", workDir),
|
|
zap.String("repo_subdir", candidate))
|
|
return candidate
|
|
}
|
|
}
|
|
return workDir
|
|
}
|
|
|
|
// 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.
|
|
//
|
|
// Multi-repo task roots are NOT git repos themselves (they hold per-repo
|
|
// child worktrees as siblings), but `git rev-parse` ascends until it finds
|
|
// a `.git` — for tasks nested under a developer's own kandev checkout this
|
|
// would land on the OUTER worktree and silently emit its status as if it
|
|
// were the task. We guard against that by requiring the resolved git
|
|
// top-level to be the same path as workDir (or, for repo subdirs called
|
|
// here from `scanRepositorySubdirs`, an absolute git-dir reachable from the
|
|
// dir's own `.git` file). In practice this means: workDir must contain its
|
|
// own `.git` entry — file or directory — for the path to be considered a
|
|
// valid git repo for tracking.
|
|
func resolveGitIndexPath(workDir string) string {
|
|
if !workDirHasOwnGitEntry(workDir) {
|
|
return ""
|
|
}
|
|
// One-shot probe at workspace setup. Acquire the throttle slot first
|
|
// (30s budget), then start the 5s exec timer — otherwise a busy git
|
|
// pool would burn through the exec budget queueing and return "",
|
|
// which the caller treats as "not a git repo" and permanently
|
|
// disables polling for the workspace.
|
|
acquireCtx, cancelAcquire := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancelAcquire()
|
|
release, err := subproc.Git().Acquire(acquireCtx)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
defer release()
|
|
execCtx, cancelExec := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancelExec()
|
|
cmd := exec.CommandContext(execCtx, "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
|
|
}
|
|
|
|
// workDirHasOwnGitEntry returns true when workDir contains a `.git` entry of
|
|
// its own (file for worktrees, directory for plain repos). This is what
|
|
// makes a directory "a git repo" from a tracker's perspective — without it,
|
|
// git would ascend up the tree to the nearest ancestor `.git`, which is the
|
|
// wrong scope for nested layouts (e.g. a multi-repo task root sitting
|
|
// inside the developer's kandev checkout).
|
|
func workDirHasOwnGitEntry(workDir string) bool {
|
|
if workDir == "" {
|
|
return false
|
|
}
|
|
_, err := os.Lstat(filepath.Join(workDir, ".git"))
|
|
return err == nil
|
|
}
|
|
|
|
// 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")
|
|
})
|
|
}
|