TL;DR
Several git subprocesses are spawned with exec.Command (no context.Context), so they have no timeout and ignore cancellation. The most serious one runs inside the SCM observer's poll loop: if that git hangs (network filesystem, credential prompt, .git lock, slow disk), it blocks the entire observer tick indefinitely and can't be interrupted by daemon shutdown.
This contradicts the repo convention (AGENTS.md): "Use context.Context as the first argument for functions that do I/O or blocking work." Every other git call in the worktree adapter already uses exec.CommandContext.
Affected call sites
| File:line |
Command |
Context available at caller? |
internal/observe/scm/observer.go:1202 |
git remote get-url origin |
Yes — called from discoverSubjects(ctx) (observer.go:447), inside the poll loop |
internal/service/project/service.go:242 |
git remote get-url origin |
Add request handler (request ctx available) |
internal/service/project/service.go:255 |
git symbolic-ref --short HEAD |
Add request handler |
internal/service/project/service.go:347 |
git rev-parse --show-toplevel (isGitRepo) |
Add request handler |
Contrast: internal/adapters/workspace/gitworktree/commands.go + workspace.go:530 already thread ctx through exec.CommandContext for every git call.
Why the observer one is the worst
resolveGitOriginURL is invoked per-project inside discoverSubjects, which runs on every SCM observer tick (DefaultTickInterval):
// observer.go:447
if url := resolveGitOriginURL(p.Path); url != "" { ... }
// observer.go:1201
func resolveGitOriginURL(path string) string {
out, err := exec.Command("git", "-C", path, "remote", "get-url", "origin").Output()
...
}
If git blocks on p.Path (a stale NFS/SMB mount, a credential helper prompting on a misconfigured remote, an index.lock left by a crashed git, or just a slow disk), the call never returns. That:
- Stalls the whole observer tick — no PR/CI facts get refreshed for any project while it's stuck.
- Ignores shutdown — daemon
Stop() cancels the observer's ctx, but the orphaned git process keeps the goroutine parked, so graceful shutdown waits out its ShutdownTimeout and then leaks the child process.
The three project/service.go calls are lower-risk (they run on an interactive project add, not a loop) but share the same defect: a hung git wedges the request past the 60s request timeout with no way to cancel the child.
Impact
- SCM observer can deadlock indefinitely on a single bad repo path, silently freezing live PR/CI updates for all projects.
- Orphaned
git child processes survive daemon shutdown.
project add can hang a request thread on a pathological repo.
Suggested fix (small + surgical)
- Change all four to
exec.CommandContext(ctx, "git", ...).
- Thread
ctx into resolveGitOriginURL(ctx, path) — discoverSubjects already has it.
- For
project/service.go, pass the request ctx into resolveGitOriginURL / resolveDefaultBranch / isGitRepo (callers in Add already hold it).
- Optionally wrap with a short
context.WithTimeout (a few seconds) so even an uncancelled parent ctx can't park on a wedged git.
No behavior change on the happy path; just bounds the unhappy one.
Acceptance criteria
Filed after reviewing all open/closed issues and PRs — not tracked elsewhere (closest is #97, which is about isGitRepo case-sensitivity/testability, not cancellation).
TL;DR
Several
gitsubprocesses are spawned withexec.Command(nocontext.Context), so they have no timeout and ignore cancellation. The most serious one runs inside the SCM observer's poll loop: if thatgithangs (network filesystem, credential prompt,.gitlock, slow disk), it blocks the entire observer tick indefinitely and can't be interrupted by daemon shutdown.This contradicts the repo convention (AGENTS.md): "Use
context.Contextas the first argument for functions that do I/O or blocking work." Every other git call in the worktree adapter already usesexec.CommandContext.Affected call sites
internal/observe/scm/observer.go:1202git remote get-url origindiscoverSubjects(ctx)(observer.go:447), inside the poll loopinternal/service/project/service.go:242git remote get-url originAddrequest handler (request ctx available)internal/service/project/service.go:255git symbolic-ref --short HEADAddrequest handlerinternal/service/project/service.go:347git rev-parse --show-toplevel(isGitRepo)Addrequest handlerContrast:
internal/adapters/workspace/gitworktree/commands.go+workspace.go:530already threadctxthroughexec.CommandContextfor every git call.Why the observer one is the worst
resolveGitOriginURLis invoked per-project insidediscoverSubjects, which runs on every SCM observer tick (DefaultTickInterval):If
gitblocks onp.Path(a stale NFS/SMB mount, a credential helper prompting on a misconfigured remote, anindex.lockleft by a crashed git, or just a slow disk), the call never returns. That:Stop()cancels the observer'sctx, but the orphanedgitprocess keeps the goroutine parked, so graceful shutdown waits out itsShutdownTimeoutand then leaks the child process.The three
project/service.gocalls are lower-risk (they run on an interactiveproject add, not a loop) but share the same defect: a hung git wedges the request past the 60s request timeout with no way to cancel the child.Impact
gitchild processes survive daemon shutdown.project addcan hang a request thread on a pathological repo.Suggested fix (small + surgical)
exec.CommandContext(ctx, "git", ...).ctxintoresolveGitOriginURL(ctx, path)—discoverSubjectsalready has it.project/service.go, pass the requestctxintoresolveGitOriginURL/resolveDefaultBranch/isGitRepo(callers inAddalready hold it).context.WithTimeout(a few seconds) so even an uncancelled parent ctx can't park on a wedged git.No behavior change on the happy path; just bounds the unhappy one.
Acceptance criteria
exec.CommandContextwith a real (cancellable) context.Filed after reviewing all open/closed issues and PRs — not tracked elsewhere (closest is #97, which is about isGitRepo case-sensitivity/testability, not cancellation).