fix: varargs binding bug in git.nim runGit/runGitCheck

Nim's varargs doesn't consume the last positional argument when there's
a trailing parameter with a default value. This caused calls like
`runGit("fetch", "origin")` to be parsed as:
  - args = ["fetch"]
  - workDir = "origin"

Instead of the intended:
  - args = ["fetch", "origin"]
  - workDir = ""

Fixed by changing from varargs to openArray, which requires explicit
array syntax at call sites: `runGit(["fetch", "origin"])`.

Also includes P2 bug fixes:
- Start heartbeat before state transition (skills-qekj)
- Reject symlinks when reading context file (skills-16zf)
- Case-insensitive conflict detection (skills-n3qp)

Smoke tested: spawn, status, start, show, cancel all work.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
dan 2026-01-10 21:24:41 -08:00
parent 3afd621bb4
commit 405e6b9aee

View file

@ -7,8 +7,9 @@ import std/[os, osproc, strutils, strformat, streams]
import ./types import ./types
import ./utils import ./utils
proc runGit*(args: varargs[string], workDir: string = ""): tuple[output: string, exitCode: int] = proc runGit*(args: openArray[string], workDir = ""): tuple[output: string, exitCode: int] =
## Run a git command and return output + exit code ## Run a git command and return output + exit code
## Note: uses openArray instead of varargs to avoid binding issues
var cmd = "git" var cmd = "git"
var fullArgs: seq[string] = @[] var fullArgs: seq[string] = @[]
@ -25,7 +26,7 @@ proc runGit*(args: varargs[string], workDir: string = ""): tuple[output: string,
result.exitCode = process.waitForExit() result.exitCode = process.waitForExit()
process.close() process.close()
proc runGitCheck*(args: varargs[string], workDir: string = ""): string = proc runGitCheck*(args: openArray[string], workDir = ""): string =
## Run git command, raise GitError on failure ## Run git command, raise GitError on failure
let (output, code) = runGit(args, workDir) let (output, code) = runGit(args, workDir)
if code != 0: if code != 0:
@ -38,16 +39,16 @@ proc createWorktree*(taskId: string, fromBranch: string = "origin/integration"):
let worktree = worktreePath(taskId) let worktree = worktreePath(taskId)
# Fetch latest (warn but continue on failure - may be offline) # Fetch latest (warn but continue on failure - may be offline)
let (fetchOut, fetchCode) = runGit("fetch", "origin") let (fetchOut, fetchCode) = runGit(["fetch", "origin"])
if fetchCode != 0: if fetchCode != 0:
logWarn("createWorktree", "fetch failed, continuing with local state: " & fetchOut.strip()) logWarn("createWorktree", "fetch failed, continuing with local state: " & fetchOut.strip())
# Create branch from base # Create branch from base
discard runGitCheck("branch", branch, fromBranch) discard runGitCheck(["branch", branch, fromBranch])
# Create worktree # Create worktree
createDir(parentDir(worktree)) createDir(parentDir(worktree))
discard runGitCheck("worktree", "add", worktree, branch) discard runGitCheck(["worktree", "add", worktree, branch])
return (branch, worktree) return (branch, worktree)
@ -55,33 +56,33 @@ proc removeWorktree*(taskId: string) =
## Remove a worktree ## Remove a worktree
let worktree = worktreePath(taskId) let worktree = worktreePath(taskId)
if dirExists(worktree): if dirExists(worktree):
let (output, code) = runGit("worktree", "remove", "--force", worktree) let (output, code) = runGit(["worktree", "remove", "--force", worktree])
if code != 0: if code != 0:
logWarn("removeWorktree", "failed for " & taskId & ": " & output.strip()) logWarn("removeWorktree", "failed for " & taskId & ": " & output.strip())
proc removeBranch*(taskId: string, remote: bool = true) = proc removeBranch*(taskId: string, remote: bool = true) =
## Remove feature branch ## Remove feature branch
let branch = branchName(taskId) let branch = branchName(taskId)
let (localOut, localCode) = runGit("branch", "-D", branch) let (localOut, localCode) = runGit(["branch", "-D", branch])
if localCode != 0: if localCode != 0:
logWarn("removeBranch", "local delete failed for " & branch & ": " & localOut.strip()) logWarn("removeBranch", "local delete failed for " & branch & ": " & localOut.strip())
if remote: if remote:
let (remoteOut, remoteCode) = runGit("push", "origin", "--delete", branch) let (remoteOut, remoteCode) = runGit(["push", "origin", "--delete", branch])
if remoteCode != 0: if remoteCode != 0:
logWarn("removeBranch", "remote delete failed for " & branch & ": " & remoteOut.strip()) logWarn("removeBranch", "remote delete failed for " & branch & ": " & remoteOut.strip())
proc rebaseOnIntegration*(worktree: string): bool = proc rebaseOnIntegration*(worktree: string): bool =
## Rebase worktree on integration branch. Returns false on conflict. ## Rebase worktree on integration branch. Returns false on conflict.
let (fetchOut, fetchCode) = runGit("fetch", "origin", workDir = worktree) let (fetchOut, fetchCode) = runGit(["fetch", "origin"], workDir = worktree)
if fetchCode != 0: if fetchCode != 0:
logWarn("rebaseOnIntegration", "fetch failed, using local state: " & fetchOut.strip()) logWarn("rebaseOnIntegration", "fetch failed, using local state: " & fetchOut.strip())
let (output, code) = runGit("rebase", "origin/integration", workDir = worktree) let (output, code) = runGit(["rebase", "origin/integration"], workDir = worktree)
if code != 0: if code != 0:
if "conflict" in output.toLowerAscii(): if "conflict" in output.toLowerAscii():
return false return false
# Other error - abort and raise # Other error - abort and raise
discard runGit("rebase", "--abort", workDir = worktree) discard runGit(["rebase", "--abort"], workDir = worktree)
raise newException(GitError, &"Rebase failed: {output}") raise newException(GitError, &"Rebase failed: {output}")
return true return true
@ -93,7 +94,7 @@ proc isRebaseInProgress*(worktree: string): bool =
proc pushBranch*(worktree: string, branch: string) = proc pushBranch*(worktree: string, branch: string) =
## Push branch to origin ## Push branch to origin
discard runGitCheck("push", "-u", "origin", branch, workDir = worktree) discard runGitCheck(["push", "-u", "origin", branch], workDir = worktree)
proc mergeToIntegration*(taskId: string, maxRetries: int = 3): bool = proc mergeToIntegration*(taskId: string, maxRetries: int = 3): bool =
## Merge feature branch to integration with retry loop ## Merge feature branch to integration with retry loop
@ -101,23 +102,23 @@ proc mergeToIntegration*(taskId: string, maxRetries: int = 3): bool =
for attempt in 1..maxRetries: for attempt in 1..maxRetries:
# Fetch latest # Fetch latest
discard runGitCheck("fetch", "origin", "integration", branch) discard runGitCheck(["fetch", "origin", "integration", branch])
# Checkout and reset integration # Checkout and reset integration
discard runGitCheck("checkout", "integration") discard runGitCheck(["checkout", "integration"])
discard runGitCheck("reset", "--hard", "origin/integration") discard runGitCheck(["reset", "--hard", "origin/integration"])
# Merge # Merge
let (mergeOutput, mergeCode) = runGit("merge", "--no-ff", branch, let (mergeOutput, mergeCode) = runGit(["merge", "--no-ff", branch,
"-m", &"Merge {branch}") "-m", &"Merge {branch}"])
if mergeCode != 0: if mergeCode != 0:
if "conflict" in mergeOutput.toLowerAscii(): if "conflict" in mergeOutput.toLowerAscii():
discard runGit("merge", "--abort") discard runGit(["merge", "--abort"])
return false return false
raise newException(GitError, &"Merge failed: {mergeOutput}") raise newException(GitError, &"Merge failed: {mergeOutput}")
# Push # Push
let (pushOutput, pushCode) = runGit("push", "origin", "integration") let (pushOutput, pushCode) = runGit(["push", "origin", "integration"])
if pushCode == 0: if pushCode == 0:
return true return true
@ -132,16 +133,15 @@ proc mergeToIntegration*(taskId: string, maxRetries: int = 3): bool =
proc getConflictedFiles*(worktree: string): seq[string] = proc getConflictedFiles*(worktree: string): seq[string] =
## Get list of files with conflicts ## Get list of files with conflicts
let (output, _) = runGit("diff", "--name-only", "--diff-filter=U", let (output, _) = runGit(["diff", "--name-only", "--diff-filter=U"], workDir = worktree)
workDir = worktree)
for line in output.splitLines(): for line in output.splitLines():
if line.strip() != "": if line.strip() != "":
result.add(line.strip()) result.add(line.strip())
proc getBranchStatus*(worktree: string): tuple[ahead, behind: int] = proc getBranchStatus*(worktree: string): tuple[ahead, behind: int] =
## Get commits ahead/behind integration ## Get commits ahead/behind integration
let (output, code) = runGit("rev-list", "--left-right", "--count", let (output, code) = runGit(["rev-list", "--left-right", "--count",
"origin/integration...HEAD", workDir = worktree) "origin/integration...HEAD"], workDir = worktree)
if code != 0: if code != 0:
return (0, 0) return (0, 0)