Documentation
¶
Overview ¶
Package gitutil provides shared git safety primitives used by both the daemon (pull/fetch) and CLI (push/commit) code paths.
Index ¶
- Constants
- 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 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 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 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 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 ¶
This section is empty.
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 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.
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 ResolveRebaseAcceptTheirs ¶ added in v0.5.0
func ResolveRebaseAcceptTheirs(ctx context.Context, repoPath string, safePrefixes []string, denyPrefixes ...[]string) error
ResolveRebaseAcceptTheirs attempts to resolve a rebase conflict by accepting the incoming version of all conflicted files, but ONLY if every conflicted file is under one of the given safe prefixes and NOT under any deny prefix.
This is safe for data directories (like data/) where the content is derived from an external source and the next sync cycle will re-fetch the latest version anyway. Last-write-wins is the correct strategy.
The denyPrefixes parameter is optional — pass nil for no exclusions. Deny prefixes carve out exceptions from the safe set: e.g., safePrefixes ["data/"] with denyPrefixes ["data/proprietary/"] means data/github/prs.json is safe but data/proprietary/keys.json is not.
Handles rename/rename and rename/delete conflicts by using git ls-files --unmerged to detect index stages, then resolving via git rm + git add instead of git checkout --theirs (which fails for rename conflicts).
Returns nil if the rebase was successfully continued after resolution. Returns an error if any conflicted file fails the safety check (the rebase is NOT aborted — caller should abort if needed).
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 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.