Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236/** * GitRepositoryResolver (saga j8dtju.5 — Workspace domain foundation, phase 1). * * A focused, injectable resolver that turns a cwd into canonical repository/worktree * identity + HEAD/branch info + worktree porcelain + ref/ancestry queries, WITHOUT ever * mutating Git state. It is the single consumer of a {@link GitRunner} in this stack; tests * inject a scripted runner for hermetic coverage and the real default runner only touches a * live `git`. * * Canonicalization rules (mirrors Git's own semantics, verified against real checkouts): * - `git rev-parse --git-common-dir --show-toplevel --git-dir` resolves all three identity * paths in ONE invocation. `--git-common-dir` is the *repository* identity (shared by a * main checkout and every linked worktree); `--show-toplevel` is the *worktree* identity. * - Git may emit RELATIVE paths (`.git` in a main checkout). Every output path is resolved * relative to the probed cwd, then realpath-resolved so a SYMLINK spelling of the cwd * collapses to the same canonical identity as the real path. * - HEAD: `git rev-parse HEAD` (full 40-hex); attached branch via `git symbolic-ref * --quiet --short HEAD`, which exits nonzero on a detached HEAD (branch omitted). * * Option-injection safety: every ref/branch argument is positioned AFTER `--end-of-options` * (supported by `rev-parse` and `merge-base`) so a ref that *looks* like an option (`-x`, * `--foo`) is treated as a ref and rejected, never interpreted. Branch-name FORMAT is checked * with `git check-ref-format --branch <name>` (the `--branch` form safely consumes its * argument as a branch name and validates it; check-ref-format does not support * `--end-of-options`, hence the alternate safe form). The runner is argv-only with NO shell, * so no shell metacharacter string is ever interpreted — the host/path/ref appears verbatim as * one argv token. * * Classification lives in `./classification`; this module only resolves and queries. */import { realpath as realpathCb } from 'node:fs';import { resolve } from 'node:path';import { promisify } from 'node:util';import type { RepositoryIdentity, WorktreeIdentity,} from '@/contracts/workspace';import { type GitRunOptions, type GitRunner, fingerprintPath,} from '@/core/git/runner';import { type WorktreePorcelainEntry, parseWorktreePorcelain,} from '@/core/git/porcelain';
const realpath = promisify(realpathCb);
/** * Exactly 40 lowercase hex — the canonical full Git commit sha form, matching the * {@link WorkspaceRecord} `lastHeadCommit`/`base` commit-sha schema. Short/upper-case * output from Git is rejected so a record can never store an ambiguous oid. */const COMMIT_SHA_RE = /^[0-9a-f]{40}$/;
/** HEAD + optional attached branch for a worktree. */export interface ResolvedHead { /** Full 40-hex HEAD commit sha. */ commit: string; /** Attached branch ref (`refs/heads/main`) when HEAD is symbolic; omitted when detached. */ branch?: string;}
/** A fully resolved repository/worktree identity + HEAD probe. */export interface ResolvedWorkspaceIdentity { repository: RepositoryIdentity; worktree: WorktreeIdentity; head: ResolvedHead;}
/** * Outcome of {@link GitRepositoryResolver.resolveIdentity}. A `non-git` kind means the cwd is * not inside any repository (the runner reported `not-a-repo`); a `probe-failed` kind means * git ran but the output could not be parsed or git failed for another reason. */export type ResolveIdentityOutcome = | { kind: 'ok'; identity: ResolvedWorkspaceIdentity } | { kind: 'non-git'; message: string } | { kind: 'probe-failed'; message: string };
/** Injectable Git resolver over a {@link GitRunner}. */export class GitRepositoryResolver { constructor(private readonly runner: GitRunner) {}
/** * Resolve canonical repository + worktree identity + HEAD for `cwd`. One `rev-parse` call * for the three identity paths, one `symbolic-ref` for the attached branch. Returns a * classified outcome so a caller can distinguish a non-Git cwd from a probe failure. */ async resolveIdentity(cwd: string): Promise<ResolveIdentityOutcome> { const rev = await this.run(['rev-parse', '--git-common-dir', '--show-toplevel', '--git-dir'], cwd); if (!rev.ok) return classifyProbeFailure(rev.error);
// rev-parse prints one path per line, in argv order. const lines = rev.result.stdout.split('\n').map((l) => l.replace(/\r$/, '')); const rawCommon = lines[0] ?? ''; const rawTop = lines[1] ?? ''; const rawGitDir = lines[2] ?? ''; if (!rawCommon || !rawTop || !rawGitDir) { return { kind: 'probe-failed', message: `malformed rev-parse output (${lines.length} lines)` }; }
let repo: RepositoryIdentity; let wt: WorktreeIdentity; try { const [commonDir, toplevel, gitDir] = await Promise.all([ canonicalize(rawCommon, cwd), canonicalize(rawTop, cwd), canonicalize(rawGitDir, cwd), ]); repo = { commonDir, commonDirFingerprint: fingerprintPath(commonDir), }; wt = { toplevel, toplevelFingerprint: fingerprintPath(toplevel), gitDir, }; } catch (err) { const message = err instanceof Error ? err.message : String(err); return { kind: 'probe-failed', message: `path canonicalization failed: ${message}` }; }
const head = await this.resolveHead(cwd); if (!head) return { kind: 'probe-failed', message: 'could not resolve HEAD commit' };
return { kind: 'ok', identity: { repository: repo, worktree: wt, head } }; }
/** * Resolve the HEAD commit (full 40-hex) and, when attached, the branch ref. Detached HEAD * → `{ commit }` with no `branch`. Returns `undefined` only when HEAD cannot be resolved * (unborn repo / probe failure). The commit is validated as exactly 40 lowercase hex to * match the {@link WorkspaceRecord} `lastHeadCommit` schema (no short/upper-case accepted). */ async resolveHead(cwd: string): Promise<ResolvedHead | undefined> { const commitRun = await this.run(['rev-parse', 'HEAD'], cwd); if (!commitRun.ok) return undefined; const commit = commitRun.result.stdout.trim(); if (!COMMIT_SHA_RE.test(commit)) return undefined;
// symbolic-ref exits nonzero on a detached HEAD; that is the normal detached signal. const br = await this.run(['symbolic-ref', '--quiet', '--short', 'HEAD'], cwd); const head: ResolvedHead = { commit }; if (br.ok) { const name = br.result.stdout.trim(); if (name) head.branch = `refs/heads/${name}`; } return head; }
/** * List worktrees as `git worktree list --porcelain -z` records. Paths in records are * absolute per porcelain output; this method does NOT resolve them (the catalog may want * the raw spelling). Returns an empty array for a non-Git cwd or a probe failure. */ async listWorktrees(cwd: string): Promise<{ ok: true; entries: WorktreePorcelainEntry[] } | { ok: false; message: string }> { const run = await this.run(['worktree', 'list', '--porcelain', '-z'], cwd); if (!run.ok) return { ok: false, message: run.error.message }; return { ok: true, entries: parseWorktreePorcelain(run.result.stdout) }; }
/** * Validate a branch NAME's format (not whether it exists). Uses `git check-ref-format * --branch <name>`: the `--branch` form consumes its argument as a branch name and validates * its format, rejecting leading-dash / malformed names. Read-only. Pure-over-runner. */ async validateBranchName(cwd: string, name: string): Promise<boolean> { const run = await this.run(['check-ref-format', '--branch', name], cwd); return run.ok && run.result.exitCode === 0; }
/** * Resolve a ref to a full 40-hex commit sha using `git rev-parse --verify --end-of-options * <ref>^{commit}`. `--end-of-options` positions `<ref>` as a ref (not an option), so a * ref-like hostile input (`-x`) is rejected as an unknown object, never interpreted. Returns * the commit sha (exactly 40 lowercase hex, matching the {@link WorkspaceRecord} schema) or * `undefined` if the ref does not resolve or the output is not a valid full sha. */ async resolveRef(cwd: string, ref: string): Promise<string | undefined> { const run = await this.run( ['rev-parse', '--verify', '--end-of-options', `${ref}^{commit}`], cwd, ); if (!run.ok) return undefined; const commit = run.result.stdout.trim(); return COMMIT_SHA_RE.test(commit) ? commit : undefined; }
/** * Prove reachability: is `maybeAncestor` an ancestor of `descendant` (i.e. is * `maybeAncestor` merged into `descendant`)? Uses `git merge-base --is-ancestor --end-of-options * <maybeAncestor> <descendant>`: exit 0 = yes (merged), 1 = no (unmerged), anything else = * error/unresolvable. `--end-of-options` protects both ref positions from option injection. * Returns `true` only when ancestry is proven. */ async isAncestor(cwd: string, maybeAncestor: string, descendant: string): Promise<boolean> { const run = await this.run( ['merge-base', '--is-ancestor', '--end-of-options', maybeAncestor, descendant], cwd, ); if (!run.ok) return false; return run.result.exitCode === 0; }
/** Run a git argv vector against cwd through the injected runner. */ private async run(argv: string[], cwd: string): Promise<Awaited<ReturnType<GitRunner['run']>>> { const opts: GitRunOptions = { argv, cwd }; return this.runner.run(opts); }}
/** * Map a runner failure to a {@link ResolveIdentityOutcome} probe/non-git classification. A * `not-a-repo` runner error is the canonical non-Git-cwd signal; anything else (timeout, * executable-missing, generic nonzero) is a probe failure the caller may retry or surface. */function classifyProbeFailure(error: import('@/core/git/runner').GitRunError): ResolveIdentityOutcome { if (error.kind === 'not-a-repo') { return { kind: 'non-git', message: 'cwd is not inside a git repository' }; } return { kind: 'probe-failed', message: error.message };}
/** * Resolve a possibly-relative Git output path against the probed cwd, then realpath it so a * symlink-spelling cwd collapses to the canonical on-disk path. Throws if realpath fails * (the path does not exist on disk) — surfaced by the caller as a probe failure. */async function canonicalize(rawPath: string, cwd: string): Promise<string> { const absolute = resolve(cwd, rawPath); return await realpath(absolute);}