/** * Git primitives for per-WorkItem worktrees (#814). * * Every check that gates a destructive operation (the dirty check, the * "landed" check) reports what it examined and returns a three-way state. * `unknown` is never treated as safe: a git command that failed, a path that * is not a worktree root, or a worktree with zero tracked files all read as * `unknown`, never `clean` (norm-guard-reports-what-it-examined; the * 2026-08-21 worktree incident, where a check that examined nothing declared * unmerged work safe to delete). * * All git calls go through `execFile` (no shell), so paths are never * word-split or glob-expanded. * * @module services/worktree/worktree-git */ /** Result of one git invocation. */ export interface GitResult { /** Exit code 0. */ ok: boolean; /** Exit code (-1 when git could not be started or timed out). */ code: number; stdout: string; stderr: string; } /** * Run git with arguments in a directory. Never throws. * * @param cwd - Working directory * @param args - Arguments (no shell parsing) * @param timeoutMs - Kill after this long * @returns Exit code and output */ export declare function runGit(cwd: string, args: string[], timeoutMs?: number): Promise; /** * The repository root containing `dir`, or null when `dir` is not in a git * work tree (or git is unavailable). * * @param dir - Any directory * @returns Absolute, symlink-resolved repo root, or null */ export declare function getRepoRoot(dir: string): Promise; /** A resolved base for a new worktree. */ export interface BaseRef { /** What it was resolved from, e.g. `origin/main` or `HEAD`. */ name: string; /** Commit the worktree branch starts at. */ sha: string; } /** * The base a new worktree branches from. * * `preferredBranch` (e.g. `wi/` for a verify/retry/review * WorkItem's own worktree) is tried FIRST when given and it resolves — a * verifier must see the worker's own commits, including ones never pushed * to `origin`, not a snapshot of `origin/main` from before the worker even * started (#829 review). Falls back to the remote default branch * (`origin/HEAD` → e.g. `origin/main`) when known, else the repo's HEAD. * * @param repo - Repo root * @param preferredBranch - Tried before the normal fallback chain, when given * @returns The base, or null when nothing resolves */ export declare function resolveBaseRef(repo: string, preferredBranch?: string): Promise; /** * Whether a local branch exists. * * @param repo - Repo root * @param branch - Short branch name * @returns true when `refs/heads/` resolves */ export declare function branchExists(repo: string, branch: string): Promise; /** * A WorkItem id, made safe as a git ref component and a path segment. * * A verify/retry/review WorkItem's id contains ':' (e.g. `abc:verify:def`), * which `git check-ref-format` rejects — `git worktree add -b wi/abc:verify:def` * fails outright, so that WorkItem would silently get no worktree at all. * Replaces every character git disallows in a ref component (control chars, * space, `~^:?*[\`), collapses a leading/trailing `.` or `/` and a `..` run * (also disallowed), and guards the empty-string and lone-`@` edge cases. * * @param id - Raw WorkItem id * @returns A string safe to use for both the branch name and the worktree path */ export declare function sanitizeWorktreeId(id: string): string; /** * Registered worktree paths of a repo (`git worktree list --porcelain`), * symlink-resolved, excluding the main checkout. * * @param repo - Repo root * @returns Paths, or null when the listing could not be read */ export declare function listWorktrees(repo: string): Promise; /** Result of reading `.worktreeinclude`. */ export interface IncludeList { /** Accepted repo-relative literal paths. */ entries: string[]; /** Lines refused (glob, absolute, `..`), with why. */ rejected: Array<{ line: string; reason: string; }>; /** Entries dropped by the cap. */ truncated: number; } /** * Read the repo-root `.worktreeinclude`: one literal repo-relative path per * line; blank lines and `#` comments ignored; globs, absolute paths and `..` * segments refused; at most `INCLUDE_MAX_ENTRIES` honoured. * * @param repo - Repo root * @returns Accepted entries and what was refused */ export declare function readIncludeFile(repo: string): Promise; /** What {@link applySharedPaths} did. */ export interface SharedPathsResult { /** Repo-relative paths symlinked into the worktree (ours to remove). */ symlinks: string[]; /** Repo-relative files copied from `.worktreeinclude` (ours to remove). */ copies: string[]; /** * SHA-256 of each copy's content right after copying, keyed by its * repo-relative path — the baseline {@link checkDirty} and * {@link removeWorktree} compare against, so an agent's own edit to a * copy (e.g. `.env`) is never silently deleted as "just our copy". */ copyHashes: Record; /** Paths not applied, with why. */ skipped: Array<{ path: string; reason: string; }>; } /** * Symlink heavy directories and copy `.worktreeinclude` files from the repo * root into a new worktree. A path is applied only when it exists in the * repo, git tracks nothing under it (a repo that commits `node_modules` * gets the real checkout, never a symlink), and it does not already exist * in the worktree. Every applied path is then added to the repo's * `info/exclude` as a rooted pattern, so it can never be committed from the * worktree even in a repo whose `.gitignore` does not cover it. * * @param repo - Repo root * @param worktree - New worktree path * @param sharedDirs - Repo-relative directories to symlink * @param includes - Repo-relative files to copy * @returns What was symlinked, copied and skipped */ export declare function applySharedPaths(repo: string, worktree: string, sharedDirs: readonly string[], includes: readonly string[]): Promise; /** * Workspace-level install dirs to share with a new worktree: every * `/node_modules` up to NESTED_INSTALL_DEPTH levels below the repo root * (never inside `node_modules`, `.git`, `.crewly`, `.claude`), but only when * the lock file is identical in repo and worktree — a branch that changed the * lock needs its own install. The root `node_modules` is covered by * DEFAULT_SHARED_DIRS. {@link applySharedPaths} still skips tracked dirs. * * @param repo - Repo root * @param worktree - New worktree (already checked out) * @returns Repo-relative directories, empty when the lock differs or is absent * * @example * ```typescript * await nestedInstallDirs('/repo', '/repo/.crewly/worktrees/wi-1'); // ['backend/node_modules', 'packages/ui/node_modules'] * ``` */ export declare function nestedInstallDirs(repo: string, worktree: string): Promise; /** * Add rooted patterns (`/node_modules`, no trailing slash, so a symlink * matches too) to the repo's shared `info/exclude` under a marker line. * Idempotent. Exclude rules never affect tracked files. * * @param repo - Repo root * @param rels - Repo-relative paths */ export declare function ensureExcluded(repo: string, rels: readonly string[]): Promise; /** Paths this feature created in a worktree (the only ones a check may exclude). */ export interface OwnedPaths { symlinks: readonly string[]; copies: readonly string[]; /** * SHA-256 of each copy's content as applied ({@link SharedPathsResult.copyHashes}). * A copy whose current content no longer matches is an agent's edit, not * "just our copy" — {@link checkDirty} flags it, {@link removeWorktree} * refuses to delete it. Absent (older manifests, or a copy this map has * no entry for) falls back to the pre-#829-review behaviour: existence * alone excludes it. */ copyHashes?: Readonly>; } /** * Repo-relative paths this feature added to the repo's shared * `info/exclude` (the lines under {@link WORKTREE_CONSTANTS.EXCLUDE_MARKER}), * minus the worktree directory itself. * * @param dir - Any directory in the repo or one of its worktrees * @returns Paths without the leading slash (empty when none / unreadable) */ export declare function readOwnExcludePatterns(dir: string): Promise; /** Outcome of {@link checkDirty}. */ export interface DirtyReport { state: 'clean' | 'dirty' | 'unknown'; /** Tracked files + status entries looked at. */ examined: number; /** Our own symlinks/copies verified and excluded. */ excludedOurs: number; /** Paths that make the worktree dirty. */ dirtyPaths: string[]; /** Why the state is unknown (when it is). */ reason?: string; /** One-line human summary: "N path(s) examined, M excluded as ours, K dirty". */ summary: string; } /** * Is a worktree safe to remove as far as uncommitted work goes? * * - The path must be a worktree ROOT (so git never answers for a parent repo). * - It must have at least one tracked file (an empty examination is not "clean"). * - `git status` must succeed; its entries, minus EXACTLY the recorded owned * paths, must be empty. * - Each owned symlink must still be a symlink; one replaced by real content * counts as dirty. * * @param worktree - Worktree path * @param owned - Symlinks/copies recorded at creation * @returns clean / dirty / unknown, with counts */ export declare function checkDirty(worktree: string, owned: OwnedPaths): Promise; /** Outcome of {@link checkLanded}. */ export interface LandedReport { state: 'landed' | 'not_landed' | 'unknown'; /** The worktree HEAD commit checked. */ tip?: string; /** How it was established (or why it could not be). */ detail: string; } /** * Are the worktree's commits safe elsewhere? "Landed" means the HEAD commit * is an ancestor of the branch's upstream or of the base ref, OR it is the * tip of some ref on `origin` (`git ls-remote`). When neither can be * established — e.g. offline and not merged locally — the answer is * `unknown`, which callers must treat as "keep". * * @param worktree - Worktree path * @param baseRefName - The base recorded at creation (e.g. `origin/main`) * @returns landed / not_landed / unknown */ export declare function checkLanded(worktree: string, baseRefName: string): Promise; /** * Commits on the worktree HEAD beyond a base commit. * * @param worktree - Worktree path * @param baseSha - Base commit * @returns Count, or null when it could not be determined */ export declare function commitsBeyond(worktree: string, baseSha: string): Promise; /** * Remove our own symlinks/copies, then `git worktree remove` WITHOUT force. * * Callers must have run {@link checkDirty} first (the primary guard). This * is a second line of defence specifically for a `.worktreeinclude` copy: * unlike an ordinary untracked file, a copy is deliberately added to * `info/exclude` (so an UNTOUCHED one never blocks a normal removal) — which * means git's own dirty check for `worktree remove` does not see an EDITED * one either (git's worktree-remove check, like `git status`, does not * count ignored files). So git refusing on its own is not a safe backstop * here the way it is for a plain untracked file; this function checks every * copy's content BEFORE touching anything, and refuses outright, leaving * the whole worktree untouched, if any changed. * * @param repo - Repo root * @param worktree - Worktree path * @param owned - Paths this feature created * @returns git's result, or a synthetic failure when an edited copy blocked it */ export declare function removeWorktree(repo: string, worktree: string, owned: OwnedPaths): Promise; //# sourceMappingURL=worktree-git.d.ts.map