Documentation
¶
Overview ¶
Package gitutil provides shared git safety primitives used by both the daemon (pull/fetch) and CLI (push/commit) code paths.
Index ¶
- Constants
- Variables
- func AbortOrClearRebase(ctx context.Context, repoPath, reason string, logger *slog.Logger) error
- func AuditAndAbort(ctx context.Context, repoPath string, op AuditableOp, reason string, ...) error
- func CreateRescueBranch(ctx context.Context, repoPath, reason string, logger *slog.Logger) (string, error)
- func DeepenUntilAncestor(ctx context.Context, repoPath, commit, ref string, step, maxIterations int) (bool, error)
- func FetchHeadAge(repoPath string) (time.Duration, bool)
- func GitHTTPTimeoutFlags() []string
- func HardenedCloneArgs(allowFileTransport bool) []string
- func HasLockFiles(gitDir string) []string
- func IsGitRepo(path string) bool
- func IsRebaseInProgress(repoPath string) bool
- func IsSafeForGitOps(repoPath string) error
- func NewNetworkCmd(ctx context.Context, args ...string) *exec.Cmd
- func PushWithRetry(ctx context.Context, repoPath string, opts PushOpts) error
- func RebaseAge(repoPath string) (time.Duration, bool)
- func RemoveStaleLockFiles(gitDir string) (removed []string, errs []error)
- func RescueThenAbort(ctx context.Context, repoPath, reason string, logger *slog.Logger) (string, error)
- func ResolveRebaseAcceptTheirs(ctx context.Context, repoPath string, safePrefixes []string, ...) error
- func RunGit(ctx context.Context, repoPath string, args ...string) (string, error)
- func SanitizeOutput(output string) string
- func StrandedCommitCount(ctx context.Context, repoPath string) (int, error)
- func StripLFSConfig(repoPath string)
- func ValidateCloneURL(cloneURL string, trustedHosts []string, allowLocal bool) error
- func ValidateHTTPSHost(rawURL string, allowedHosts map[string]bool) error
- type AuditableOp
- type GitRunner
- type PushOpts
- type RealRunner
- type RepoState
Constants ¶
const AbandonedLockAge = 1 * time.Hour
AbandonedLockAge is how old an OWNERLESS lock (index.lock, shallow.lock, config.lock, HEAD.lock) must be before it is treated as abandoned.
Much longer than StaleLockAge on purpose. Those files carry no PID, so there is no owner to probe and age is the only signal — and five minutes is not a safe bound: a slow `git pull --rebase` on a large repo over a bad network can legitimately hold the index longer than that, and deleting the lock would admit a second writer and risk index corruption or lost uncommitted work.
No index operation stays live for an hour. A lock that old is a crash, which is the case worth recovering from — the real incident was a next-index-<pid>.lock plus an index.lock sitting untouched for three months.
const MinFetchHeadAge = 30 * time.Second
MinFetchHeadAge is the minimum age of FETCH_HEAD before we'll fetch again. Prevents redundant fetches if another process fetched recently.
const StaleLockAge = 5 * time.Minute
StaleLockAge is how old a git lock file must be before we consider it abandoned. Git operations normally hold locks for milliseconds to a few seconds, but a slow git pull --rebase on a large repo over a poor network can hold index.lock for several minutes. 5 minutes is conservative enough to cover legitimate operations while still recovering from crashed processes.
const StaleRebaseThreshold = 5 * time.Minute
StaleRebaseThreshold is how long a rebase must have been in progress before automated recovery treats it as a wedge rather than an in-flight operation. A fresh rebase (younger than this) is almost always a live `pull --rebase` or a human mid-operation and must be left alone. Matches the daemon's own staleness gate so the CLI (doctor) and daemon agree on what "stuck" means.
Variables ¶
var ErrNoStrandedCommits = fmt.Errorf("no stranded commits: nothing to rescue")
ErrNoStrandedCommits reports that HEAD carries nothing that is not already reachable from a branch or remote — so there is nothing to rescue and the caller should use the ordinary recovery path.
Functions ¶
func AbortOrClearRebase ¶ added in v0.11.0
AbortOrClearRebase clears an in-progress rebase state that is blocking sync.
It first tries the reversible, audited `git rebase --abort` (via AuditAndAbort), which is correct whenever the rebase state directory is intact. When abort FAILS because the state directory is structurally incomplete — a "zombie" left by a process killed mid-rebase, e.g. a .git/rebase-merge containing only an `autostash` entry with no head-name/orig-head — abort cannot determine where to reset HEAD, so it escalates to `git rebase --quit`, which removes the state directory WITHOUT moving HEAD.
The quit escalation is gated on two conditions that together make it safe:
- The state directory is missing the metadata `--abort` needs (head-name / orig-head). A complete directory that still failed to abort is a different, unknown problem — surface it, don't guess.
- HEAD is on a real branch (not detached). A detached HEAD means the rebase had already rewound and was mid-replay, where --quit would strand HEAD at a partial-replay commit. A zombie killed during rebase init never rewound HEAD, so the branch still holds every original commit and --quit is a pure no-op on history.
Any parked working tree recorded in the state's `autostash` entry is logged (object id) before the directory is dropped so it stays recoverable as a dangling object — never silently discarded (.claude/rules/daemon-git.md).
Returns nil when no rebase remains in progress afterward; otherwise the error from the last recovery attempt. Logger MUST be non-nil in normal use; a nil logger falls back to a discard handler rather than panicking.
func AuditAndAbort ¶ added in v0.9.0
func AuditAndAbort(ctx context.Context, repoPath string, op AuditableOp, reason string, logger *slog.Logger) error
AuditAndAbort runs `git <op> --abort` on repoPath with structured logging before and after, so silent recovery from a wedged state leaves a clear audit trail. Per .claude/rules/daemon-git.md, daemon code must NEVER discard uncommitted changes without logging what was discarded.
Pre-abort log fields: op=<op>_abort_pre, repo, reason, head_sha, unmerged_count, unmerged_sample (first 3 paths, comma-joined), stash_count. Post-abort log fields: op=<op>_abort_post, repo, head_sha_after, success=true. On failure: op=<op>_abort_failed, repo, error.
Returns nil if the abort succeeded, or the abort error otherwise. Logger MUST be non-nil. Reason is a free-form string describing why the abort was triggered (e.g., "auto-resolve failed", "doctor --fix").
func CreateRescueBranch ¶ added in v0.13.0
func CreateRescueBranch(ctx context.Context, repoPath, reason string, logger *slog.Logger) (string, error)
CreateRescueBranch points a new rescue-wedge-<UTC> branch at HEAD and verifies it resolves, so commits reachable only from HEAD stop being one checkout away from unreferenced.
This is deliberately separable from RescueThenAbort because the two halves have very different risk profiles. Creating a branch is purely ADDITIVE — it adds a ref and mutates nothing else — so it is safe to run unattended, in an agent context, without a human present. Clearing the wedge is destructive and is not. Splitting them means an automated pass can always make the data safe even when it must leave the wedge for a human.
Returns ErrNoStrandedCommits when nothing is at risk, so callers do not litter the repo with rescue branches on healthy repos.
func DeepenUntilAncestor ¶ added in v0.9.0
func DeepenUntilAncestor(ctx context.Context, repoPath, commit, ref string, step, maxIterations int) (bool, error)
DeepenUntilAncestor attempts `git fetch --deepen <step>` in a loop until `git merge-base --is-ancestor <commit> <ref>` succeeds, or the cap is exhausted. Used by destructive ops (e.g. session redaction) that need a definitive ancestry answer in a shallow repo.
Returns:
- (true, nil) — ancestry confirmed (commit is reachable from ref).
- (false, nil) — ancestry definitively absent after full deepen, OR not shallow and not an ancestor. Caller should treat as "no".
- (false, err) — fetch or merge-base error other than non-ancestor.
Matches the pattern `git rebase --autosquash` has used since 2.39.
func FetchHeadAge ¶
FetchHeadAge returns how long ago FETCH_HEAD was last modified. Returns (0, false) if FETCH_HEAD doesn't exist or can't be read.
func GitHTTPTimeoutFlags ¶ added in v0.6.0
func GitHTTPTimeoutFlags() []string
GitHTTPTimeoutFlags returns git config flags that bound DNS/TCP/TLS connection time and detect stalled transfers. Without these, git inherits the OS DNS resolver timeout (~13 min on macOS) which blocks background operations.
- http.connectTimeout=10: fail DNS+TCP+TLS within 10s
- http.lowSpeedLimit=1000: minimum bytes/sec during transfer
- http.lowSpeedTime=15: abort if below lowSpeedLimit for 15s
func HardenedCloneArgs ¶ added in v0.10.0
HardenedCloneArgs returns the `-c` flags that disable git's dangerous transports for a clone. Prepend these to the git argument list, before the "clone" subcommand. Always pass "--" before the positional <url> <path> too, so a hostile URL can never be parsed as a flag.
allowFileTransport must be wired to a test-only override (e.g. gitserver.TestAllowFileTransport) so the suite can clone from file:// bare repos while production stays locked. In production it is always false.
func HasLockFiles ¶
HasLockFiles checks .git/ for stale lock files that block git operations. Returns the names of lock files found (empty slice = safe to proceed).
func IsGitRepo ¶ added in v0.6.0
IsGitRepo checks whether path is the root of a valid git repository. It reads .git/HEAD, which works for both regular repos (where .git is a directory) and worktrees (where .git is a file pointing to the real git dir). A readable HEAD is the most reliable lightweight check — it catches partial clones and corrupt repos that a simple os.Stat(".git") would miss.
func IsRebaseInProgress ¶
IsRebaseInProgress checks whether the repo is stuck in a broken rebase state. Returns true if .git/rebase-merge or .git/rebase-apply exists.
func IsSafeForGitOps ¶
IsSafeForGitOps combines lock file and rebase state checks into a single pre-flight check. Returns nil if safe to proceed, or an error describing why the repo is blocked.
func NewNetworkCmd ¶ added in v0.10.0
NewNetworkCmd builds an *exec.Cmd for a git operation that talks to a remote (clone, fetch, ls-remote, push). It is the single chokepoint that guarantees every network git invocation runs non-interactively.
GIT_TERMINAL_PROMPT=0: ox resolves credentials via the ox-managed credential helper, never an interactive prompt. Without this, a credential gap makes git prompt for a username on a TTY that the daemon (and doctor fallbacks) don't have — the prompt EOFs into a confusing "could not read Username ... Input/output error" instead of a clear auth failure.
Use this for every direct exec.Command("git", ...) network call so the env hardening can't be forgotten in one path while present in another — the exact drift that let the team-context clone prompt non-interactively while the ledger clone did not. RunGit applies the same env for calls that route through it; this covers the call sites that build their own *exec.Cmd (because they need to set Env, capture output differently, etc.).
The caller still sets Dir and appends any credential/protocol/timeout flags.
LC_ALL=C / LANG=C: matches RunGit's env — several callers substring-match git's output to classify failures (non-fast-forward, LFS, auth), which breaks silently on a host whose locale renders git's messages translated. See RunGit's comment in run.go for the full rationale.
commit.gpgsign=false / tag.gpgsign=false: also matches RunGit (run.go). Most network commands never commit, so this reads like a no-op — but `git pull --rebase` does (internal/daemon/sync_managed.go), and a signing prompt on the daemon's TTY-less environment kills that commit. The rebase then sits halted with a CLEAN, conflict-free index: byte-identical to the upstream-equivalent-commit halt that ResolveRebaseAcceptTheirs skips. Skipping it would silently discard content that was resolved but never recorded. Applied here rather than at the one committing call site so a future network command that commits inherits the hardening by default.
func PushWithRetry ¶ added in v0.6.0
PushWithRetry pushes a git repo to its remote with pre-flight checks, retry, conflict resolution, and backoff.
SAFETY: Force push (--force, --force-with-lease) is banned. All push conflicts are resolved via pull --rebase. Our git remotes reject force pushes server-side, so any force push attempt would fail anyway.
Pre-flight: lock/rebase safety, LFS config cleanup, optional credential refresh.
Retry loop: up to MaxRetries attempts with linear backoff (1s, 2s, 3s...). On non-fast-forward rejection: pulls with --rebase --autostash, optionally auto-resolves conflicts for paths in AutoResolvePrefixes.
func RebaseAge ¶ added in v0.11.0
RebaseAge returns how long a rebase has been in progress, based on the mtime of the .git/rebase-merge or .git/rebase-apply directory. The bool is false if no rebase is in progress (age is then meaningless).
Used to distinguish a transient, in-flight rebase (seconds old — leave it alone) from a genuinely wedged one abandoned by a prior crash or a rebase that stopped at an "edit"/conflict and was never continued (minutes/days old — safe to auto-recover). A fresh rebase that the daemon's own pull just started must NOT be aborted out from under itself.
func RemoveStaleLockFiles ¶ added in v0.6.0
RemoveStaleLockFiles removes git lock files older than StaleLockAge. Safe to call at daemon startup or before pull operations — only removes files that no running git process could still be holding. Returns the names of files removed and any removal errors encountered.
func RescueThenAbort ¶ added in v0.13.0
func RescueThenAbort(ctx context.Context, repoPath, reason string, logger *slog.Logger) (string, error)
RescueThenAbort recovers a ledger whose HEAD carries commits that exist NOWHERE else, by creating a verified rescue branch BEFORE it touches the rebase state.
Why this exists ¶
The ledger holds the user's only copy of unpushed session data. `git rebase --abort` resets HEAD to orig-head and `--quit` drops the state directory; either way, commits that were only reachable from a detached HEAD become unreferenced and survive solely in the reflog until gc prunes them.
AbortOrClearRebase deliberately REFUSES to escalate to --quit when HEAD is detached, precisely because quitting there would strand a partial replay. That refusal is correct and this function does not weaken it. The gap it leaves is that nothing then recovers the wedge at all: bd ox-akab stranded roughly six weeks of sessions on a detached HEAD, invisible to the user, because every automated path correctly declined to act and no path made the commits safe first.
The ordering, which is the whole point ¶
- Count commits reachable from HEAD but from no branch or remote. Zero means nothing is at risk: return ErrNoStrandedCommits so the caller falls back to the ordinary path.
- Create rescue-wedge-<UTC> at HEAD and VERIFY the ref resolves. A branch that was not created is not a rescue, and discovering that after the abort is discovering it too late.
- Only now attempt AbortOrClearRebase.
- Re-verify the rescue ref still resolves AND still carries the same commit count. Recovery that quietly moved the safety net is not recovery.
This function NEVER runs git gc, --prune, or reflog expire, and no caller may run them in the same doctor pass: unreferenced commits live in the reflog only until a gc, so pruning collapses the recovery window to zero.
Returns the rescue branch name so the caller can print it FIRST, before any other output. On any failure after step 2 the rescue branch is left in place on purpose — an orphaned rescue branch is cheap, and losing the commits is not.
func ResolveRebaseAcceptTheirs ¶ added in v0.5.0
func RunGit ¶
RunGit executes a git command with context for timeout/cancellation. Output is auto-sanitized to remove credentials. Use repoPath="" for commands that don't need -C.
func SanitizeOutput ¶
SanitizeOutput removes credentials and harmless noise from git command output.
func StrandedCommitCount ¶ added in v0.13.0
StrandedCommitCount returns how many commits are reachable from HEAD but from no branch and no remote-tracking ref — that is, commits that would become unreferenced if HEAD moved.
This is the alarm that went unrung for six weeks in bd ox-akab: session commits kept landing on a detached HEAD while every ref that anyone reads stayed behind. A non-zero value here means data exists in exactly one place.
func StripLFSConfig ¶
func StripLFSConfig(repoPath string)
StripLFSConfig removes lfs.repositoryformatversion from local git config. This config is set by git-lfs when filter.lfs.required=true is global, but it causes HTTP 403 on push to GitLab when the server-side ALB doesn't expect LFS-aware clients. Safe to call on any repo — no-op if not set.
func ValidateCloneURL ¶ added in v0.10.0
ValidateCloneURL rejects clone URLs that can turn `git clone` into arbitrary command execution or local-file access.
git's `ext::` transport forks a shell command, and `file://` reaches the local filesystem; either is RCE/SSRF when the URL is sourced from an attacker- influenced channel (a tampered on-disk credentials file, a compromised API response). This validator is the first of two independent defenses; the second is HardenedCloneArgs, which disables those transports at the git level even if a URL slips through.
Scheme policy:
- https:// always allowed
- http:// allowed ONLY for localhost / 127.0.0.1 (local dev)
- everything else rejected (ext://, git://, ssh://, file://, …)
Host policy: if trustedHosts is non-empty, the URL host must equal one of them or be a subdomain of one. If trustedHosts is empty, any host is accepted (the caller is relying on scheme validation + HardenedCloneArgs alone — appropriate for user-owned ledger repos that may live on github.com, gitlab.com, or a self-hosted forge).
allowLocal permits `file://` URLs and scheme-less local filesystem paths. In production this is false (a ledger/team-context clone is always a remote https repo, never a local path). Tests that clone from a local bare repo wire this to the test-only override (gitserver.TestAllowFileTransport) — the same flag that gates HardenedCloneArgs — so both guards relax together. The `ext::` transport is rejected regardless of allowLocal: it forks a shell and is never legitimate.
func ValidateHTTPSHost ¶ added in v0.10.0
ValidateHTTPSHost rejects a URL whose scheme is not https or whose host is not in the allowlist. It is used before fetching attacker-influenced URLs (e.g. an adapter asset's browser_download_url taken from a GitHub API response).
Per ADR-022 (decision 4) this is a transport guard, NOT the primary integrity control: a host allowlist cannot stop malicious bytes served at a legitimate URL, and over-tight host lists break when a CDN rotates hosts. The primary control for downloaded binaries is checksum verification. Use this only as defense-in-depth alongside a checksum gate, never as a substitute.
Types ¶
type AuditableOp ¶ added in v0.9.0
type AuditableOp string
AuditableOp is a git operation whose --abort behavior is audited.
const ( AuditOpRebase AuditableOp = "rebase" AuditOpMerge AuditableOp = "merge" AuditOpCherryPick AuditableOp = "cherry-pick" )
type GitRunner ¶ added in v0.6.0
type GitRunner interface {
RunGit(ctx context.Context, repoPath string, args ...string) (string, error)
}
GitRunner abstracts git command execution for testability.
func DefaultRunner ¶ added in v0.6.0
func DefaultRunner() GitRunner
DefaultRunner returns the production GitRunner.
type PushOpts ¶ added in v0.6.0
type PushOpts struct {
// AutoResolvePrefixes lists path prefixes where accept-theirs conflict
// resolution is safe (e.g., "data/github/", "data/murmurs/").
// Empty means no auto-resolve — rebase failures abort immediately.
AutoResolvePrefixes []string
// AutoResolveDenyPrefixes lists path prefixes excluded from auto-resolution.
// These carve out exceptions from AutoResolvePrefixes using most-specific-wins
// semantics — e.g., deny "data/proprietary/" while allowing "data/".
AutoResolveDenyPrefixes []string
// PrePush is called before the push loop starts (after lock/LFS checks).
// Use for credential refresh or other caller-specific setup.
// Non-nil errors are logged as warnings but do not prevent the push attempt.
PrePush func(repoPath string) error
// ReconcileLFS is called when a push fails with "LFS objects are missing".
// If set, PushWithRetry calls this instead of failing permanently, then
// retries the push once. This allows the caller to wire lfs.ReconcileUnpushedPointers
// (which strips orphaned pointer stubs and squashes history) without creating
// an import cycle between gitutil and lfs.
// Returns (true, nil) if reconciliation made changes worth retrying.
// Returns (false, err) if reconciliation failed — err is logged and the
// original push error is returned to the caller with the reconciliation
// error appended for diagnostics.
ReconcileLFS func(repoPath string) (changed bool, err error)
// OnUnresolvedConflicts is called when pull --rebase halts AND
// AutoResolvePrefixes-based accept-theirs cannot resolve every conflicted
// path. Receives the list of conflicted paths. If it returns (true, nil),
// the rebase has been resolved (rebase --continue ran inside the callback)
// and PushWithRetry continues the retry loop. If it returns (false, nil) or
// (false, err), PushWithRetry aborts the rebase and returns an error.
//
// Use this to wire higher-tier resolution (e.g. LLM merge) without coupling
// gitutil to those packages.
OnUnresolvedConflicts func(ctx context.Context, repoPath string, paths []string) (resolved bool, err error)
// MaxRetries is the number of push attempts. Zero means use default (3).
// To attempt exactly once with no retries, set to 1.
MaxRetries int
// OpTimeout is the timeout per git operation (default 60s).
OpTimeout time.Duration
// Logger for push diagnostics (defaults to slog.Default).
Logger *slog.Logger
}
PushOpts configures push behavior for PushWithRetry.
type RealRunner ¶ added in v0.6.0
type RealRunner struct{}
RealRunner executes git commands via os/exec (production implementation).
type RepoState ¶ added in v0.9.0
type RepoState struct {
// Shallow reports whether the repo has a `.git/shallow` file
// (resolved via `git rev-parse --git-common-dir`, so linked worktrees
// inherit the main repo's shallow state).
Shallow bool
// Partial reports whether the repo has a promisor remote or
// `extensions.partialClone` set. Object reads may require network.
Partial bool
// Reason is a short human-readable description suitable for UI/log
// output, e.g. "shallow clone" or "partial clone (blob:none)".
// Empty when neither Shallow nor Partial is true.
Reason string
}
RepoState describes whether a repository has the complete commit graph needed for reachability queries (ahead/behind, merge-base, ancestry).
A repo can be "incomplete" in two distinct ways:
Shallow — `git clone --depth N` creates `.git/shallow` listing the commits whose parents have been truncated. Walking past them fails. CI defaults (GitHub Actions actions/checkout@v5) still use this.
Partial — `git clone --filter=blob:none` (or tree:0) creates a promisor remote that fetches objects lazily. The commit graph is complete, but blob/tree reads may fault into the network mid-walk. 2026 CI providers (Buildkite, Depot, Blacksmith, Namespace) lean toward this over shallow because it breaks fewer tools.
Callers running history walks (rev-list --left-right --count, merge-base --is-ancestor, log --since) must treat `Incomplete()==true` results as opaque — render a sentinel ("—" / null), not zero counts. Zero would be a confident lie; the truth is "unknowable from this clone."
func InspectRepo ¶ added in v0.9.0
InspectRepo detects shallow and partial-clone state for repoPath.
Worktree correctness: uses `git rev-parse --git-common-dir` so a linked worktree inherits its main repo's shallow status (`.git/shallow` lives on the common dir, not the worktree's gitdir). Conductor — ox's primary consumer — runs in linked worktrees, so this matters.
Returns a zero RepoState (no error) when repoPath is not a git repo; callers can use IsGitRepo first if they need to distinguish.
func (RepoState) Incomplete ¶ added in v0.9.0
Incomplete reports whether the repo lacks full history for reachability queries. Callers should branch on this before invoking divergence / ancestry git commands.