2426 lines
97 KiB
TypeScript
2426 lines
97 KiB
TypeScript
import * as fs from "node:fs";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
import { $which, hasFsCode, isEisdir, isEnoent, isEnotdir, Snowflake } from "@oh-my-pi/pi-utils";
|
|
import type { Subprocess } from "bun";
|
|
import {
|
|
parseDiffHunks as parseCommitDiffHunks,
|
|
parseFileDiffs,
|
|
parseFileHunks,
|
|
parseNumstat,
|
|
} from "../commit/git/diff";
|
|
import type { FileDiff, FileHunks, NumstatEntry } from "../commit/types";
|
|
import { ToolAbortError, ToolError, throwIfAborted } from "../tools/tool-errors";
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// Types
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
export interface GitCommandResult {
|
|
exitCode: number;
|
|
stdout: string;
|
|
stderr: string;
|
|
}
|
|
|
|
export interface GitRepository {
|
|
commonDir: string;
|
|
gitDir: string;
|
|
gitEntryPath: string;
|
|
headPath: string;
|
|
repoRoot: string;
|
|
isReftable?: boolean;
|
|
}
|
|
|
|
export interface GitStatusSummary {
|
|
staged: number;
|
|
unstaged: number;
|
|
untracked: number;
|
|
}
|
|
|
|
export type HunkSelection = {
|
|
path: string;
|
|
hunks: { type: "all" } | { type: "indices"; indices: number[] } | { type: "lines"; start: number; end: number };
|
|
};
|
|
|
|
export interface StageHunksOptions {
|
|
readonly diffCached?: boolean;
|
|
readonly rawDiff?: string;
|
|
readonly signal?: AbortSignal;
|
|
}
|
|
export interface HunkSelectionValidationError {
|
|
readonly path: string;
|
|
readonly message: string;
|
|
}
|
|
|
|
export interface DiffOptions {
|
|
readonly allowFailure?: boolean;
|
|
readonly base?: string;
|
|
readonly binary?: boolean;
|
|
readonly cached?: boolean;
|
|
readonly env?: Record<string, string | undefined>;
|
|
readonly files?: readonly string[];
|
|
readonly head?: string;
|
|
readonly nameOnly?: boolean;
|
|
readonly noIndex?: { left: string; right: string };
|
|
readonly numstat?: boolean;
|
|
readonly signal?: AbortSignal;
|
|
readonly stat?: boolean;
|
|
}
|
|
|
|
export interface StatusOptions {
|
|
readonly pathspecs?: readonly string[];
|
|
readonly porcelainV1?: boolean;
|
|
readonly signal?: AbortSignal;
|
|
readonly untrackedFiles?: "all" | "no" | "normal";
|
|
readonly z?: boolean;
|
|
}
|
|
|
|
export interface CommitAuthor {
|
|
readonly date?: string;
|
|
readonly email: string;
|
|
readonly name: string;
|
|
}
|
|
|
|
export interface CommitDetails {
|
|
readonly author: CommitAuthor;
|
|
readonly message: string;
|
|
}
|
|
|
|
export interface CommitOptions {
|
|
readonly allowEmpty?: boolean;
|
|
readonly author?: CommitAuthor;
|
|
readonly files?: readonly string[];
|
|
readonly signal?: AbortSignal;
|
|
}
|
|
|
|
export interface PushOptions {
|
|
readonly forceWithLease?: boolean;
|
|
readonly refspec?: string;
|
|
readonly remote?: string;
|
|
readonly signal?: AbortSignal;
|
|
}
|
|
|
|
export interface PatchOptions {
|
|
readonly cached?: boolean;
|
|
readonly check?: boolean;
|
|
readonly env?: Record<string, string | undefined>;
|
|
readonly reverse?: boolean;
|
|
readonly threeWay?: boolean;
|
|
readonly signal?: AbortSignal;
|
|
}
|
|
|
|
export interface RestoreOptions {
|
|
readonly files?: readonly string[];
|
|
readonly signal?: AbortSignal;
|
|
readonly source?: string;
|
|
readonly staged?: boolean;
|
|
readonly worktree?: boolean;
|
|
}
|
|
|
|
export interface FetchOptions {
|
|
readonly signal?: AbortSignal;
|
|
/** Deadline for the network transfer. Defaults to {@link GIT_NETWORK_TIMEOUT_MS}. */
|
|
readonly timeoutMs?: number;
|
|
}
|
|
|
|
export interface CloneOptions {
|
|
readonly ref?: string;
|
|
readonly sha?: string;
|
|
readonly signal?: AbortSignal;
|
|
/** Deadline for the network transfer. Defaults to {@link GIT_NETWORK_TIMEOUT_MS}. */
|
|
readonly timeoutMs?: number;
|
|
}
|
|
|
|
interface GitHeadBase extends GitRepository {
|
|
headContent: string;
|
|
}
|
|
|
|
export interface GitRefHead extends GitHeadBase {
|
|
branchName: string | null;
|
|
commit: string | null;
|
|
kind: "ref";
|
|
ref: string;
|
|
}
|
|
|
|
export interface GitDetachedHead extends GitHeadBase {
|
|
commit: string | null;
|
|
kind: "detached";
|
|
}
|
|
|
|
export type GitHeadState = GitRefHead | GitDetachedHead;
|
|
|
|
export interface GitWorktreeEntry {
|
|
branch?: string;
|
|
detached: boolean;
|
|
head?: string;
|
|
path: string;
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// Error
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
export class GitCommandError extends Error {
|
|
readonly args: readonly string[];
|
|
readonly result: GitCommandResult;
|
|
|
|
constructor(args: readonly string[], result: GitCommandResult) {
|
|
super(formatCommandFailure(args, result));
|
|
this.name = "GitCommandError";
|
|
this.args = [...args];
|
|
this.result = result;
|
|
}
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// Internal: Core execution
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
const NO_OPTIONAL_LOCKS = "--no-optional-locks";
|
|
const HEAD_REF_PREFIX = "ref:";
|
|
const LOCAL_BRANCH_PREFIX = "refs/heads/";
|
|
const DEFAULT_BRANCH_REFS = ["refs/remotes/origin/HEAD", "refs/remotes/upstream/HEAD"] as const;
|
|
const SHORT_LIVED_GIT_CONFIG: readonly (readonly [key: string, value: string])[] = [
|
|
["core.fsmonitor", "false"],
|
|
["core.untrackedCache", "false"],
|
|
];
|
|
const AMBIENT_GIT_ENV = {
|
|
GIT_DIR: undefined,
|
|
GIT_COMMON_DIR: undefined,
|
|
GIT_WORK_TREE: undefined,
|
|
GIT_INDEX_FILE: undefined,
|
|
GIT_OBJECT_DIRECTORY: undefined,
|
|
GIT_ALTERNATE_OBJECT_DIRECTORIES: undefined,
|
|
} satisfies Record<string, undefined>;
|
|
|
|
const GIT_NON_INTERACTIVE_ENV = {
|
|
GIT_ASKPASS: "true",
|
|
GIT_EDITOR: "true",
|
|
GIT_TERMINAL_PROMPT: "0",
|
|
SSH_ASKPASS: "/usr/bin/false",
|
|
} satisfies Record<string, string>;
|
|
const GH_NON_INTERACTIVE_ENV = {
|
|
...GIT_NON_INTERACTIVE_ENV,
|
|
GH_PROMPT_DISABLED: "1",
|
|
} satisfies Record<string, string>;
|
|
|
|
/** Default deadline for git and gh subprocesses spawned by the coding agent. */
|
|
export const GIT_COMMAND_TIMEOUT_MS = 5 * 60 * 1000;
|
|
/**
|
|
* Default deadline for git subprocesses that perform network transfers
|
|
* (`clone`/`fetch`). Large-repo transfers legitimately outlive
|
|
* {@link GIT_COMMAND_TIMEOUT_MS}, so they get a wider deadline; local plumbing
|
|
* commands keep the short one.
|
|
*/
|
|
export const GIT_NETWORK_TIMEOUT_MS = 30 * 60 * 1000;
|
|
/** Maximum captured stdout or stderr bytes retained from git and gh subprocesses. */
|
|
export const GIT_COMMAND_OUTPUT_LIMIT_BYTES = 8 * 1024 * 1024;
|
|
|
|
const GIT_COMMAND_TIMEOUT_EXIT_CODE = 124;
|
|
// Exit code returned when the `git` binary cannot be launched at all (spawn
|
|
// ENOENT). Mirrors the POSIX "command not found" code so read-only callers that
|
|
// degrade on any non-zero exit treat a missing git the same as a failed
|
|
// invocation instead of letting the raw spawn ENOENT escape as an unhandled
|
|
// rejection.
|
|
const GIT_SPAWN_ENOENT_EXIT_CODE = 127;
|
|
const GIT_OUTPUT_TRUNCATED_MARKER = "\n[git subprocess output truncated after 8 MiB]\n";
|
|
const GIT_COMMAND_TERMINATE_GRACE_MS = 5_000;
|
|
|
|
type CommandName = "git" | "gh";
|
|
|
|
function resolveTimeoutMs(timeoutMs: number | undefined, fallback: number = GIT_COMMAND_TIMEOUT_MS): number {
|
|
if (timeoutMs === undefined) return fallback;
|
|
if (!Number.isFinite(timeoutMs) || timeoutMs < 0) return fallback;
|
|
return Math.trunc(timeoutMs);
|
|
}
|
|
|
|
function resolveOutputLimit(maxOutputBytes: number | undefined): number {
|
|
if (maxOutputBytes === undefined) return GIT_COMMAND_OUTPUT_LIMIT_BYTES;
|
|
if (!Number.isFinite(maxOutputBytes) || maxOutputBytes < 0) return GIT_COMMAND_OUTPUT_LIMIT_BYTES;
|
|
return Math.trunc(maxOutputBytes);
|
|
}
|
|
|
|
function formatCommandLabel(command: CommandName, args: readonly string[]): string {
|
|
return `${command} ${args.join(" ")}`.trim();
|
|
}
|
|
|
|
async function waitForChildExit(child: Subprocess, timeoutMs: number): Promise<boolean> {
|
|
if (timeoutMs <= 0) return false;
|
|
const timeout = Promise.withResolvers<false>();
|
|
const timer = setTimeout(() => timeout.resolve(false), timeoutMs);
|
|
timer.unref?.();
|
|
try {
|
|
return await Promise.race([
|
|
child.exited.then(
|
|
() => true,
|
|
() => true,
|
|
),
|
|
timeout.promise,
|
|
]);
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
async function terminateTimedOutChild(child: Subprocess): Promise<void> {
|
|
child.kill("SIGTERM");
|
|
if (await waitForChildExit(child, GIT_COMMAND_TERMINATE_GRACE_MS)) return;
|
|
child.kill("SIGKILL");
|
|
await waitForChildExit(child, GIT_COMMAND_TERMINATE_GRACE_MS);
|
|
}
|
|
|
|
async function waitForExitWithTimeout(
|
|
child: Subprocess,
|
|
commandLabel: string,
|
|
timeoutMs: number,
|
|
): Promise<{ exitCode: number | null; timedOut: false } | { timedOut: true; stderr: string }> {
|
|
if (timeoutMs === 0) {
|
|
await terminateTimedOutChild(child);
|
|
return { timedOut: true, stderr: `${commandLabel} timed out after 0ms` };
|
|
}
|
|
const timeout = Promise.withResolvers<"timeout">();
|
|
const timer = setTimeout(() => timeout.resolve("timeout"), timeoutMs);
|
|
timer.unref?.();
|
|
try {
|
|
const result = await Promise.race([
|
|
child.exited.then(exitCode => ({ kind: "exit" as const, exitCode })),
|
|
timeout.promise.then(() => ({ kind: "timeout" as const })),
|
|
]);
|
|
if (result.kind === "exit") {
|
|
return { timedOut: false, exitCode: result.exitCode };
|
|
}
|
|
await terminateTimedOutChild(child);
|
|
return { timedOut: true, stderr: `${commandLabel} timed out after ${timeoutMs}ms` };
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
async function readCappedText(stream: ReadableStream<Uint8Array>, maxBytes: number): Promise<string> {
|
|
const reader = stream.getReader();
|
|
const decoder = new TextDecoder();
|
|
const chunks: string[] = [];
|
|
let remaining = maxBytes;
|
|
let truncated = false;
|
|
try {
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
if (!truncated && value.length <= remaining) {
|
|
chunks.push(decoder.decode(value, { stream: true }));
|
|
remaining -= value.length;
|
|
continue;
|
|
}
|
|
if (!truncated && remaining > 0) {
|
|
chunks.push(decoder.decode(value.subarray(0, remaining), { stream: true }));
|
|
remaining = 0;
|
|
}
|
|
truncated = true;
|
|
}
|
|
chunks.push(decoder.decode());
|
|
if (truncated) chunks.push(GIT_OUTPUT_TRUNCATED_MARKER);
|
|
return chunks.join("");
|
|
} finally {
|
|
reader.releaseLock();
|
|
}
|
|
}
|
|
|
|
async function cancelOutput(stream: ReadableStream<Uint8Array>): Promise<void> {
|
|
try {
|
|
await stream.cancel();
|
|
} catch {
|
|
// Best-effort cleanup after a timeout; the subprocess has already been signaled.
|
|
}
|
|
}
|
|
|
|
async function collectSubprocessResult(
|
|
command: CommandName,
|
|
args: readonly string[],
|
|
child: Subprocess,
|
|
options: Pick<CommandOptions, "maxOutputBytes" | "timeoutMs"> = {},
|
|
): Promise<GitCommandResult> {
|
|
const stdoutStream = child.stdout;
|
|
const stderrStream = child.stderr;
|
|
if (!(stdoutStream instanceof ReadableStream) || !(stderrStream instanceof ReadableStream)) {
|
|
throw new Error(`Failed to capture ${command} command output.`);
|
|
}
|
|
const maxOutputBytes = resolveOutputLimit(options.maxOutputBytes);
|
|
const stdoutPromise = readCappedText(stdoutStream, maxOutputBytes);
|
|
const stderrPromise = readCappedText(stderrStream, maxOutputBytes);
|
|
const exit = await waitForExitWithTimeout(
|
|
child,
|
|
formatCommandLabel(command, args),
|
|
resolveTimeoutMs(options.timeoutMs),
|
|
);
|
|
if (exit.timedOut) {
|
|
void stdoutPromise.catch(() => undefined);
|
|
void stderrPromise.catch(() => undefined);
|
|
await Promise.all([cancelOutput(stdoutStream), cancelOutput(stderrStream)]);
|
|
return { exitCode: GIT_COMMAND_TIMEOUT_EXIT_CODE, stdout: "", stderr: exit.stderr };
|
|
}
|
|
const [stdout, stderr] = await Promise.all([stdoutPromise, stderrPromise]);
|
|
return { exitCode: exit.exitCode ?? 0, stdout, stderr };
|
|
}
|
|
|
|
interface CommandOptions {
|
|
readonly env?: Record<string, string | undefined>;
|
|
readonly maxOutputBytes?: number;
|
|
readonly readOnly?: boolean;
|
|
readonly signal?: AbortSignal;
|
|
readonly stdin?: string | Uint8Array | ArrayBuffer | SharedArrayBuffer;
|
|
readonly timeoutMs?: number;
|
|
}
|
|
|
|
function normalizeStdin(input: CommandOptions["stdin"]): "ignore" | Uint8Array {
|
|
if (input === undefined) return "ignore";
|
|
if (typeof input === "string") return new TextEncoder().encode(input);
|
|
if (input instanceof Uint8Array) return input;
|
|
return new Uint8Array(input);
|
|
}
|
|
|
|
function buildGitEnv(overrides?: Record<string, string | undefined>): Record<string, string | undefined> {
|
|
return {
|
|
...process.env,
|
|
GIT_OPTIONAL_LOCKS: "0",
|
|
...AMBIENT_GIT_ENV,
|
|
...overrides,
|
|
...GIT_NON_INTERACTIVE_ENV,
|
|
};
|
|
}
|
|
|
|
function ensureAvailable(): void {
|
|
if (!$which("git")) {
|
|
throw new Error("git is not installed.");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Launch a `git` plumbing command synchronously and decode stdout. Returns the
|
|
* exit code plus trimmed stdout; a missing `git` binary (spawn ENOENT) is
|
|
* reported as {@link GIT_SPAWN_ENOENT_EXIT_CODE} so sync read-only callers
|
|
* degrade to `null` instead of throwing an uncaught error during rendering.
|
|
*/
|
|
function gitSpawnSyncText(cwd: string, args: readonly string[]): { exitCode: number; stdout: string } {
|
|
const commandArgs = withShortLivedGitConfig(withNoOptionalLocks(args));
|
|
try {
|
|
const result = Bun.spawnSync(["git", ...commandArgs], {
|
|
cwd,
|
|
env: buildGitEnv(),
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
windowsHide: true,
|
|
});
|
|
return { exitCode: result.exitCode ?? 0, stdout: new TextDecoder().decode(result.stdout).trim() };
|
|
} catch (err) {
|
|
if (isEnoent(err)) return { exitCode: GIT_SPAWN_ENOENT_EXIT_CODE, stdout: "" };
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
function formatCommandFailure(
|
|
args: readonly string[],
|
|
result: Pick<GitCommandResult, "exitCode" | "stdout" | "stderr">,
|
|
): string {
|
|
const stderr = result.stderr.trim();
|
|
if (stderr) return stderr;
|
|
const stdout = result.stdout.trim();
|
|
if (stdout) return stdout;
|
|
return `git ${args.join(" ")} failed with exit code ${result.exitCode}`;
|
|
}
|
|
|
|
async function git(cwd: string, args: readonly string[], options: CommandOptions = {}): Promise<GitCommandResult> {
|
|
const commandArgs = withShortLivedGitConfig(options.readOnly ? withNoOptionalLocks(args) : [...args]);
|
|
let child: Subprocess;
|
|
try {
|
|
child = Bun.spawn(["git", ...commandArgs], {
|
|
cwd,
|
|
env: buildGitEnv(options.env),
|
|
signal: options.signal,
|
|
stdin: normalizeStdin(options.stdin),
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
windowsHide: true,
|
|
});
|
|
} catch (err) {
|
|
if (isEnoent(err)) {
|
|
// A deleted/nonexistent cwd also surfaces as a spawn ENOENT; only blame
|
|
// the binary when the working directory actually exists.
|
|
const stderr = fs.existsSync(cwd) ? "git is not installed." : `working directory does not exist: ${cwd}`;
|
|
return { exitCode: GIT_SPAWN_ENOENT_EXIT_CODE, stdout: "", stderr };
|
|
}
|
|
throw err;
|
|
}
|
|
|
|
return await collectSubprocessResult("git", commandArgs, child, options);
|
|
}
|
|
|
|
function withNoOptionalLocks(args: readonly string[]): string[] {
|
|
if (args.includes(NO_OPTIONAL_LOCKS)) return [...args];
|
|
return [NO_OPTIONAL_LOCKS, ...args];
|
|
}
|
|
|
|
function withShortLivedGitConfig(args: readonly string[]): string[] {
|
|
const prefix: string[] = [];
|
|
for (const [key, value] of SHORT_LIVED_GIT_CONFIG) {
|
|
if (hasGitConfig(args, key, value)) continue;
|
|
prefix.push("-c", `${key}=${value}`);
|
|
}
|
|
return [...prefix, ...args];
|
|
}
|
|
|
|
function hasGitConfig(args: readonly string[], key: string, value: string): boolean {
|
|
const expected = `${key}=${value}`;
|
|
for (let index = 0; index < args.length - 1; index += 1) {
|
|
if (args[index] === "-c" && args[index + 1] === expected) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
async function runChecked(
|
|
cwd: string,
|
|
args: readonly string[],
|
|
options: CommandOptions = {},
|
|
): Promise<GitCommandResult> {
|
|
ensureAvailable();
|
|
const result = await git(cwd, args, options);
|
|
if (result.exitCode !== 0) {
|
|
throw new GitCommandError(args, result);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
async function runEffect(cwd: string, args: readonly string[], options: CommandOptions = {}): Promise<void> {
|
|
await runChecked(cwd, args, options);
|
|
}
|
|
|
|
async function runText(cwd: string, args: readonly string[], options: CommandOptions = {}): Promise<string> {
|
|
return (await runChecked(cwd, args, options)).stdout;
|
|
}
|
|
|
|
async function tryText(
|
|
cwd: string,
|
|
args: readonly string[],
|
|
options: CommandOptions = {},
|
|
): Promise<string | undefined> {
|
|
ensureAvailable();
|
|
const result = await git(cwd, args, options);
|
|
if (result.exitCode !== 0) return undefined;
|
|
return result.stdout;
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// Internal: per-repo write serialization
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
// Git uses lock files (`.git/config.lock`, commit-graph chain locks,
|
|
// `packed-refs.lock`, …) for many of its mutating operations. Each is created
|
|
// O_EXCL with no waiter, so concurrent in-process git invocations against the
|
|
// same repository fail immediately rather than block. Worktrees share the
|
|
// primary repo's `.git` directory, so racing across worktrees has the same
|
|
// failure mode. We give callers a single per-repo serialization point keyed by
|
|
// the primary repo root: any block that mutates repo state should hold this
|
|
// lock so unrelated callers cannot collide on git's internal locks.
|
|
const repoWriteChain = new Map<string, Promise<unknown>>();
|
|
|
|
/**
|
|
* Serialize an async block that mutates a git repository against other
|
|
* in-process callers operating on the same repository. The lock is keyed by
|
|
* the primary repo root so worktrees of the same repo share a single queue.
|
|
* Failures in one block do not poison the queue for the next caller.
|
|
*
|
|
* Not reentrant: do NOT nest acquisitions for the same repo. Helpers in this
|
|
* module never auto-acquire — callers wrap the critical section themselves.
|
|
*/
|
|
export async function withRepoLock<T>(cwd: string, fn: () => Promise<T>, signal?: AbortSignal): Promise<T> {
|
|
const key = (await repo.primaryRoot(cwd, signal)) ?? cwd;
|
|
const prior = repoWriteChain.get(key);
|
|
const run = (async () => {
|
|
if (prior) {
|
|
try {
|
|
await prior;
|
|
} catch {
|
|
// A prior caller failing must not block us from running.
|
|
}
|
|
}
|
|
throwIfAborted(signal);
|
|
return fn();
|
|
})();
|
|
repoWriteChain.set(key, run);
|
|
try {
|
|
return await run;
|
|
} finally {
|
|
if (repoWriteChain.get(key) === run) repoWriteChain.delete(key);
|
|
}
|
|
}
|
|
|
|
function splitLines(text: string): string[] {
|
|
return text
|
|
.split("\n")
|
|
.map(line => line.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function trimScalar(text: string | undefined): string | undefined {
|
|
const trimmed = text?.trim();
|
|
return trimmed || undefined;
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// Internal: Argument builders
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
function buildDiffArgs(options: DiffOptions): string[] {
|
|
const args = ["diff"];
|
|
if (options.binary) args.push("--binary");
|
|
if (options.cached) args.push("--cached");
|
|
if (options.nameOnly) args.push("--name-only");
|
|
if (options.stat) args.push("--stat");
|
|
if (options.numstat) args.push("--numstat");
|
|
if (options.noIndex) {
|
|
args.push("--no-index", options.noIndex.left, options.noIndex.right);
|
|
return args;
|
|
}
|
|
if (options.base) {
|
|
args.push(options.base);
|
|
if (options.head) args.push(options.head);
|
|
}
|
|
if (options.files?.length) args.push("--", ...options.files);
|
|
return args;
|
|
}
|
|
|
|
function buildApplyArgs(patchPath: string, options: PatchOptions): string[] {
|
|
const args = ["apply"];
|
|
if (options.check) args.push("--check");
|
|
if (options.cached) args.push("--cached");
|
|
if (options.reverse) args.push("--reverse");
|
|
if (options.threeWay) args.push("--3way");
|
|
args.push("--binary", patchPath);
|
|
return args;
|
|
}
|
|
|
|
async function writeTempPatch(content: string): Promise<string> {
|
|
const tempPath = path.join(os.tmpdir(), `omp-git-patch-${Snowflake.next()}.patch`);
|
|
await Bun.write(tempPath, content);
|
|
return tempPath;
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// Internal: Repository resolution
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
type EntryType = "directory" | "file";
|
|
|
|
function shouldRetry(err: unknown, n: number) {
|
|
if (isEnoent(err) || isEisdir(err) || isEnotdir(err) || hasFsCode(err, "ENFILE") || hasFsCode(err, "EMFILE"))
|
|
return false;
|
|
if (hasFsCode(err, "EINTR")) return n < EINTR_MAX_RETRIES;
|
|
if (n > EINTR_MAX_RETRIES) throw err;
|
|
throw err;
|
|
}
|
|
|
|
/**
|
|
* Bounded retry for synchronous I/O against `EINTR`. POSIX permits short syscalls
|
|
* to be interrupted by signals; when that happens libc traditionally retries.
|
|
* Node's sync wrappers surface the raw `EINTR` so we replicate the retry locally.
|
|
* Any other error (and persistent EINTR after `EINTR_MAX_RETRIES`) is rethrown
|
|
* for the caller's normal "optional metadata" classifier to handle.
|
|
*/
|
|
const EINTR_MAX_RETRIES = 3;
|
|
function retryOnEintrSync<T>(op: () => T): T | null {
|
|
for (let attempt = 0; attempt <= EINTR_MAX_RETRIES; attempt += 1) {
|
|
try {
|
|
return op();
|
|
} catch (err) {
|
|
if (shouldRetry(err, attempt)) continue;
|
|
return null;
|
|
}
|
|
}
|
|
throw new Error("retryOnEintrSync: exhausted without resolution");
|
|
}
|
|
async function retryOnEintr<T>(op: () => Promise<T>): Promise<T | null> {
|
|
for (let attempt = 0; attempt <= EINTR_MAX_RETRIES; attempt += 1) {
|
|
try {
|
|
return await op();
|
|
} catch (err) {
|
|
if (shouldRetry(err, attempt)) continue;
|
|
return null;
|
|
}
|
|
}
|
|
throw new Error("retryOnEintr: exhausted without resolution");
|
|
}
|
|
|
|
function getEntryTypeSync(gitEntryPath: string): EntryType | null {
|
|
return retryOnEintrSync(() => {
|
|
const stat = fs.statSync(gitEntryPath);
|
|
if (stat.isDirectory()) return "directory";
|
|
if (stat.isFile()) return "file";
|
|
return null;
|
|
});
|
|
}
|
|
|
|
async function getEntryType(gitEntryPath: string): Promise<EntryType | null> {
|
|
return retryOnEintr(async () => {
|
|
const stat = await fs.promises.stat(gitEntryPath);
|
|
if (stat.isDirectory()) return "directory";
|
|
if (stat.isFile()) return "file";
|
|
return null;
|
|
});
|
|
}
|
|
|
|
function readOptionalTextSync(filePath: string): string | null {
|
|
return retryOnEintrSync(() => fs.readFileSync(filePath, "utf8"));
|
|
}
|
|
|
|
async function readOptionalText(filePath: string): Promise<string | null> {
|
|
return retryOnEintr(async () => await Bun.file(filePath).text());
|
|
}
|
|
|
|
async function readOptionalBytes(filePath: string): Promise<Uint8Array | null> {
|
|
return retryOnEintr(async () => await Bun.file(filePath).bytes());
|
|
}
|
|
|
|
function parseGitDirPointer(content: string): string | null {
|
|
const match = /^gitdir:\s*(.+)\s*$/iu.exec(content.trim());
|
|
return match?.[1] ?? null;
|
|
}
|
|
|
|
function resolveGitDirSync(gitEntryPath: string, entryType: EntryType): string | null {
|
|
if (entryType === "directory") return gitEntryPath;
|
|
const content = readOptionalTextSync(gitEntryPath);
|
|
if (content === null) return null;
|
|
const parsed = parseGitDirPointer(content);
|
|
if (!parsed) return null;
|
|
const gitDir = path.resolve(path.dirname(gitEntryPath), parsed);
|
|
return getEntryTypeSync(gitDir) === "directory" ? gitDir : null;
|
|
}
|
|
|
|
async function resolveGitDir(gitEntryPath: string, entryType: EntryType): Promise<string | null> {
|
|
if (entryType === "directory") return gitEntryPath;
|
|
const content = await readOptionalText(gitEntryPath);
|
|
if (content === null) return null;
|
|
const parsed = parseGitDirPointer(content);
|
|
if (!parsed) return null;
|
|
const gitDir = path.resolve(path.dirname(gitEntryPath), parsed);
|
|
return (await getEntryType(gitDir)) === "directory" ? gitDir : null;
|
|
}
|
|
|
|
function resolveCommonDirSync(gitDir: string): string {
|
|
const content = readOptionalTextSync(path.join(gitDir, "commondir"));
|
|
const relative = content?.trim();
|
|
if (!relative) return gitDir;
|
|
return path.resolve(gitDir, relative);
|
|
}
|
|
|
|
async function resolveCommonDir(gitDir: string): Promise<string> {
|
|
const content = await readOptionalText(path.join(gitDir, "commondir"));
|
|
const relative = content?.trim();
|
|
if (!relative) return gitDir;
|
|
return path.resolve(gitDir, relative);
|
|
}
|
|
function isLinkedWorktree(repository: GitRepository): boolean {
|
|
return (
|
|
repository.gitDir !== repository.commonDir &&
|
|
getEntryTypeSync(path.join(repository.gitDir, "commondir")) === "file"
|
|
);
|
|
}
|
|
|
|
async function isLinkedWorktreeAsync(repository: GitRepository): Promise<boolean> {
|
|
return (
|
|
repository.gitDir !== repository.commonDir &&
|
|
(await getEntryType(path.join(repository.gitDir, "commondir"))) === "file"
|
|
);
|
|
}
|
|
|
|
function primaryRootFromRepositorySync(repository: GitRepository): string {
|
|
if (path.basename(repository.commonDir) === ".git") return path.dirname(repository.commonDir);
|
|
if (isLinkedWorktree(repository)) return repository.commonDir;
|
|
return repository.repoRoot;
|
|
}
|
|
|
|
async function primaryRootFromRepository(repository: GitRepository): Promise<string> {
|
|
if (path.basename(repository.commonDir) === ".git") return path.dirname(repository.commonDir);
|
|
if (await isLinkedWorktreeAsync(repository)) return repository.commonDir;
|
|
return repository.repoRoot;
|
|
}
|
|
|
|
function resolveRepoFromEntrySync(repoRoot: string, gitEntryPath: string, entryType: EntryType): GitRepository | null {
|
|
const gitDir = resolveGitDirSync(gitEntryPath, entryType);
|
|
if (!gitDir) return null;
|
|
return {
|
|
commonDir: resolveCommonDirSync(gitDir),
|
|
gitDir,
|
|
gitEntryPath,
|
|
headPath: path.join(gitDir, "HEAD"),
|
|
repoRoot,
|
|
};
|
|
}
|
|
|
|
async function resolveRepoFromEntry(
|
|
repoRoot: string,
|
|
gitEntryPath: string,
|
|
entryType: EntryType,
|
|
): Promise<GitRepository | null> {
|
|
const gitDir = await resolveGitDir(gitEntryPath, entryType);
|
|
if (!gitDir) return null;
|
|
return {
|
|
commonDir: await resolveCommonDir(gitDir),
|
|
gitDir,
|
|
gitEntryPath,
|
|
headPath: path.join(gitDir, "HEAD"),
|
|
repoRoot,
|
|
};
|
|
}
|
|
|
|
function resolveRepositorySync(startDir: string): GitRepository | null {
|
|
let current = path.resolve(startDir);
|
|
while (true) {
|
|
const gitEntryPath = path.join(current, ".git");
|
|
const entryType = getEntryTypeSync(gitEntryPath);
|
|
if (entryType) {
|
|
const repository = resolveRepoFromEntrySync(current, gitEntryPath, entryType);
|
|
if (repository) return repository;
|
|
}
|
|
const parent = path.dirname(current);
|
|
if (parent === current) return null;
|
|
current = parent;
|
|
}
|
|
}
|
|
|
|
async function resolveRepository(startDir: string): Promise<GitRepository | null> {
|
|
let current = path.resolve(startDir);
|
|
while (true) {
|
|
const gitEntryPath = path.join(current, ".git");
|
|
const entryType = await getEntryType(gitEntryPath);
|
|
if (entryType) {
|
|
const repository = await resolveRepoFromEntry(current, gitEntryPath, entryType);
|
|
if (repository) return repository;
|
|
}
|
|
const parent = path.dirname(current);
|
|
if (parent === current) return null;
|
|
current = parent;
|
|
}
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// Internal: Ref resolution
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
function getRefLookupDirs(repository: GitRepository): string[] {
|
|
if (repository.gitDir === repository.commonDir) return [repository.gitDir];
|
|
return [repository.gitDir, repository.commonDir];
|
|
}
|
|
|
|
function normalizeRefValue(content: string | null): string | null {
|
|
const trimmed = content?.trim() ?? "";
|
|
return trimmed || null;
|
|
}
|
|
|
|
function parsePackedRefs(content: string | null, targetRef: string): string | null {
|
|
if (!content) return null;
|
|
for (const line of content.split("\n")) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith("^")) continue;
|
|
const [sha, refName] = trimmed.split(" ", 2);
|
|
if (refName === targetRef && sha) return sha;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function stripGitConfigComments(line: string): string {
|
|
let clean = "";
|
|
let inQuotes = false;
|
|
for (let i = 0; i < line.length; i++) {
|
|
const char = line[i];
|
|
if (char === '"') {
|
|
inQuotes = !inQuotes;
|
|
clean += char;
|
|
} else if (!inQuotes && (char === ";" || char === "#")) {
|
|
break;
|
|
} else {
|
|
clean += char;
|
|
}
|
|
}
|
|
return clean.trim();
|
|
}
|
|
|
|
function parseGitConfigHasReftable(content: string): boolean {
|
|
let inExtensions = false;
|
|
for (const line of content.split("\n")) {
|
|
const trimmed = stripGitConfigComments(line);
|
|
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
|
|
const section = trimmed.slice(1, -1).trim().toLowerCase();
|
|
inExtensions = section === "extensions";
|
|
} else if (inExtensions) {
|
|
const eqIndex = trimmed.indexOf("=");
|
|
if (eqIndex !== -1) {
|
|
const key = trimmed.slice(0, eqIndex).trim().toLowerCase();
|
|
let value = trimmed.slice(eqIndex + 1).trim();
|
|
if (key === "refstorage") {
|
|
if (value.startsWith('"') && value.endsWith('"')) {
|
|
value = value.slice(1, -1).trim();
|
|
}
|
|
const lowerValue = value.toLowerCase();
|
|
if (lowerValue === "reftable" || lowerValue.startsWith("reftable:")) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function isReftableRepoSync(repository: GitRepository): boolean {
|
|
if (repository.isReftable !== undefined) return repository.isReftable;
|
|
const configPath = path.join(repository.commonDir, "config");
|
|
const content = readOptionalTextSync(configPath);
|
|
repository.isReftable = content ? parseGitConfigHasReftable(content) : false;
|
|
return repository.isReftable;
|
|
}
|
|
|
|
async function isReftableRepo(repository: GitRepository): Promise<boolean> {
|
|
if (repository.isReftable !== undefined) return repository.isReftable;
|
|
const configPath = path.join(repository.commonDir, "config");
|
|
const content = await readOptionalText(configPath);
|
|
repository.isReftable = content ? parseGitConfigHasReftable(content) : false;
|
|
return repository.isReftable;
|
|
}
|
|
|
|
async function resolveHeadStateReftable(repository: GitRepository, signal?: AbortSignal): Promise<GitHeadState | null> {
|
|
throwIfAborted(signal);
|
|
const symResult = await git(repository.repoRoot, ["symbolic-ref", "HEAD"], { readOnly: true, signal }).catch(err => {
|
|
if (signal?.aborted || (err instanceof Error && (err.name === "AbortError" || err.name === "ToolAbortError"))) {
|
|
throw err;
|
|
}
|
|
return null;
|
|
});
|
|
throwIfAborted(signal);
|
|
const revResult = await git(repository.repoRoot, ["rev-parse", "--verify", "HEAD"], {
|
|
readOnly: true,
|
|
signal,
|
|
}).catch(err => {
|
|
if (signal?.aborted || (err instanceof Error && (err.name === "AbortError" || err.name === "ToolAbortError"))) {
|
|
throw err;
|
|
}
|
|
return null;
|
|
});
|
|
const commit = revResult && revResult.exitCode === 0 ? revResult.stdout.trim() || null : null;
|
|
|
|
if (symResult && symResult.exitCode === 0) {
|
|
const ref = symResult.stdout.trim();
|
|
const branchName = ref.startsWith(LOCAL_BRANCH_PREFIX) ? ref.slice(LOCAL_BRANCH_PREFIX.length) : null;
|
|
return {
|
|
...repository,
|
|
kind: "ref",
|
|
ref,
|
|
branchName,
|
|
commit,
|
|
headContent: `${HEAD_REF_PREFIX} ${ref}`,
|
|
};
|
|
}
|
|
|
|
return {
|
|
...repository,
|
|
kind: "detached",
|
|
commit,
|
|
headContent: commit || "",
|
|
};
|
|
}
|
|
|
|
function resolveHeadStateReftableSync(repository: GitRepository): GitHeadState | null {
|
|
const symResult = gitSpawnSyncText(repository.repoRoot, ["symbolic-ref", "HEAD"]);
|
|
const revResult = gitSpawnSyncText(repository.repoRoot, ["rev-parse", "--verify", "HEAD"]);
|
|
const commit = revResult.exitCode === 0 ? revResult.stdout || null : null;
|
|
|
|
if (symResult.exitCode === 0) {
|
|
const ref = symResult.stdout;
|
|
const branchName = ref.startsWith(LOCAL_BRANCH_PREFIX) ? ref.slice(LOCAL_BRANCH_PREFIX.length) : null;
|
|
return {
|
|
...repository,
|
|
kind: "ref",
|
|
ref,
|
|
branchName,
|
|
commit,
|
|
headContent: `${HEAD_REF_PREFIX} ${ref}`,
|
|
};
|
|
}
|
|
|
|
return {
|
|
...repository,
|
|
kind: "detached",
|
|
commit,
|
|
headContent: commit || "",
|
|
};
|
|
}
|
|
|
|
function readRefSync(repository: GitRepository, targetRef: string): string | null {
|
|
if (isReftableRepoSync(repository)) {
|
|
const symResult = gitSpawnSyncText(repository.repoRoot, ["symbolic-ref", targetRef]);
|
|
if (symResult.exitCode === 0) {
|
|
return `${HEAD_REF_PREFIX} ${symResult.stdout}`;
|
|
}
|
|
const revResult = gitSpawnSyncText(repository.repoRoot, ["rev-parse", "--verify", targetRef]);
|
|
if (revResult.exitCode === 0) {
|
|
return revResult.stdout || null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
for (const dir of getRefLookupDirs(repository)) {
|
|
const value = normalizeRefValue(readOptionalTextSync(path.join(dir, targetRef)));
|
|
if (value) return value;
|
|
}
|
|
for (const dir of getRefLookupDirs(repository)) {
|
|
const value = parsePackedRefs(readOptionalTextSync(path.join(dir, "packed-refs")), targetRef);
|
|
if (value) return value;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function readRef(repository: GitRepository, targetRef: string, signal?: AbortSignal): Promise<string | null> {
|
|
if (await isReftableRepo(repository)) {
|
|
throwIfAborted(signal);
|
|
const symResult = await git(repository.repoRoot, ["symbolic-ref", targetRef], { readOnly: true, signal }).catch(
|
|
err => {
|
|
if (
|
|
signal?.aborted ||
|
|
(err instanceof Error && (err.name === "AbortError" || err.name === "ToolAbortError"))
|
|
) {
|
|
throw err;
|
|
}
|
|
return null;
|
|
},
|
|
);
|
|
if (symResult && symResult.exitCode === 0) {
|
|
return `${HEAD_REF_PREFIX} ${symResult.stdout.trim()}`;
|
|
}
|
|
throwIfAborted(signal);
|
|
const revResult = await git(repository.repoRoot, ["rev-parse", "--verify", targetRef], {
|
|
readOnly: true,
|
|
signal,
|
|
}).catch(err => {
|
|
if (
|
|
signal?.aborted ||
|
|
(err instanceof Error && (err.name === "AbortError" || err.name === "ToolAbortError"))
|
|
) {
|
|
throw err;
|
|
}
|
|
return null;
|
|
});
|
|
if (revResult && revResult.exitCode === 0) {
|
|
return revResult.stdout.trim() || null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
for (const dir of getRefLookupDirs(repository)) {
|
|
const value = normalizeRefValue(await readOptionalText(path.join(dir, targetRef)));
|
|
if (value) return value;
|
|
}
|
|
for (const dir of getRefLookupDirs(repository)) {
|
|
const value = parsePackedRefs(await readOptionalText(path.join(dir, "packed-refs")), targetRef);
|
|
if (value) return value;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// Internal: Head state parsing
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
function parseHeadStateSync(repository: GitRepository, headContent: string): GitHeadState {
|
|
const trimmed = headContent.trim();
|
|
if (!trimmed?.startsWith(HEAD_REF_PREFIX)) {
|
|
return { ...repository, commit: trimmed || null, headContent, kind: "detached" };
|
|
}
|
|
const refValue = trimmed.slice(HEAD_REF_PREFIX.length).trim();
|
|
const branchName = refValue.startsWith(LOCAL_BRANCH_PREFIX) ? refValue.slice(LOCAL_BRANCH_PREFIX.length) : null;
|
|
return {
|
|
...repository,
|
|
branchName,
|
|
commit: readRefSync(repository, refValue),
|
|
headContent,
|
|
kind: "ref",
|
|
ref: refValue,
|
|
};
|
|
}
|
|
|
|
async function parseHeadState(repository: GitRepository, headContent: string): Promise<GitHeadState> {
|
|
const trimmed = headContent.trim();
|
|
if (!trimmed?.startsWith(HEAD_REF_PREFIX)) {
|
|
return { ...repository, commit: trimmed || null, headContent, kind: "detached" };
|
|
}
|
|
const refValue = trimmed.slice(HEAD_REF_PREFIX.length).trim();
|
|
const branchName = refValue.startsWith(LOCAL_BRANCH_PREFIX) ? refValue.slice(LOCAL_BRANCH_PREFIX.length) : null;
|
|
return {
|
|
...repository,
|
|
branchName,
|
|
commit: await readRef(repository, refValue),
|
|
headContent,
|
|
kind: "ref",
|
|
ref: refValue,
|
|
};
|
|
}
|
|
|
|
function parseDefaultBranchRef(refPath: string, target: string | null): string | null {
|
|
if (!target?.startsWith(HEAD_REF_PREFIX)) return null;
|
|
const resolvedRef = target.slice(HEAD_REF_PREFIX.length).trim();
|
|
const remotePrefix = refPath.slice(0, -"HEAD".length);
|
|
if (!resolvedRef.startsWith(remotePrefix)) return null;
|
|
return resolvedRef.slice(remotePrefix.length) || null;
|
|
}
|
|
|
|
function stripRemotePrefix(refValue: string): string | null {
|
|
const slash = refValue.indexOf("/");
|
|
if (slash < 0) return refValue || null;
|
|
return refValue.slice(slash + 1) || null;
|
|
}
|
|
|
|
function parseWorktreeList(text: string): GitWorktreeEntry[] {
|
|
const trimmed = text.trim();
|
|
if (!trimmed) return [];
|
|
return trimmed
|
|
.split(/\n\s*\n/)
|
|
.map(block => block.trim())
|
|
.filter(Boolean)
|
|
.map(block => {
|
|
const entry: GitWorktreeEntry = { detached: false, path: "" };
|
|
for (const line of block.split("\n")) {
|
|
if (line.startsWith("worktree ")) entry.path = line.slice("worktree ".length);
|
|
else if (line.startsWith("HEAD ")) entry.head = line.slice("HEAD ".length);
|
|
else if (line.startsWith("branch ")) entry.branch = line.slice("branch ".length);
|
|
else if (line === "detached") entry.detached = true;
|
|
}
|
|
return entry;
|
|
});
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// Internal: Hunk selection
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
function extractFileHeader(diffText: string): string {
|
|
const lines = diffText.split("\n");
|
|
const headerLines: string[] = [];
|
|
for (const line of lines) {
|
|
if (line.startsWith("@@")) break;
|
|
headerLines.push(line);
|
|
}
|
|
return headerLines.join("\n");
|
|
}
|
|
|
|
function selectHunks(file: FileHunks, selector: HunkSelection["hunks"]): FileHunks["hunks"] {
|
|
if (selector.type === "indices") {
|
|
const wanted = new Set(selector.indices.map(v => Math.max(1, Math.floor(v))));
|
|
return file.hunks.filter(hunk => wanted.has(hunk.index + 1));
|
|
}
|
|
if (selector.type === "lines") {
|
|
const start = Math.floor(selector.start);
|
|
const end = Math.floor(selector.end);
|
|
return file.hunks.filter(hunk => hunk.newStart <= end && hunk.newStart + hunk.newLines - 1 >= start);
|
|
}
|
|
return file.hunks;
|
|
}
|
|
|
|
export function createHunkSelectionValidator(
|
|
rawDiff: string,
|
|
): (selections: readonly HunkSelection[]) => HunkSelectionValidationError[] {
|
|
const fileDiffMap = new Map(parseFileDiffs(rawDiff).map(entry => [entry.filename, entry]));
|
|
return selections => validateHunkSelectionsFromMap(fileDiffMap, selections);
|
|
}
|
|
|
|
function validateHunkSelectionsFromMap(
|
|
fileDiffMap: ReadonlyMap<string, FileDiff>,
|
|
selections: readonly HunkSelection[],
|
|
): HunkSelectionValidationError[] {
|
|
const errors: HunkSelectionValidationError[] = [];
|
|
|
|
for (const selection of selections) {
|
|
const fileDiff = fileDiffMap.get(selection.path);
|
|
if (!fileDiff) continue;
|
|
if (selection.hunks.type === "all") continue;
|
|
if (fileDiff.isBinary) {
|
|
errors.push({ path: selection.path, message: `Cannot select hunks for binary file ${selection.path}` });
|
|
continue;
|
|
}
|
|
const selected = selectHunks(parseFileHunks(fileDiff), selection.hunks);
|
|
if (selected.length === 0) {
|
|
errors.push({ path: selection.path, message: `No hunks selected for ${selection.path}` });
|
|
}
|
|
}
|
|
|
|
return errors;
|
|
}
|
|
|
|
export function validateHunkSelections(
|
|
rawDiff: string,
|
|
selections: readonly HunkSelection[],
|
|
): HunkSelectionValidationError[] {
|
|
return createHunkSelectionValidator(rawDiff)(selections);
|
|
}
|
|
|
|
function parseStatusPorcelain(text: string): GitStatusSummary {
|
|
let staged = 0;
|
|
let unstaged = 0;
|
|
let untracked = 0;
|
|
for (const line of text.split("\n")) {
|
|
if (!line) continue;
|
|
const x = line[0];
|
|
const y = line[1];
|
|
if (x === "?" && y === "?") {
|
|
untracked += 1;
|
|
continue;
|
|
}
|
|
if (x && x !== " " && x !== "?") staged += 1;
|
|
if (y && y !== " ") unstaged += 1;
|
|
}
|
|
return { staged, unstaged, untracked };
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// API: diff
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
/** Run `git diff` with the given options. Returns raw diff text. */
|
|
export const diff = Object.assign(
|
|
async function diff(cwd: string, options: DiffOptions = {}): Promise<string> {
|
|
const args = buildDiffArgs(options);
|
|
if (options.allowFailure) {
|
|
return (await git(cwd, args, { env: options.env, readOnly: true, signal: options.signal })).stdout;
|
|
}
|
|
return runText(cwd, args, { env: options.env, readOnly: true, signal: options.signal });
|
|
},
|
|
{
|
|
/** List changed file paths. */
|
|
async changedFiles(
|
|
cwd: string,
|
|
options: Pick<DiffOptions, "cached" | "files" | "signal"> = {},
|
|
): Promise<string[]> {
|
|
return splitLines(await diff(cwd, { ...options, nameOnly: true }));
|
|
},
|
|
/** Parsed per-file add/remove counts. */
|
|
async numstat(cwd: string, options: Pick<DiffOptions, "cached" | "signal"> = {}): Promise<NumstatEntry[]> {
|
|
return parseNumstat(await diff(cwd, { ...options, numstat: true }));
|
|
},
|
|
/** Parsed diff hunks for the given files. */
|
|
async hunks(
|
|
cwd: string,
|
|
files: readonly string[],
|
|
options: { cached?: boolean; signal?: AbortSignal } = {},
|
|
): Promise<FileHunks[]> {
|
|
return parseCommitDiffHunks(
|
|
await diff(cwd, { cached: options.cached ?? true, files, signal: options.signal }),
|
|
);
|
|
},
|
|
/** Check whether a diff exists (uses `--quiet` for efficiency). */
|
|
async has(cwd: string, options: Pick<DiffOptions, "cached" | "files" | "signal"> = {}): Promise<boolean> {
|
|
const args = ["diff"];
|
|
if (options.cached) args.push("--cached");
|
|
args.push("--quiet");
|
|
if (options.files?.length) args.push("--", ...options.files);
|
|
const result = await git(cwd, args, { readOnly: true, signal: options.signal });
|
|
if (result.exitCode === 0) return false;
|
|
if (result.exitCode === 1) return true;
|
|
throw new GitCommandError(args, result);
|
|
},
|
|
/** Diff between two tree-ish objects (`git diff-tree`). */
|
|
async tree(
|
|
cwd: string,
|
|
base: string,
|
|
headRef: string,
|
|
options: { binary?: boolean; signal?: AbortSignal; allowFailure?: boolean } = {},
|
|
): Promise<string> {
|
|
const args = ["diff-tree", "-r", "-p"];
|
|
if (options.binary) args.push("--binary");
|
|
args.push(base, headRef);
|
|
if (options.allowFailure) {
|
|
return (await git(cwd, args, { readOnly: true, signal: options.signal })).stdout;
|
|
}
|
|
return runText(cwd, args, { readOnly: true, signal: options.signal });
|
|
},
|
|
/** Parse raw diff text into per-file diffs. */
|
|
parseFiles(text: string): FileDiff[] {
|
|
return parseFileDiffs(text);
|
|
},
|
|
/** Parse raw diff text into per-file hunks. */
|
|
parseHunks(text: string): FileHunks[] {
|
|
return parseCommitDiffHunks(text);
|
|
},
|
|
},
|
|
);
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// API: status
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
/** Run `git status --porcelain`. Returns raw status text. */
|
|
export const status = Object.assign(
|
|
async function status(cwd: string, options: StatusOptions = {}): Promise<string> {
|
|
const args = ["status"];
|
|
args.push(options.porcelainV1 ? "--porcelain=v1" : "--porcelain");
|
|
if (options.z) args.push("-z");
|
|
if (options.untrackedFiles) args.push(`--untracked-files=${options.untrackedFiles}`);
|
|
if (options.pathspecs?.length) args.push("--", ...options.pathspecs);
|
|
return runText(cwd, args, { readOnly: true, signal: options.signal });
|
|
},
|
|
{
|
|
/** Parsed status counts (staged, unstaged, untracked). */
|
|
async summary(cwd: string, signal?: AbortSignal): Promise<GitStatusSummary | null> {
|
|
const result = await git(cwd, ["status", "--porcelain"], { readOnly: true, signal });
|
|
if (result.exitCode !== 0) return null;
|
|
return parseStatusPorcelain(result.stdout);
|
|
},
|
|
/** Parse porcelain status text into counts. */
|
|
parse: parseStatusPorcelain,
|
|
},
|
|
);
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// API: stage
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
export const stage = {
|
|
/** Stage files. Empty array stages all (`git add -A`). */
|
|
async files(cwd: string, files: readonly string[] = [], signal?: AbortSignal): Promise<void> {
|
|
const args = files.length === 0 ? ["add", "-A"] : ["add", "--", ...files];
|
|
await runEffect(cwd, args, { signal });
|
|
},
|
|
|
|
/** Selectively stage hunks from the provided diff or the current working tree diff. */
|
|
async hunks(cwd: string, selections: HunkSelection[], options: StageHunksOptions = {}): Promise<void> {
|
|
if (selections.length === 0) return;
|
|
const rawDiff = options.rawDiff ?? (await diff(cwd, { cached: options.diffCached, signal: options.signal }));
|
|
const fileDiffs = parseFileDiffs(rawDiff);
|
|
const fileDiffMap = new Map(fileDiffs.map(entry => [entry.filename, entry]));
|
|
const patchParts: string[] = [];
|
|
|
|
for (const selection of selections) {
|
|
const fileDiff = fileDiffMap.get(selection.path);
|
|
if (!fileDiff) throw new Error(`No diff found for ${selection.path}`);
|
|
if (fileDiff.isBinary) {
|
|
if (selection.hunks.type !== "all")
|
|
throw new Error(`Cannot select hunks for binary file ${selection.path}`);
|
|
patchParts.push(fileDiff.content);
|
|
continue;
|
|
}
|
|
if (selection.hunks.type === "all") {
|
|
patchParts.push(fileDiff.content);
|
|
continue;
|
|
}
|
|
const fileHunks = parseFileHunks(fileDiff);
|
|
const selected = selectHunks(fileHunks, selection.hunks);
|
|
if (selected.length === 0) throw new Error(`No hunks selected for ${selection.path}`);
|
|
const header = extractFileHeader(fileDiff.content);
|
|
patchParts.push([header, ...selected.map(h => h.content)].join("\n"));
|
|
}
|
|
|
|
const patchText = patch.join(patchParts);
|
|
if (!patchText.trim()) return;
|
|
await patch.applyText(cwd, patchText, { cached: true, signal: options.signal });
|
|
},
|
|
|
|
/** Unstage files. Empty array unstages all (`git reset`). */
|
|
async reset(cwd: string, files: readonly string[] = [], signal?: AbortSignal): Promise<void> {
|
|
const args = files.length === 0 ? ["reset"] : ["reset", "--", ...files];
|
|
await runEffect(cwd, args, { signal });
|
|
},
|
|
};
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// API: commit, push, checkout
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
/** Create a commit with the given message (passed via stdin). */
|
|
export async function commit(cwd: string, message: string, options: CommitOptions = {}): Promise<GitCommandResult> {
|
|
const args = ["commit", "-F", "-"];
|
|
if (options.author) {
|
|
args.push(`--author=${options.author.name} <${options.author.email}>`);
|
|
if (options.author.date) args.push(`--date=${options.author.date}`);
|
|
}
|
|
if (options.allowEmpty) args.push("--allow-empty");
|
|
if (options.files?.length) args.push("--", ...options.files);
|
|
return runChecked(cwd, args, { signal: options.signal, stdin: message });
|
|
}
|
|
|
|
/** Push the current branch (branch-scoped: never follows tags). */
|
|
export async function push(cwd: string, options: PushOptions = {}): Promise<void> {
|
|
// `--no-follow-tags` overrides a user's `push.followTags = true`, which
|
|
// would otherwise ride every reachable annotated tag along with the
|
|
// branch — rejected refs ("permission denied") on remotes the user
|
|
// cannot tag (e.g. PR-head forks), failing the call after the branch
|
|
// itself already updated. Tool pushes push exactly the named refspec.
|
|
const args = ["push", "--no-follow-tags"];
|
|
if (options.forceWithLease) args.push("--force-with-lease");
|
|
if (options.remote) args.push(options.remote);
|
|
if (options.refspec) args.push(options.refspec);
|
|
await runEffect(cwd, args, { signal: options.signal });
|
|
}
|
|
|
|
/** Checkout a ref. */
|
|
export async function checkout(cwd: string, ref: string, signal?: AbortSignal): Promise<void> {
|
|
await runEffect(cwd, ["checkout", ref], { signal });
|
|
}
|
|
|
|
/** Fetch a specific refspec from a remote. Network transfer: defaults to the {@link GIT_NETWORK_TIMEOUT_MS} deadline. */
|
|
export async function fetch(
|
|
cwd: string,
|
|
remote: string,
|
|
source: string,
|
|
target: string,
|
|
options: FetchOptions = {},
|
|
): Promise<void> {
|
|
await runEffect(cwd, ["fetch", remote, `+${source}:${target}`], {
|
|
signal: options.signal,
|
|
timeoutMs: resolveTimeoutMs(options.timeoutMs, GIT_NETWORK_TIMEOUT_MS),
|
|
});
|
|
}
|
|
|
|
/** Read a tree-ish into the index. */
|
|
export async function readTree(
|
|
cwd: string,
|
|
treeish: string,
|
|
options: Pick<CommandOptions, "env" | "signal"> = {},
|
|
): Promise<void> {
|
|
await runEffect(cwd, ["read-tree", treeish], options);
|
|
}
|
|
|
|
/** Write the current index as a tree and return its object id. */
|
|
export async function writeTree(cwd: string, options: Pick<CommandOptions, "env" | "signal"> = {}): Promise<string> {
|
|
return (await runText(cwd, ["write-tree"], options)).trim();
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// API: worktree isolation
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
/** Outcome of {@link detachGitDir}. */
|
|
export type DetachGitDirResult =
|
|
/** `worktreeRoot` had no `.git`; nothing to detach. */
|
|
| "no-git"
|
|
/** `.git` already resolves to an independent object DB — left untouched. */
|
|
| "independent"
|
|
/** Detached into a standalone repo borrowing `sourceCommonDir`'s objects. */
|
|
| "detached";
|
|
|
|
/**
|
|
* Sever a copied/mounted working tree from the git metadata it shares with a
|
|
* source checkout, turning it into a standalone repository that borrows the
|
|
* source object database through `objects/info/alternates`.
|
|
*
|
|
* Isolation backends (reflink/apfs/btrfs/rcopy…) materialise `merged` by
|
|
* copying `worktreeRoot` byte-for-byte. When `worktreeRoot` is a **linked git
|
|
* worktree** its `.git` is a pointer file (`gitdir: …/worktrees/<name>`), so
|
|
* the copy still resolves HEAD/index/refs through the source repo — a task's
|
|
* `git checkout`/`commit` inside the isolation then mutates the *parent*
|
|
* checkout. The rcopy `git worktree add` path leaks the other way: task
|
|
* branches land in the shared ref namespace and stack on each other.
|
|
*
|
|
* After detaching, the working tree keeps its files verbatim while:
|
|
* - HEAD, refs, and the index are frozen to the snapshot at call time;
|
|
* - all commits/branches the task creates stay private to the isolation;
|
|
* - objects resolve against `sourceCommonDir` via alternates, so history reads
|
|
* and later `git fetch <merged>` object transfer keep working;
|
|
* - the source checkout's HEAD, branch, index, and working tree are untouched.
|
|
*
|
|
* A full-copy `.git` (non-worktree source) already owns its object DB and is
|
|
* returned as `"independent"` without modification. `worktreeRoot` without a
|
|
* `.git` yields `"no-git"`.
|
|
*/
|
|
export async function detachGitDir(worktreeRoot: string, sourceCommonDir: string): Promise<DetachGitDirResult> {
|
|
ensureAvailable();
|
|
const gitEntry = path.join(worktreeRoot, ".git");
|
|
let entryStat: fs.Stats;
|
|
try {
|
|
entryStat = await fs.promises.lstat(gitEntry);
|
|
} catch (err) {
|
|
if (isEnoent(err)) return "no-git";
|
|
throw err;
|
|
}
|
|
// Canonicalize both sides before comparing: `rev-parse` resolves symlinks
|
|
// (macOS `/tmp` → `/private/tmp`) while callers derive `sourceCommonDir`
|
|
// lexically from the session cwd. A lexical mismatch here would silently
|
|
// classify a shared linked-worktree copy as "independent" and skip the
|
|
// detach entirely — leaving the parent-mutation leak in place.
|
|
const parentCommon = await fs.promises.realpath(sourceCommonDir).catch(() => path.resolve(sourceCommonDir));
|
|
const isoCommonRaw = (
|
|
await runText(worktreeRoot, ["rev-parse", "--path-format=absolute", "--git-common-dir"], {
|
|
readOnly: true,
|
|
})
|
|
).trim();
|
|
const isoCommon = await fs.promises.realpath(isoCommonRaw).catch(() => path.resolve(isoCommonRaw));
|
|
// A full-copy `.git` already resolves to its own object DB — leave it alone.
|
|
if (isoCommon !== parentCommon) return "independent";
|
|
|
|
// Snapshot the state the standalone repo must preserve. HEAD may be a branch
|
|
// ref (normal checkout), detached, or unborn (a fresh/orphan branch with no
|
|
// commits — a linked worktree still shares the parent ref namespace, so it
|
|
// must be severed too). Refs are frozen so `baseSha..branch` ranges and
|
|
// history reads keep resolving after the source moves on.
|
|
const headSha = (await tryText(worktreeRoot, ["rev-parse", "HEAD"], { readOnly: true }))?.trim() ?? "";
|
|
const headRef = (await tryText(worktreeRoot, ["symbolic-ref", "-q", "HEAD"], { readOnly: true }))?.trim() ?? "";
|
|
const refDump = headSha
|
|
? (
|
|
await runText(worktreeRoot, ["for-each-ref", "--format=%(objectname) %(refname)"], {
|
|
readOnly: true,
|
|
})
|
|
).trim()
|
|
: "";
|
|
const objectFormat =
|
|
(await tryText(worktreeRoot, ["rev-parse", "--show-object-format"], { readOnly: true }))?.trim() || "sha1";
|
|
const userName = await config.get(worktreeRoot, "user.name");
|
|
const userEmail = await config.get(worktreeRoot, "user.email");
|
|
|
|
// Preserve the index verbatim rather than round-tripping through
|
|
// write-tree/read-tree: the raw index carries skip-worktree bits (sparse
|
|
// checkout), assume-unchanged flags, and exact stage entries. A rebuilt
|
|
// index drops skip-worktree, so files intentionally absent from a sparse
|
|
// working tree would read as deletions and delta capture would apply those
|
|
// deletions back to the parent. Sparse config + patterns are carried too so
|
|
// later git operations in the isolation keep honouring the sparse view.
|
|
const indexPath = (
|
|
await runText(worktreeRoot, ["rev-parse", "--path-format=absolute", "--git-path", "index"], {
|
|
readOnly: true,
|
|
})
|
|
).trim();
|
|
const indexBytes = await readOptionalBytes(indexPath);
|
|
const sparseCheckout = await config.get(worktreeRoot, "core.sparseCheckout");
|
|
const sparseCone = await config.get(worktreeRoot, "core.sparseCheckoutCone");
|
|
const sparsePatternPath = (
|
|
await runText(worktreeRoot, ["rev-parse", "--path-format=absolute", "--git-path", "info/sparse-checkout"], {
|
|
readOnly: true,
|
|
})
|
|
).trim();
|
|
const sparsePatterns = await readOptionalText(sparsePatternPath);
|
|
// Status parity with the source: an explicit core.filemode (e.g. false on
|
|
// mounts ignoring the executable bit) must carry over, or the re-inited
|
|
// repo's platform default makes clean files read as mode-changed and delta
|
|
// capture would apply bogus chmod diffs back to the parent.
|
|
const fileMode = await config.get(worktreeRoot, "core.fileMode");
|
|
// A split index references sharedindex.* files beside the source index;
|
|
// restoring the raw index without them makes every git read fail. Carry the
|
|
// shared files (and the config) alongside the verbatim index bytes.
|
|
const splitIndex = await config.get(worktreeRoot, "core.splitIndex");
|
|
const sharedIndexFiles: Array<{ name: string; bytes: Uint8Array }> = [];
|
|
if (indexBytes) {
|
|
const indexDir = path.dirname(indexPath);
|
|
let entries: string[] = [];
|
|
try {
|
|
entries = await fs.promises.readdir(indexDir);
|
|
} catch {}
|
|
for (const name of entries) {
|
|
if (!name.startsWith("sharedindex.")) continue;
|
|
const bytes = await readOptionalBytes(path.join(indexDir, name));
|
|
if (bytes) sharedIndexFiles.push({ name, bytes });
|
|
}
|
|
}
|
|
// A shallow source deliberately lacks parents beyond its `shallow` boundary
|
|
// file; without it, history traversal over the borrowed objects treats the
|
|
// boundary commit's missing parent as corruption.
|
|
const shallowBoundary = await readOptionalText(path.join(parentCommon, "shallow"));
|
|
|
|
// A pointer `.git` file whose worktree-admin dir back-references this exact
|
|
// tree is the rcopy `git worktree add` registration. Remove that admin entry
|
|
// so the source repo's worktree list stops tracking the isolation. A pointer
|
|
// referencing the *source's* admin (a copied linked-worktree `.git`) is not
|
|
// ours to delete — only the local pointer file is discarded. Compare via
|
|
// realpath: git canonicalizes the back-reference (e.g. macOS `/var` →
|
|
// `/private/var`), so a lexical path comparison would miss the match and
|
|
// leave a stale registration in the source repo's worktree list.
|
|
let ownWorktreeAdmin: string | undefined;
|
|
if (entryStat.isFile()) {
|
|
const pointer = parseGitDirPointer((await readOptionalText(gitEntry)) ?? "");
|
|
if (pointer) {
|
|
const adminDir = path.resolve(path.dirname(gitEntry), pointer);
|
|
const backRef = (await readOptionalText(path.join(adminDir, "gitdir")))?.trim();
|
|
if (backRef) {
|
|
const [realBackRef, realGitEntry] = await Promise.all([
|
|
fs.promises.realpath(backRef).catch(() => path.resolve(backRef)),
|
|
fs.promises.realpath(gitEntry).catch(() => path.resolve(gitEntry)),
|
|
]);
|
|
if (realBackRef === realGitEntry) ownWorktreeAdmin = adminDir;
|
|
}
|
|
}
|
|
}
|
|
|
|
await fs.promises.rm(gitEntry, { recursive: true, force: true });
|
|
if (ownWorktreeAdmin) await fs.promises.rm(ownWorktreeAdmin, { recursive: true, force: true });
|
|
|
|
// Preserve the checked-out branch name so an unborn HEAD (fresh/orphan
|
|
// branch with no commits) keeps its symbolic ref after `init` rather than
|
|
// snapping to the init default; born HEADs get the ref rewritten below anyway.
|
|
const initArgs = ["init", "--object-format", objectFormat, "-q"];
|
|
const initialBranch = headRef.startsWith(LOCAL_BRANCH_PREFIX) ? headRef.slice(LOCAL_BRANCH_PREFIX.length) : "";
|
|
if (initialBranch) initArgs.push("-b", initialBranch);
|
|
await runEffect(worktreeRoot, initArgs);
|
|
const objectsInfo = path.join(gitEntry, "objects", "info");
|
|
await fs.promises.mkdir(objectsInfo, { recursive: true });
|
|
const alternates = [path.join(parentCommon, "objects")];
|
|
const chained = await readOptionalText(path.join(parentCommon, "objects", "info", "alternates"));
|
|
if (chained) {
|
|
for (const line of chained.split("\n")) {
|
|
const entry = line.trim();
|
|
if (!entry) continue;
|
|
alternates.push(path.isAbsolute(entry) ? entry : path.resolve(parentCommon, "objects", entry));
|
|
}
|
|
}
|
|
await Bun.write(path.join(objectsInfo, "alternates"), `${alternates.join("\n")}\n`);
|
|
|
|
// Freeze refs when HEAD is born. Point HEAD at the raw SHA first so
|
|
// `update-ref` writes land even for the branch HEAD currently names, then
|
|
// restore the symbolic HEAD. An unborn HEAD has no refs to freeze; `init -b`
|
|
// above already set the symbolic HEAD to the unborn branch.
|
|
if (headSha) {
|
|
await Bun.write(path.join(gitEntry, "HEAD"), `${headSha}\n`);
|
|
if (refDump) {
|
|
const commands = refDump
|
|
.split("\n")
|
|
.filter(Boolean)
|
|
.map(line => {
|
|
const sep = line.indexOf(" ");
|
|
return `create ${line.slice(sep + 1)} ${line.slice(0, sep)}`;
|
|
})
|
|
.join("\n");
|
|
await runEffect(worktreeRoot, ["update-ref", "--stdin"], { stdin: `${commands}\n` });
|
|
}
|
|
if (headRef) await Bun.write(path.join(gitEntry, "HEAD"), `ref: ${headRef}\n`);
|
|
} else if (headRef && !initialBranch) {
|
|
// Unborn detached HEAD (no branch, no commit) — restore the raw ref target.
|
|
await Bun.write(path.join(gitEntry, "HEAD"), `ref: ${headRef}\n`);
|
|
}
|
|
|
|
// Carry the source identity so isolated commits have an author.
|
|
if (userName) await config.set(worktreeRoot, "user.name", userName);
|
|
if (userEmail) await config.set(worktreeRoot, "user.email", userEmail);
|
|
if (fileMode !== undefined) await config.set(worktreeRoot, "core.fileMode", fileMode);
|
|
if (splitIndex !== undefined) await config.set(worktreeRoot, "core.splitIndex", splitIndex);
|
|
// Preserve the shallow boundary so history traversal over the borrowed
|
|
// object DB stops at the boundary instead of failing on missing parents.
|
|
if (shallowBoundary !== null) await Bun.write(path.join(gitEntry, "shallow"), shallowBoundary);
|
|
|
|
// Restore sparse-checkout state before the index so skip-worktree entries
|
|
// keep resolving against the carried patterns.
|
|
if (sparseCheckout) await config.set(worktreeRoot, "core.sparseCheckout", sparseCheckout);
|
|
if (sparseCone) await config.set(worktreeRoot, "core.sparseCheckoutCone", sparseCone);
|
|
if (sparsePatterns !== null) {
|
|
const infoDir = path.join(gitEntry, "info");
|
|
await fs.promises.mkdir(infoDir, { recursive: true });
|
|
await Bun.write(path.join(infoDir, "sparse-checkout"), sparsePatterns);
|
|
}
|
|
|
|
// Restore the index verbatim (skip-worktree, assume-unchanged, exact stage
|
|
// entries) so the working tree's dirty set — including sparse-excluded files
|
|
// — matches the source. Fall back to rebuilding from HEAD only when the
|
|
// source had no index (a bare-ish/never-staged checkout).
|
|
if (indexBytes) {
|
|
for (const shared of sharedIndexFiles) {
|
|
await Bun.write(path.join(gitEntry, shared.name), shared.bytes);
|
|
}
|
|
await Bun.write(path.join(gitEntry, "index"), indexBytes);
|
|
} else if (headSha) {
|
|
await readTree(worktreeRoot, headSha);
|
|
}
|
|
return "detached";
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// API: show
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
/** Run `git show` on a revision. */
|
|
export const show = Object.assign(
|
|
async function show(
|
|
cwd: string,
|
|
revision: string,
|
|
options: { format?: string; signal?: AbortSignal } = {},
|
|
): Promise<string> {
|
|
return runText(cwd, ["show", `--format=${options.format ?? ""}`, revision], {
|
|
readOnly: true,
|
|
signal: options.signal,
|
|
});
|
|
},
|
|
{
|
|
/** Get the path prefix of the current directory relative to the repo root. */
|
|
async prefix(cwd: string, signal?: AbortSignal): Promise<string> {
|
|
return (await runText(cwd, ["rev-parse", "--show-prefix"], { readOnly: true, signal })).trim();
|
|
},
|
|
},
|
|
);
|
|
|
|
/** Read commit message and author metadata for replay/rewrite flows. */
|
|
export async function commitDetails(cwd: string, revision: string, signal?: AbortSignal): Promise<CommitDetails> {
|
|
const raw = await runText(cwd, ["show", "-s", "--format=%an%x00%ae%x00%aI%x00%B", revision], {
|
|
readOnly: true,
|
|
signal,
|
|
});
|
|
const [name = "", email = "", date = "", ...messageParts] = raw.split("\0");
|
|
return {
|
|
author: { date, email, name },
|
|
message: messageParts.join("\0").replace(/\n$/, ""),
|
|
};
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// API: log
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
export const log = {
|
|
/** Recent commit subjects (one-line each). */
|
|
async subjects(cwd: string, count: number, signal?: AbortSignal): Promise<string[]> {
|
|
return splitLines(await runText(cwd, ["log", `-n${count}`, "--pretty=format:%s"], { readOnly: true, signal }));
|
|
},
|
|
/** Recent commits as `<short-sha> <subject>` onelines. */
|
|
async onelines(cwd: string, count: number, signal?: AbortSignal): Promise<string[]> {
|
|
return splitLines(
|
|
await runText(cwd, ["log", `-${count}`, "--oneline", "--no-decorate"], { readOnly: true, signal }),
|
|
);
|
|
},
|
|
};
|
|
|
|
export const revList = {
|
|
/** Commits in `base..head`, oldest first. */
|
|
async range(cwd: string, base: string, head: string, signal?: AbortSignal): Promise<string[]> {
|
|
return splitLines(await runText(cwd, ["rev-list", "--reverse", `${base}..${head}`], { readOnly: true, signal }));
|
|
},
|
|
};
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// API: branch
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
export const branch = {
|
|
/** Current branch name, or null if detached/unavailable. */
|
|
async current(cwd: string, signal?: AbortSignal): Promise<string | null> {
|
|
const headState = await resolveHead(cwd);
|
|
if (headState?.kind === "ref") return headState.branchName ?? headState.ref;
|
|
const result = await git(cwd, ["symbolic-ref", "--short", "HEAD"], { readOnly: true, signal });
|
|
if (result.exitCode !== 0) return null;
|
|
return result.stdout.trim() || null;
|
|
},
|
|
|
|
/** Default branch name (from remote HEAD refs). */
|
|
async default(cwd: string, signal?: AbortSignal): Promise<string | null> {
|
|
const repository = await resolveRepository(cwd);
|
|
if (repository) {
|
|
for (const refPath of DEFAULT_BRANCH_REFS) {
|
|
const target = await readRef(repository, refPath, signal);
|
|
const branchName = parseDefaultBranchRef(refPath, target);
|
|
if (branchName) return branchName;
|
|
}
|
|
}
|
|
for (const remoteRef of ["origin/HEAD", "upstream/HEAD"]) {
|
|
const result = await git(cwd, ["rev-parse", "--abbrev-ref", remoteRef], { readOnly: true, signal });
|
|
if (result.exitCode !== 0) continue;
|
|
const branchName = stripRemotePrefix(result.stdout.trim());
|
|
if (branchName) return branchName;
|
|
}
|
|
return null;
|
|
},
|
|
|
|
/** Create a new branch at the given start point. */
|
|
async create(cwd: string, name: string, startPoint = "HEAD", signal?: AbortSignal): Promise<void> {
|
|
await runEffect(cwd, ["branch", name, startPoint], { signal });
|
|
},
|
|
|
|
/** Force-move a branch to a new start point. */
|
|
async force(cwd: string, name: string, startPoint: string, signal?: AbortSignal): Promise<void> {
|
|
await runEffect(cwd, ["branch", "--force", name, startPoint], { signal });
|
|
},
|
|
|
|
/** Delete a branch. Throws on failure. */
|
|
async delete(cwd: string, name: string, options: { force?: boolean; signal?: AbortSignal } = {}): Promise<void> {
|
|
await runEffect(cwd, ["branch", options.force === false ? "-d" : "-D", name], { signal: options.signal });
|
|
},
|
|
|
|
/** Delete a branch. Returns false on failure instead of throwing. */
|
|
async tryDelete(
|
|
cwd: string,
|
|
name: string,
|
|
options: { force?: boolean; signal?: AbortSignal } = {},
|
|
): Promise<boolean> {
|
|
const result = await git(cwd, ["branch", options.force === false ? "-d" : "-D", name], {
|
|
signal: options.signal,
|
|
});
|
|
return result.exitCode === 0;
|
|
},
|
|
|
|
/** Create and checkout a new branch. */
|
|
async checkoutNew(cwd: string, name: string, signal?: AbortSignal): Promise<void> {
|
|
await runEffect(cwd, ["checkout", "-b", name], { signal });
|
|
},
|
|
|
|
/** List branches. Pass `{ all: true }` to include remotes. */
|
|
async list(cwd: string, options: { all?: boolean; signal?: AbortSignal } = {}): Promise<string[]> {
|
|
const args = ["branch"];
|
|
if (options.all) args.push("-a");
|
|
args.push("--format=%(refname:short)");
|
|
return splitLines(await runText(cwd, args, { readOnly: true, signal: options.signal }));
|
|
},
|
|
};
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// API: remote
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
export const remote = {
|
|
/** List remote names. */
|
|
async list(cwd: string, signal?: AbortSignal): Promise<string[]> {
|
|
return splitLines(await runText(cwd, ["remote"], { readOnly: true, signal }));
|
|
},
|
|
|
|
/** Get the URL for a remote. */
|
|
async url(cwd: string, name: string, signal?: AbortSignal): Promise<string | undefined> {
|
|
return trimScalar(await tryText(cwd, ["remote", "get-url", name], { readOnly: true, signal }));
|
|
},
|
|
|
|
/**
|
|
* Add a remote pointing at `url`. Idempotent: if a remote named `name`
|
|
* already exists with the same URL (e.g. an in-process race or a leftover
|
|
* remote from a previous run), this is treated as success. Throws when the
|
|
* remote exists with a different URL — that's a real conflict the caller
|
|
* needs to resolve, not paper over.
|
|
*/
|
|
async add(cwd: string, name: string, url: string, signal?: AbortSignal): Promise<void> {
|
|
const result = await git(cwd, ["remote", "add", name, url], { signal });
|
|
if (result.exitCode === 0) return;
|
|
const existing = await remote.url(cwd, name, signal);
|
|
if (existing !== undefined) {
|
|
if (existing === url) return;
|
|
throw new ToolError(`remote ${name} already exists with URL ${existing}, expected ${url}`);
|
|
}
|
|
throw new GitCommandError(["remote", "add", name, url], result);
|
|
},
|
|
};
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// API: ref
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
export const ref = {
|
|
/** Check if a ref exists. */
|
|
async exists(cwd: string, refName: string, signal?: AbortSignal): Promise<boolean> {
|
|
if (refName === "HEAD") return (await head.sha(cwd, signal)) !== null;
|
|
const repository = await resolveRepository(cwd);
|
|
if (repository && refName.startsWith("refs/")) return (await readRef(repository, refName, signal)) !== null;
|
|
const result = await git(cwd, ["show-ref", "--verify", "--quiet", refName], { readOnly: true, signal });
|
|
return result.exitCode === 0;
|
|
},
|
|
|
|
/** Resolve a ref to its commit SHA. */
|
|
async resolve(cwd: string, refName: string, signal?: AbortSignal): Promise<string | null> {
|
|
if (refName === "HEAD") return head.sha(cwd, signal);
|
|
const repository = await resolveRepository(cwd);
|
|
if (repository && refName.startsWith("refs/")) return readRef(repository, refName, signal);
|
|
const result = await git(cwd, ["rev-parse", refName], { readOnly: true, signal });
|
|
if (result.exitCode !== 0) return null;
|
|
return result.stdout.trim() || null;
|
|
},
|
|
|
|
/** Tags pointing at a ref. */
|
|
async tags(cwd: string, refName = "HEAD", signal?: AbortSignal): Promise<string[]> {
|
|
return splitLines(
|
|
await runText(
|
|
cwd,
|
|
[
|
|
"for-each-ref",
|
|
"--points-at",
|
|
refName,
|
|
"--sort=-version:refname",
|
|
"--format=%(refname:strip=2)",
|
|
"refs/tags",
|
|
],
|
|
{ readOnly: true, signal },
|
|
),
|
|
);
|
|
},
|
|
};
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// API: config
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
export const config = {
|
|
async get(cwd: string, key: string, signal?: AbortSignal): Promise<string | undefined> {
|
|
return trimScalar(await tryText(cwd, ["config", "--get", key], { readOnly: true, signal }));
|
|
},
|
|
|
|
async set(cwd: string, key: string, value: string, signal?: AbortSignal): Promise<void> {
|
|
await runEffect(cwd, ["config", key, value], { signal });
|
|
},
|
|
|
|
async getBranch(cwd: string, branchName: string, key: string, signal?: AbortSignal): Promise<string | undefined> {
|
|
return config.get(cwd, `branch.${branchName}.${key}`, signal);
|
|
},
|
|
|
|
async setBranch(cwd: string, branchName: string, key: string, value: string, signal?: AbortSignal): Promise<void> {
|
|
return config.set(cwd, `branch.${branchName}.${key}`, value, signal);
|
|
},
|
|
};
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// API: worktree
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
export const worktree = {
|
|
async add(
|
|
cwd: string,
|
|
worktreePath: string,
|
|
refName: string,
|
|
options: { detach?: boolean; signal?: AbortSignal } = {},
|
|
): Promise<void> {
|
|
const args = ["worktree", "add"];
|
|
if (options.detach) args.push("--detach");
|
|
args.push(worktreePath, refName);
|
|
await runEffect(cwd, args, { signal: options.signal });
|
|
},
|
|
|
|
async remove(
|
|
cwd: string,
|
|
worktreePath: string,
|
|
options: { force?: boolean; signal?: AbortSignal } = {},
|
|
): Promise<void> {
|
|
const args = ["worktree", "remove"];
|
|
if (options.force ?? true) args.push("-f");
|
|
args.push(worktreePath);
|
|
await runEffect(cwd, args, { signal: options.signal });
|
|
},
|
|
|
|
async tryRemove(
|
|
cwd: string,
|
|
worktreePath: string,
|
|
options: { force?: boolean; signal?: AbortSignal } = {},
|
|
): Promise<boolean> {
|
|
const args = ["worktree", "remove"];
|
|
if (options.force ?? true) args.push("-f");
|
|
args.push(worktreePath);
|
|
const result = await git(cwd, args, { signal: options.signal });
|
|
return result.exitCode === 0;
|
|
},
|
|
|
|
async list(cwd: string, signal?: AbortSignal): Promise<GitWorktreeEntry[]> {
|
|
return parseWorktreeList(await runText(cwd, ["worktree", "list", "--porcelain"], { readOnly: true, signal }));
|
|
},
|
|
|
|
async prune(cwd: string, signal?: AbortSignal): Promise<void> {
|
|
await runEffect(cwd, ["worktree", "prune"], { signal });
|
|
},
|
|
};
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// API: patch
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
export const patch = {
|
|
/** Apply a patch file. */
|
|
async apply(cwd: string, patchPath: string, options: PatchOptions = {}): Promise<void> {
|
|
await runEffect(cwd, buildApplyArgs(patchPath, options), { env: options.env, signal: options.signal });
|
|
},
|
|
|
|
/** Apply a patch from a string (writes to a temp file). */
|
|
async applyText(cwd: string, patchText: string, options: PatchOptions = {}): Promise<void> {
|
|
if (!patchText.trim()) return;
|
|
const tempPath = await writeTempPatch(patchText);
|
|
try {
|
|
await patch.apply(cwd, tempPath, options);
|
|
} finally {
|
|
await fs.promises.rm(tempPath, { force: true });
|
|
}
|
|
},
|
|
|
|
/** Check if a patch file can be applied cleanly. */
|
|
async canApply(cwd: string, patchPath: string, options: Omit<PatchOptions, "check"> = {}): Promise<boolean> {
|
|
const result = await git(cwd, buildApplyArgs(patchPath, { ...options, check: true }), {
|
|
env: options.env,
|
|
readOnly: true,
|
|
signal: options.signal,
|
|
});
|
|
return result.exitCode === 0;
|
|
},
|
|
|
|
/** Check if a patch string can be applied cleanly. */
|
|
async canApplyText(cwd: string, patchText: string, options: Omit<PatchOptions, "check"> = {}): Promise<boolean> {
|
|
if (!patchText.trim()) return true;
|
|
const tempPath = await writeTempPatch(patchText);
|
|
try {
|
|
return await patch.canApply(cwd, tempPath, options);
|
|
} finally {
|
|
await fs.promises.rm(tempPath, { force: true });
|
|
}
|
|
},
|
|
|
|
/** Join patch parts into a single patch string. */
|
|
join(parts: string[]): string {
|
|
return `${parts
|
|
.map(part => (part.endsWith("\n") ? part : `${part}\n`))
|
|
.join("\n")
|
|
.replace(/\n+$/, "")}\n`;
|
|
},
|
|
};
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// API: cherryPick
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
export const cherryPick = Object.assign(
|
|
async function cherryPick(cwd: string, revision: string, signal?: AbortSignal): Promise<void> {
|
|
await runEffect(cwd, ["cherry-pick", revision], { signal });
|
|
},
|
|
{
|
|
async abort(cwd: string, signal?: AbortSignal): Promise<void> {
|
|
await runEffect(cwd, ["cherry-pick", "--abort"], { signal });
|
|
},
|
|
/**
|
|
* Skip the current commit of an in-progress cherry-pick sequence and
|
|
* continue with the rest of the range. Use after {@link isEmptyError}
|
|
* reports the current attempt collapsed to a no-op — the alternative,
|
|
* `--abort`, throws away every remaining commit in the range.
|
|
*/
|
|
async skip(cwd: string, signal?: AbortSignal): Promise<void> {
|
|
await runEffect(cwd, ["cherry-pick", "--skip"], { signal });
|
|
},
|
|
/**
|
|
* True when a cherry-pick failure was caused by the current commit
|
|
* being empty against HEAD — either redundant with an already-applied
|
|
* change, or auto-resolved to HEAD by a 3-way merge. Callers should
|
|
* `--skip` in this case to advance the sequencer rather than aborting
|
|
* the whole range: an empty commit is not a merge conflict, and any
|
|
* later commits in the range still deserve to land.
|
|
*/
|
|
isEmptyError(err: unknown): boolean {
|
|
return err instanceof GitCommandError && /the previous cherry-pick is now empty/i.test(err.result.stderr);
|
|
},
|
|
},
|
|
);
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// API: stash
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
export const stash = {
|
|
/** Stash working tree + index changes. Returns true when git created a new stash entry. */
|
|
async push(cwd: string, message?: string): Promise<boolean> {
|
|
ensureAvailable();
|
|
const previousStash = await ref.resolve(cwd, "refs/stash");
|
|
const args = ["stash", "push", "--include-untracked"];
|
|
if (message) args.push("-m", message);
|
|
await runEffect(cwd, args);
|
|
const nextStash = await ref.resolve(cwd, "refs/stash");
|
|
return nextStash !== null && nextStash !== previousStash;
|
|
},
|
|
/** Pop the most recent stash entry, optionally restoring its staged state. */
|
|
async pop(cwd: string, options?: { index?: boolean }): Promise<void> {
|
|
const args = ["stash", "pop"];
|
|
if (options?.index) args.push("--index");
|
|
await runEffect(cwd, args);
|
|
},
|
|
/**
|
|
* Return the working-tree patch that `stash@{0}` would apply, in a form
|
|
* that `git apply --check` can consume. Empty string when no stash entry
|
|
* exists or the stash contains no diffable working-tree changes.
|
|
*/
|
|
async showPatch(cwd: string): Promise<string> {
|
|
return (await tryText(cwd, ["stash", "show", "-p", "--binary", "stash@{0}"], { readOnly: true })) ?? "";
|
|
},
|
|
/** Return untracked paths stored in the top stash entry. */
|
|
async untrackedFiles(cwd: string): Promise<string[]> {
|
|
const output = await tryText(cwd, ["ls-tree", "-r", "-z", "--name-only", "stash@{0}^3"], { readOnly: true });
|
|
return output?.split("\0").filter(Boolean) ?? [];
|
|
},
|
|
/**
|
|
* Attempt to restore the top stash entry. On success returns `true` and
|
|
* git drops the stash entry. On conflict returns `false`, leaves the stash
|
|
* entry preserved for manual resolution, and guarantees the failed restore
|
|
* leaves no unmerged index entries or partially-restored untracked files.
|
|
*
|
|
* The historical raw `pop` catches the failure in a `finally` block and
|
|
* only logs — it leaves `.git/index` with stage 1/2/3 unmerged entries
|
|
* that survive indefinitely, corrupting every subsequent overlay-isolated
|
|
* task that reads through this repo's `.git/`. See issue #4175.
|
|
*/
|
|
async tryPop(cwd: string, options?: { index?: boolean }): Promise<boolean> {
|
|
// Preflight: `git stash pop` internally does a 3-way merge, so a plain
|
|
// `git apply --check` is too strict — it rejects hunks whose context
|
|
// drifted from HEAD even when 3-way merge would resolve them cleanly.
|
|
// Match pop's semantics with `--3way --check`, which succeeds iff the
|
|
// patch either applies directly or merges without conflict against
|
|
// the patch's `index abc..def` base blobs.
|
|
const workingPatch = await stash.showPatch(cwd);
|
|
if (workingPatch.trim() && !(await patch.canApplyText(cwd, workingPatch, { threeWay: true }))) {
|
|
return false;
|
|
}
|
|
const restoredUntracked = await stash.untrackedFiles(cwd);
|
|
try {
|
|
await stash.pop(cwd, options);
|
|
return true;
|
|
} catch {
|
|
// Preflight can still miss mode-only or delete/modify conflicts. If
|
|
// the pop left unmerged entries, wipe them: HEAD holds the merged
|
|
// state so `reset --hard HEAD` restores a clean index and working
|
|
// tree without losing the cherry-picked commits. A failed pop can
|
|
// still restore unrelated untracked files before exiting while
|
|
// preserving the stash entry, so clean only the untracked paths
|
|
// recorded in that stash. The user's WIP remains recoverable via
|
|
// `git stash pop`.
|
|
try {
|
|
await reset(cwd, { hard: true });
|
|
} catch {
|
|
/* best-effort cleanup — do not mask the primary conflict */
|
|
}
|
|
if (restoredUntracked.length > 0) {
|
|
try {
|
|
await clean(cwd, { includeIgnored: true, literalPathspecs: true, paths: restoredUntracked });
|
|
} catch {
|
|
/* best-effort cleanup — do not mask the primary conflict */
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
},
|
|
};
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// API: clone, restore, clean
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
export async function clone(url: string, targetDir: string, options: CloneOptions = {}): Promise<void> {
|
|
ensureAvailable();
|
|
const absoluteTarget = path.resolve(targetDir);
|
|
await fs.promises.mkdir(path.dirname(absoluteTarget), { recursive: true });
|
|
|
|
// `git clone --depth 1 --single-branch` only fetches the tip of the target
|
|
// branch, so any subsequent `git checkout <sha>` for a non-tip commit fails
|
|
// with "reference is not a tree". When the caller pinned a specific SHA we
|
|
// fall back to a full clone so the object is guaranteed to be present.
|
|
const shallow = !options.sha;
|
|
const args = ["clone"];
|
|
if (shallow) args.push("--depth", "1");
|
|
if (options.ref) args.push("--branch", options.ref, "--single-branch");
|
|
else if (shallow) args.push("--single-branch");
|
|
args.push(url, absoluteTarget);
|
|
|
|
try {
|
|
await runEffect(path.dirname(absoluteTarget), args, {
|
|
signal: options.signal,
|
|
timeoutMs: resolveTimeoutMs(options.timeoutMs, GIT_NETWORK_TIMEOUT_MS),
|
|
});
|
|
if (options.sha) {
|
|
try {
|
|
await checkout(absoluteTarget, options.sha, options.signal);
|
|
} catch {
|
|
await fs.promises.rm(absoluteTarget, { force: true, recursive: true });
|
|
throw new Error(`Failed to checkout SHA ${options.sha} in cloned repository ${url}`);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
await fs.promises.rm(absoluteTarget, { force: true, recursive: true });
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
export async function restore(cwd: string, options: RestoreOptions = {}): Promise<void> {
|
|
const args = ["restore"];
|
|
if (options.source) args.push(`--source=${options.source}`);
|
|
if (options.staged) args.push("--staged");
|
|
if (options.worktree) args.push("--worktree");
|
|
if (options.files?.length) args.push("--", ...options.files);
|
|
await runEffect(cwd, args, { signal: options.signal });
|
|
}
|
|
|
|
/**
|
|
* Run `git reset` with options. Default is a soft reset (no flag); pass `hard: true` for a destructive reset.
|
|
*
|
|
* NOTE: stage.reset() handles the per-file unstaging case. This helper exists for tree-wide resets.
|
|
*/
|
|
export async function reset(
|
|
cwd: string,
|
|
options: { hard?: boolean; mixed?: boolean; soft?: boolean; target?: string; signal?: AbortSignal } = {},
|
|
): Promise<void> {
|
|
const args = ["reset"];
|
|
if (options.hard) args.push("--hard");
|
|
else if (options.mixed) args.push("--mixed");
|
|
else if (options.soft) args.push("--soft");
|
|
if (options.target) args.push(options.target);
|
|
await runEffect(cwd, args, { signal: options.signal });
|
|
}
|
|
|
|
export async function clean(
|
|
cwd: string,
|
|
options: {
|
|
ignoredOnly?: boolean;
|
|
includeIgnored?: boolean;
|
|
literalPathspecs?: boolean;
|
|
paths?: readonly string[];
|
|
signal?: AbortSignal;
|
|
} = {},
|
|
): Promise<void> {
|
|
const args = [options.literalPathspecs ? "--literal-pathspecs" : undefined, "clean"].filter(
|
|
(arg): arg is string => arg !== undefined,
|
|
);
|
|
args.push(options.ignoredOnly ? "-fdX" : options.includeIgnored ? "-fdx" : "-fd");
|
|
if (options.paths?.length) args.push("--", ...options.paths);
|
|
await runEffect(cwd, args, { signal: options.signal });
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// API: ls
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
export const ls = {
|
|
/** List files tracked or untracked by git. */
|
|
async files(
|
|
cwd: string,
|
|
options: { others?: boolean; excludeStandard?: boolean; signal?: AbortSignal } = {},
|
|
): Promise<string[]> {
|
|
const args = ["ls-files"];
|
|
if (options.others) args.push("--others");
|
|
if (options.excludeStandard) args.push("--exclude-standard");
|
|
return splitLines(await runText(cwd, args, { readOnly: true, signal: options.signal }));
|
|
},
|
|
|
|
/** List untracked files (excludes ignored). */
|
|
async untracked(cwd: string, signal?: AbortSignal): Promise<string[]> {
|
|
return ls.files(cwd, { others: true, excludeStandard: true, signal });
|
|
},
|
|
|
|
/** List paths present in a ref, optionally filtered to specific paths. */
|
|
async tree(cwd: string, ref: string, files: readonly string[] = [], signal?: AbortSignal): Promise<string[]> {
|
|
const args = ["ls-tree", "--name-only", "-r", "-z", ref];
|
|
if (files.length > 0) args.push("--", ...files);
|
|
const raw = await runText(cwd, args, { readOnly: true, signal });
|
|
return raw.split("\0").filter(entry => entry.length > 0);
|
|
},
|
|
|
|
/** List submodule paths (recursive). */
|
|
async submodules(cwd: string, signal?: AbortSignal): Promise<string[]> {
|
|
const output = await git(cwd, ["submodule", "--quiet", "foreach", "--recursive", "echo $sm_path"], {
|
|
readOnly: true,
|
|
signal,
|
|
});
|
|
return splitLines(output.stdout);
|
|
},
|
|
};
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// API: head
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
export const head = {
|
|
/** Full HEAD state (branch, commit, repo info). */
|
|
async resolve(cwd: string, signal?: AbortSignal): Promise<GitHeadState | null> {
|
|
const repository = await resolveRepository(cwd);
|
|
if (!repository) return null;
|
|
if (await isReftableRepo(repository)) {
|
|
return resolveHeadStateReftable(repository, signal);
|
|
}
|
|
const content = await readOptionalText(repository.headPath);
|
|
if (content === null) return null;
|
|
return parseHeadState(repository, content);
|
|
},
|
|
|
|
/** Full HEAD state (synchronous). */
|
|
resolveSync(cwd: string): GitHeadState | null {
|
|
const repository = resolveRepositorySync(cwd);
|
|
if (!repository) return null;
|
|
if (isReftableRepoSync(repository)) {
|
|
return resolveHeadStateReftableSync(repository);
|
|
}
|
|
const content = readOptionalTextSync(repository.headPath);
|
|
if (content === null) return null;
|
|
return parseHeadStateSync(repository, content);
|
|
},
|
|
|
|
/** Current HEAD commit SHA. */
|
|
async sha(cwd: string, signal?: AbortSignal): Promise<string | null> {
|
|
const headState = await head.resolve(cwd, signal);
|
|
if (headState?.commit) return headState.commit;
|
|
const result = await git(cwd, ["rev-parse", "HEAD"], { readOnly: true, signal });
|
|
if (result.exitCode !== 0) return null;
|
|
return result.stdout.trim() || null;
|
|
},
|
|
|
|
/** Abbreviated HEAD commit SHA. */
|
|
async short(cwd: string, length = 7, signal?: AbortSignal): Promise<string | null> {
|
|
const result = await git(cwd, ["rev-parse", `--short=${length}`, "HEAD"], { readOnly: true, signal });
|
|
if (result.exitCode !== 0) return null;
|
|
return result.stdout.trim() || null;
|
|
},
|
|
};
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// API: repo
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
export const repo = {
|
|
/** Resolve the repository root (may be a worktree root). */
|
|
async root(cwd: string, signal?: AbortSignal): Promise<string | null> {
|
|
const repository = await resolveRepository(cwd);
|
|
if (repository) return repository.repoRoot;
|
|
const result = await git(cwd, ["rev-parse", "--show-toplevel"], { readOnly: true, signal });
|
|
if (result.exitCode !== 0) return null;
|
|
return result.stdout.trim() || null;
|
|
},
|
|
|
|
/** Resolve the primary checkout root, or the shared common dir for bare-repo worktrees. */
|
|
async primaryRoot(cwd: string, signal?: AbortSignal): Promise<string | null> {
|
|
const repository = await resolveRepository(cwd);
|
|
if (repository) return primaryRootFromRepository(repository);
|
|
const repoRoot = await repo.root(cwd, signal);
|
|
if (!repoRoot) return null;
|
|
const commonDir = await runText(repoRoot, ["rev-parse", "--path-format=absolute", "--git-common-dir"], {
|
|
readOnly: true,
|
|
signal,
|
|
});
|
|
if (path.basename(commonDir.trim()) === ".git") return path.dirname(commonDir.trim());
|
|
return repoRoot;
|
|
},
|
|
|
|
/**
|
|
* Sync sibling of {@link primaryRoot}. Resolves only via on-disk `.git`/
|
|
* `commondir` walking — no subprocess fallback — so it stays usable from
|
|
* paths where async I/O is impractical (e.g. `computeBankScope`). Returns
|
|
* `null` when `cwd` is outside a repository. Bare-repo worktrees resolve to
|
|
* the shared common dir (`foo.git`) because they have no primary checkout.
|
|
*/
|
|
primaryRootSync(cwd: string): string | null {
|
|
const repository = resolveRepositorySync(cwd);
|
|
if (!repository) return null;
|
|
return primaryRootFromRepositorySync(repository);
|
|
},
|
|
|
|
/**
|
|
* Linked-worktree metadata for `cwd`, or `null` when `cwd` is the primary
|
|
* checkout (or outside a repository). `root` is the worktree's own checkout
|
|
* root; `primaryRoot` is the shared main checkout that names the project.
|
|
* Resolves purely via on-disk `.git`/`commondir` walking — no subprocess —
|
|
* so the status line may call it on every render.
|
|
*/
|
|
linkedWorktreeSync(cwd: string): { root: string; primaryRoot: string } | null {
|
|
const repository = resolveRepositorySync(cwd);
|
|
if (!repository || !isLinkedWorktree(repository)) return null;
|
|
return { root: repository.repoRoot, primaryRoot: primaryRootFromRepositorySync(repository) };
|
|
},
|
|
|
|
/** Full GitRepository metadata (sync). */
|
|
resolveSync(cwd: string): GitRepository | null {
|
|
return resolveRepositorySync(cwd);
|
|
},
|
|
|
|
/** Full GitRepository metadata. */
|
|
resolve(cwd: string): Promise<GitRepository | null> {
|
|
return resolveRepository(cwd);
|
|
},
|
|
|
|
/** Check if the repository uses the reftable reference storage format (sync). */
|
|
isReftableSync(repository: GitRepository): boolean {
|
|
return isReftableRepoSync(repository);
|
|
},
|
|
|
|
/** Check if the repository uses the reftable reference storage format. */
|
|
isReftable(repository: GitRepository): Promise<boolean> {
|
|
return isReftableRepo(repository);
|
|
},
|
|
};
|
|
|
|
// Helper used during head resolution — defined here to reference `head` namespace.
|
|
async function resolveHead(cwd: string, signal?: AbortSignal): Promise<GitHeadState | null> {
|
|
return head.resolve(cwd, signal);
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// API: github (GitHub CLI)
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
export interface GhCommandResult {
|
|
exitCode: number;
|
|
stdout: string;
|
|
stderr: string;
|
|
}
|
|
|
|
export interface GhCommandOptions {
|
|
repoProvided?: boolean;
|
|
trimOutput?: boolean;
|
|
}
|
|
|
|
function formatGhFailure(args: readonly string[], stdout: string, stderr: string, options?: GhCommandOptions): string {
|
|
const message = (stderr || stdout).trim();
|
|
if (message.includes("gh auth login") || message.includes("not logged into any GitHub hosts")) {
|
|
return "GitHub CLI is not authenticated. Run `gh auth login`.";
|
|
}
|
|
if (
|
|
!options?.repoProvided &&
|
|
(message.includes("not a git repository") ||
|
|
message.includes("no git remotes found") ||
|
|
message.includes("unable to determine current repository"))
|
|
) {
|
|
return "GitHub repository context is unavailable. Pass `repo` explicitly or run the tool inside a GitHub checkout.";
|
|
}
|
|
if (message.length > 0) return message;
|
|
return `GitHub CLI command failed: gh ${args.join(" ")}`;
|
|
}
|
|
|
|
export const github = {
|
|
/** Check if `gh` CLI is installed. */
|
|
available(): boolean {
|
|
return Boolean($which("gh"));
|
|
},
|
|
|
|
/** Run a raw `gh` CLI command. Does not throw on non-zero exit. */
|
|
async run(cwd: string, args: string[], signal?: AbortSignal, options?: GhCommandOptions): Promise<GhCommandResult> {
|
|
throwIfAborted(signal);
|
|
if (!$which("gh")) {
|
|
throw new ToolError("GitHub CLI (gh) is not installed. Install it from https://cli.github.com/.");
|
|
}
|
|
try {
|
|
const child = Bun.spawn(["gh", ...args], {
|
|
cwd,
|
|
env: {
|
|
...process.env,
|
|
...GH_NON_INTERACTIVE_ENV,
|
|
},
|
|
stdin: "ignore",
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
windowsHide: true,
|
|
signal,
|
|
});
|
|
const { stdout, stderr, exitCode } = await collectSubprocessResult("gh", args, child, {});
|
|
throwIfAborted(signal);
|
|
const trim = options?.trimOutput !== false;
|
|
return {
|
|
exitCode: exitCode ?? 0,
|
|
stdout: trim ? stdout.trim() : stdout,
|
|
stderr: trim ? stderr.trim() : stderr,
|
|
};
|
|
} catch (error) {
|
|
if (signal?.aborted) throw new ToolAbortError();
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
/** Run `gh` and parse stdout as JSON. Throws on non-zero exit or invalid JSON. */
|
|
async json<T>(cwd: string, args: string[], signal?: AbortSignal, options?: GhCommandOptions): Promise<T> {
|
|
const result = await github.run(cwd, args, signal, options);
|
|
if (result.exitCode !== 0) {
|
|
throw new ToolError(formatGhFailure(args, result.stdout, result.stderr, options));
|
|
}
|
|
if (!result.stdout) {
|
|
throw new ToolError("GitHub CLI returned empty output.");
|
|
}
|
|
try {
|
|
return JSON.parse(result.stdout) as T;
|
|
} catch {
|
|
throw new ToolError("GitHub CLI returned invalid JSON output.");
|
|
}
|
|
},
|
|
|
|
/** Run `gh` and return stdout as text. Throws on non-zero exit. */
|
|
async text(cwd: string, args: string[], signal?: AbortSignal, options?: GhCommandOptions): Promise<string> {
|
|
const result = await github.run(cwd, args, signal, options);
|
|
if (result.exitCode !== 0) {
|
|
throw new ToolError(formatGhFailure(args, result.stdout, result.stderr, options));
|
|
}
|
|
return result.stdout;
|
|
},
|
|
};
|