* fix: avoid native hangs in gsd auto paths * fix: use .js extension in edit-diff.test.ts import for tsc compatibility * fix: prevent OOM on large file diffs and implement context-line windowing - Add size guard (MAX_DP_CELLS=4M) to buildLineDiff that falls back to a linear-time prefix/suffix matching algorithm for large files, preventing the O(n*m) DP table from causing OOM crashes - Implement contextLines parameter in generateDiffString so only lines within N lines of a change are rendered (with "..." separators), matching unified diff behavior — the parameter was previously accepted but ignored - Add tests for both context windowing and large-file fallback --------- Co-authored-by: TÂCHES <afromanguy@me.com>
1017 lines
30 KiB
TypeScript
1017 lines
30 KiB
TypeScript
// Native Git Bridge
|
|
// Provides high-performance git operations backed by libgit2 via the Rust native module.
|
|
// Falls back to execSync/execFileSync git commands when the native module is unavailable.
|
|
//
|
|
// Both READ and WRITE operations are native — push operations remain as
|
|
// execSync calls because git2 credential handling is too complex.
|
|
|
|
import { execSync, execFileSync } from "node:child_process";
|
|
import { existsSync, readFileSync, unlinkSync, rmSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
|
|
/** Env overlay that suppresses interactive git credential prompts and git-svn noise. */
|
|
const GIT_NO_PROMPT_ENV = {
|
|
...process.env,
|
|
GIT_TERMINAL_PROMPT: "0",
|
|
GIT_ASKPASS: "",
|
|
GIT_SVN_ID: "",
|
|
};
|
|
|
|
// Issue #453: keep auto-mode bookkeeping on the stable git CLI path unless a
|
|
// caller explicitly opts into the native helper.
|
|
const NATIVE_GSD_GIT_ENABLED = process.env.GSD_ENABLE_NATIVE_GSD_GIT === "1";
|
|
|
|
// ─── Native Module Types ──────────────────────────────────────────────────
|
|
|
|
interface GitDiffStat {
|
|
filesChanged: number;
|
|
insertions: number;
|
|
deletions: number;
|
|
summary: string;
|
|
}
|
|
|
|
interface GitNameStatus {
|
|
status: string;
|
|
path: string;
|
|
}
|
|
|
|
interface GitNumstat {
|
|
added: number;
|
|
removed: number;
|
|
path: string;
|
|
}
|
|
|
|
interface GitLogEntry {
|
|
sha: string;
|
|
message: string;
|
|
}
|
|
|
|
interface GitWorktreeEntry {
|
|
path: string;
|
|
branch: string;
|
|
isBare: boolean;
|
|
}
|
|
|
|
interface GitBatchInfo {
|
|
branch: string;
|
|
hasChanges: boolean;
|
|
status: string;
|
|
stagedCount: number;
|
|
unstagedCount: number;
|
|
}
|
|
|
|
interface GitMergeResult {
|
|
success: boolean;
|
|
conflicts: string[];
|
|
}
|
|
|
|
// ─── Native Module Loading ──────────────────────────────────────────────────
|
|
|
|
let nativeModule: {
|
|
// Existing read functions
|
|
gitCurrentBranch: (repoPath: string) => string | null;
|
|
gitMainBranch: (repoPath: string) => string;
|
|
gitBranchExists: (repoPath: string, branch: string) => boolean;
|
|
gitHasMergeConflicts: (repoPath: string) => boolean;
|
|
gitWorkingTreeStatus: (repoPath: string) => string;
|
|
gitHasChanges: (repoPath: string) => boolean;
|
|
gitCommitCountBetween: (repoPath: string, fromRef: string, toRef: string) => number;
|
|
// New read functions
|
|
gitIsRepo: (path: string) => boolean;
|
|
gitHasStagedChanges: (repoPath: string) => boolean;
|
|
gitDiffStat: (repoPath: string, fromRef: string, toRef: string) => GitDiffStat;
|
|
gitDiffNameStatus: (repoPath: string, fromRef: string, toRef: string, pathspec?: string, useMergeBase?: boolean) => GitNameStatus[];
|
|
gitDiffNumstat: (repoPath: string, fromRef: string, toRef: string) => GitNumstat[];
|
|
gitDiffContent: (repoPath: string, fromRef: string, toRef: string, pathspec?: string, exclude?: string, useMergeBase?: boolean) => string;
|
|
gitLogOneline: (repoPath: string, fromRef: string, toRef: string) => GitLogEntry[];
|
|
gitWorktreeList: (repoPath: string) => GitWorktreeEntry[];
|
|
gitBranchList: (repoPath: string, pattern?: string) => string[];
|
|
gitBranchListMerged: (repoPath: string, target: string, pattern?: string) => string[];
|
|
gitLsFiles: (repoPath: string, pathspec: string) => string[];
|
|
gitForEachRef: (repoPath: string, prefix: string) => string[];
|
|
gitConflictFiles: (repoPath: string) => string[];
|
|
gitBatchInfo: (repoPath: string) => GitBatchInfo;
|
|
// Write functions
|
|
gitInit: (path: string, initialBranch?: string) => void;
|
|
gitAddAll: (repoPath: string) => void;
|
|
gitAddPaths: (repoPath: string, paths: string[]) => void;
|
|
gitResetPaths: (repoPath: string, paths: string[]) => void;
|
|
gitCommit: (repoPath: string, message: string, allowEmpty?: boolean) => string;
|
|
gitCheckoutBranch: (repoPath: string, branch: string) => void;
|
|
gitCheckoutTheirs: (repoPath: string, paths: string[]) => void;
|
|
gitMergeSquash: (repoPath: string, branch: string) => GitMergeResult;
|
|
gitMergeAbort: (repoPath: string) => void;
|
|
gitRebaseAbort: (repoPath: string) => void;
|
|
gitResetHard: (repoPath: string) => void;
|
|
gitBranchDelete: (repoPath: string, branch: string, force?: boolean) => void;
|
|
gitBranchForceReset: (repoPath: string, branch: string, target: string) => void;
|
|
gitRmCached: (repoPath: string, paths: string[], recursive?: boolean) => string[];
|
|
gitRmForce: (repoPath: string, paths: string[]) => void;
|
|
gitWorktreeAdd: (repoPath: string, wtPath: string, branch: string, createBranch?: boolean, startPoint?: string) => void;
|
|
gitWorktreeRemove: (repoPath: string, wtPath: string, force?: boolean) => void;
|
|
gitWorktreePrune: (repoPath: string) => void;
|
|
gitRevertCommit: (repoPath: string, sha: string) => void;
|
|
gitRevertAbort: (repoPath: string) => void;
|
|
gitUpdateRef: (repoPath: string, refname: string, target?: string) => void;
|
|
} | null = null;
|
|
|
|
let loadAttempted = false;
|
|
|
|
function loadNative(): typeof nativeModule {
|
|
if (loadAttempted) return nativeModule;
|
|
loadAttempted = true;
|
|
if (!NATIVE_GSD_GIT_ENABLED) return nativeModule;
|
|
|
|
try {
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
const mod = require("@gsd/native");
|
|
if (mod.gitCurrentBranch && mod.gitHasChanges) {
|
|
nativeModule = mod;
|
|
}
|
|
} catch {
|
|
// Native module not available — all functions fall back to git CLI
|
|
}
|
|
|
|
return nativeModule;
|
|
}
|
|
|
|
// ─── Fallback Helpers ──────────────────────────────────────────────────────
|
|
|
|
/** Run a git command via execFileSync. Returns trimmed stdout. */
|
|
function gitExec(basePath: string, args: string[], allowFailure = false): string {
|
|
try {
|
|
return execFileSync("git", args, {
|
|
cwd: basePath,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
encoding: "utf-8",
|
|
env: GIT_NO_PROMPT_ENV,
|
|
}).trim();
|
|
} catch {
|
|
if (allowFailure) return "";
|
|
throw new Error(`git ${args.join(" ")} failed in ${basePath}`);
|
|
}
|
|
}
|
|
|
|
/** Run a git command via execFileSync. Returns trimmed stdout. */
|
|
function gitFileExec(basePath: string, args: string[], allowFailure = false): string {
|
|
try {
|
|
return execFileSync("git", args, {
|
|
cwd: basePath,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
encoding: "utf-8",
|
|
}).trim();
|
|
} catch {
|
|
if (allowFailure) return "";
|
|
throw new Error(`git ${args.join(" ")} failed in ${basePath}`);
|
|
}
|
|
}
|
|
|
|
// ─── Existing Read Functions ──────────────────────────────────────────────
|
|
|
|
/**
|
|
* Get the current branch name.
|
|
* Native: reads HEAD symbolic ref via libgit2.
|
|
* Fallback: `git branch --show-current`.
|
|
*/
|
|
export function nativeGetCurrentBranch(basePath: string): string {
|
|
const native = loadNative();
|
|
if (native) {
|
|
const branch = native.gitCurrentBranch(basePath);
|
|
return branch ?? "";
|
|
}
|
|
return gitExec(basePath, ["branch", "--show-current"]);
|
|
}
|
|
|
|
/**
|
|
* Detect the repo-level main branch (origin/HEAD → main → master → current).
|
|
* Native: checks refs via libgit2.
|
|
* Fallback: `git symbolic-ref` + `git show-ref` chain.
|
|
*/
|
|
export function nativeDetectMainBranch(basePath: string): string {
|
|
const native = loadNative();
|
|
if (native) {
|
|
return native.gitMainBranch(basePath);
|
|
}
|
|
|
|
const symbolic = gitExec(basePath, ["symbolic-ref", "refs/remotes/origin/HEAD"], true);
|
|
if (symbolic) {
|
|
const match = symbolic.match(/refs\/remotes\/origin\/(.+)$/);
|
|
if (match) return match[1]!;
|
|
}
|
|
|
|
const mainExists = gitExec(basePath, ["show-ref", "--verify", "refs/heads/main"], true);
|
|
if (mainExists) return "main";
|
|
|
|
const masterExists = gitExec(basePath, ["show-ref", "--verify", "refs/heads/master"], true);
|
|
if (masterExists) return "master";
|
|
|
|
return gitExec(basePath, ["branch", "--show-current"]);
|
|
}
|
|
|
|
/**
|
|
* Check if a local branch exists.
|
|
* Native: checks refs/heads/<name> via libgit2.
|
|
* Fallback: `git show-ref --verify`.
|
|
*/
|
|
export function nativeBranchExists(basePath: string, branch: string): boolean {
|
|
const native = loadNative();
|
|
if (native) {
|
|
return native.gitBranchExists(basePath, branch);
|
|
}
|
|
const result = gitExec(basePath, ["show-ref", "--verify", `refs/heads/${branch}`], true);
|
|
return result !== "";
|
|
}
|
|
|
|
/**
|
|
* Check if the index has unmerged entries (merge conflicts).
|
|
* Native: reads index conflict state via libgit2.
|
|
* Fallback: `git diff --name-only --diff-filter=U`.
|
|
*/
|
|
export function nativeHasMergeConflicts(basePath: string): boolean {
|
|
const native = loadNative();
|
|
if (native) {
|
|
return native.gitHasMergeConflicts(basePath);
|
|
}
|
|
const result = gitExec(basePath, ["diff", "--name-only", "--diff-filter=U"], true);
|
|
return result !== "";
|
|
}
|
|
|
|
/**
|
|
* Get working tree status (porcelain format).
|
|
* Native: reads status via libgit2.
|
|
* Fallback: `git status --porcelain`.
|
|
*/
|
|
export function nativeWorkingTreeStatus(basePath: string): string {
|
|
const native = loadNative();
|
|
if (native) {
|
|
return native.gitWorkingTreeStatus(basePath);
|
|
}
|
|
return gitExec(basePath, ["status", "--porcelain"], true);
|
|
}
|
|
|
|
/**
|
|
* Quick check: any staged or unstaged changes?
|
|
* Native: libgit2 status check (single syscall).
|
|
* Fallback: `git status --short`.
|
|
*/
|
|
export function nativeHasChanges(basePath: string): boolean {
|
|
const native = loadNative();
|
|
if (native) {
|
|
return native.gitHasChanges(basePath);
|
|
}
|
|
const result = gitExec(basePath, ["status", "--short"], true);
|
|
return result !== "";
|
|
}
|
|
|
|
/**
|
|
* Count commits between two refs (from..to).
|
|
* Native: libgit2 revwalk.
|
|
* Fallback: `git rev-list --count from..to`.
|
|
*/
|
|
export function nativeCommitCountBetween(basePath: string, fromRef: string, toRef: string): number {
|
|
const native = loadNative();
|
|
if (native) {
|
|
return native.gitCommitCountBetween(basePath, fromRef, toRef);
|
|
}
|
|
const result = gitExec(basePath, ["rev-list", "--count", `${fromRef}..${toRef}`], true);
|
|
return parseInt(result, 10) || 0;
|
|
}
|
|
|
|
// ─── New Read Functions ──────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Check if a path is inside a git repository.
|
|
* Native: Repository::open() check.
|
|
* Fallback: `git rev-parse --git-dir`.
|
|
*/
|
|
export function nativeIsRepo(basePath: string): boolean {
|
|
const native = loadNative();
|
|
if (native) {
|
|
return native.gitIsRepo(basePath);
|
|
}
|
|
try {
|
|
execSync("git rev-parse --git-dir", { cwd: basePath, stdio: "pipe" });
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if there are staged changes (index differs from HEAD).
|
|
* Native: libgit2 tree-to-index diff.
|
|
* Fallback: `git diff --cached --stat`.
|
|
*/
|
|
export function nativeHasStagedChanges(basePath: string): boolean {
|
|
const native = loadNative();
|
|
if (native) {
|
|
return native.gitHasStagedChanges(basePath);
|
|
}
|
|
const result = gitExec(basePath, ["diff", "--cached", "--stat"], true);
|
|
return result !== "";
|
|
}
|
|
|
|
/**
|
|
* Get diff statistics.
|
|
* Use fromRef="HEAD", toRef="WORKDIR" for working tree diff.
|
|
* Use fromRef="HEAD", toRef="INDEX" for staged diff.
|
|
* Native: libgit2 diff stats.
|
|
* Fallback: `git diff --stat`.
|
|
*/
|
|
export function nativeDiffStat(basePath: string, fromRef: string, toRef: string): GitDiffStat {
|
|
const native = loadNative();
|
|
if (native) {
|
|
return native.gitDiffStat(basePath, fromRef, toRef);
|
|
}
|
|
|
|
// Fallback
|
|
let args: string[];
|
|
if (fromRef === "HEAD" && toRef === "WORKDIR") {
|
|
args = ["diff", "--stat", "HEAD"];
|
|
} else if (fromRef === "HEAD" && toRef === "INDEX") {
|
|
args = ["diff", "--stat", "--cached", "HEAD"];
|
|
} else {
|
|
args = ["diff", "--stat", fromRef, toRef];
|
|
}
|
|
|
|
const result = gitExec(basePath, args, true);
|
|
// Parse numeric stats from the summary line (e.g. "3 files changed, 10 insertions(+), 2 deletions(-)")
|
|
let filesChanged = 0, insertions = 0, deletions = 0;
|
|
const statsMatch = result.match(/(\d+) files? changed(?:, (\d+) insertions?\(\+\))?(?:, (\d+) deletions?\(-\))?/);
|
|
if (statsMatch) {
|
|
filesChanged = parseInt(statsMatch[1] ?? "0", 10);
|
|
insertions = parseInt(statsMatch[2] ?? "0", 10);
|
|
deletions = parseInt(statsMatch[3] ?? "0", 10);
|
|
}
|
|
return { filesChanged, insertions, deletions, summary: result };
|
|
}
|
|
|
|
/**
|
|
* Get name-status diff between two refs with optional pathspec filter.
|
|
* useMergeBase: if true, uses three-dot semantics (main...branch).
|
|
* Native: libgit2 tree-to-tree diff.
|
|
* Fallback: `git diff --name-status`.
|
|
*/
|
|
export function nativeDiffNameStatus(
|
|
basePath: string,
|
|
fromRef: string,
|
|
toRef: string,
|
|
pathspec?: string,
|
|
useMergeBase?: boolean,
|
|
): GitNameStatus[] {
|
|
const native = loadNative();
|
|
if (native) {
|
|
return native.gitDiffNameStatus(basePath, fromRef, toRef, pathspec, useMergeBase);
|
|
}
|
|
|
|
// Fallback
|
|
const separator = useMergeBase ? "..." : " ";
|
|
const args = ["diff", "--name-status", `${fromRef}${separator}${toRef}`];
|
|
if (pathspec) args.push("--", pathspec);
|
|
|
|
const result = gitExec(basePath, args, true);
|
|
if (!result) return [];
|
|
|
|
return result.split("\n").filter(Boolean).map(line => {
|
|
const [status, ...pathParts] = line.split("\t");
|
|
return { status: status ?? "", path: pathParts.join("\t") };
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get numstat diff between two refs.
|
|
* Native: libgit2 patch line stats.
|
|
* Fallback: `git diff --numstat`.
|
|
*/
|
|
export function nativeDiffNumstat(basePath: string, fromRef: string, toRef: string): GitNumstat[] {
|
|
const native = loadNative();
|
|
if (native) {
|
|
return native.gitDiffNumstat(basePath, fromRef, toRef);
|
|
}
|
|
|
|
const result = gitExec(basePath, ["diff", "--numstat", fromRef, toRef], true);
|
|
if (!result) return [];
|
|
|
|
return result.split("\n").filter(Boolean).map(line => {
|
|
const [a, r, ...pathParts] = line.split("\t");
|
|
return {
|
|
added: a === "-" ? 0 : parseInt(a ?? "0", 10),
|
|
removed: r === "-" ? 0 : parseInt(r ?? "0", 10),
|
|
path: pathParts.join("\t"),
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get unified diff content between two refs.
|
|
* useMergeBase: if true, uses three-dot semantics.
|
|
* Native: libgit2 diff print.
|
|
* Fallback: `git diff`.
|
|
*/
|
|
export function nativeDiffContent(
|
|
basePath: string,
|
|
fromRef: string,
|
|
toRef: string,
|
|
pathspec?: string,
|
|
exclude?: string,
|
|
useMergeBase?: boolean,
|
|
): string {
|
|
const native = loadNative();
|
|
if (native) {
|
|
return native.gitDiffContent(basePath, fromRef, toRef, pathspec, exclude, useMergeBase);
|
|
}
|
|
|
|
const separator = useMergeBase ? "..." : " ";
|
|
const args = ["diff", `${fromRef}${separator}${toRef}`];
|
|
if (pathspec) {
|
|
args.push("--", pathspec);
|
|
} else if (exclude) {
|
|
args.push("--", ".", `:(exclude)${exclude}`);
|
|
}
|
|
|
|
return gitExec(basePath, args, true);
|
|
}
|
|
|
|
/**
|
|
* Get commit log between two refs (from..to).
|
|
* Native: libgit2 revwalk.
|
|
* Fallback: `git log --oneline from..to`.
|
|
*/
|
|
export function nativeLogOneline(basePath: string, fromRef: string, toRef: string): GitLogEntry[] {
|
|
const native = loadNative();
|
|
if (native) {
|
|
return native.gitLogOneline(basePath, fromRef, toRef);
|
|
}
|
|
|
|
const result = gitExec(basePath, ["log", "--oneline", `${fromRef}..${toRef}`], true);
|
|
if (!result) return [];
|
|
|
|
return result.split("\n").filter(Boolean).map(line => {
|
|
const sha = line.substring(0, 7);
|
|
const message = line.substring(8);
|
|
return { sha, message };
|
|
});
|
|
}
|
|
|
|
/**
|
|
* List git worktrees.
|
|
* Native: libgit2 worktree API.
|
|
* Fallback: `git worktree list --porcelain`.
|
|
*/
|
|
export function nativeWorktreeList(basePath: string): GitWorktreeEntry[] {
|
|
const native = loadNative();
|
|
if (native) {
|
|
return native.gitWorktreeList(basePath);
|
|
}
|
|
|
|
const result = gitExec(basePath, ["worktree", "list", "--porcelain"], true);
|
|
if (!result) return [];
|
|
|
|
const entries: GitWorktreeEntry[] = [];
|
|
const blocks = result.replaceAll("\r\n", "\n").split("\n\n").filter(Boolean);
|
|
|
|
for (const block of blocks) {
|
|
const lines = block.split("\n");
|
|
const wtLine = lines.find(l => l.startsWith("worktree "));
|
|
const branchLine = lines.find(l => l.startsWith("branch "));
|
|
const isBare = lines.some(l => l === "bare");
|
|
|
|
if (wtLine) {
|
|
entries.push({
|
|
path: wtLine.replace("worktree ", ""),
|
|
branch: branchLine ? branchLine.replace("branch refs/heads/", "") : "",
|
|
isBare,
|
|
});
|
|
}
|
|
}
|
|
|
|
return entries;
|
|
}
|
|
|
|
/**
|
|
* List branches matching an optional pattern.
|
|
* Native: libgit2 branch iterator.
|
|
* Fallback: `git branch --list <pattern>`.
|
|
*/
|
|
export function nativeBranchList(basePath: string, pattern?: string): string[] {
|
|
const native = loadNative();
|
|
if (native) {
|
|
return native.gitBranchList(basePath, pattern);
|
|
}
|
|
|
|
const args = ["branch", "--list"];
|
|
if (pattern) args.push(pattern);
|
|
|
|
const result = gitFileExec(basePath, args, true);
|
|
if (!result) return [];
|
|
|
|
return result.split("\n").map(b => b.trim().replace(/^\* /, "")).filter(Boolean);
|
|
}
|
|
|
|
/**
|
|
* List branches merged into target.
|
|
* Native: libgit2 merge-base check.
|
|
* Fallback: `git branch --merged <target> --list <pattern>`.
|
|
*/
|
|
export function nativeBranchListMerged(basePath: string, target: string, pattern?: string): string[] {
|
|
const native = loadNative();
|
|
if (native) {
|
|
return native.gitBranchListMerged(basePath, target, pattern);
|
|
}
|
|
|
|
const args = ["branch", "--merged", target];
|
|
if (pattern) args.push("--list", pattern);
|
|
|
|
const result = gitFileExec(basePath, args, true);
|
|
if (!result) return [];
|
|
|
|
return result.split("\n").map(b => b.trim()).filter(Boolean);
|
|
}
|
|
|
|
/**
|
|
* List tracked files matching a pathspec.
|
|
* Native: libgit2 index iteration.
|
|
* Fallback: `git ls-files <pathspec>`.
|
|
*/
|
|
export function nativeLsFiles(basePath: string, pathspec: string): string[] {
|
|
const native = loadNative();
|
|
if (native) {
|
|
return native.gitLsFiles(basePath, pathspec);
|
|
}
|
|
|
|
const result = gitFileExec(basePath, ["ls-files", pathspec], true);
|
|
if (!result) return [];
|
|
return result.split("\n").filter(Boolean);
|
|
}
|
|
|
|
/**
|
|
* List references matching a prefix.
|
|
* Native: libgit2 references_glob.
|
|
* Fallback: `git for-each-ref <prefix> --format=%(refname)`.
|
|
*/
|
|
export function nativeForEachRef(basePath: string, prefix: string): string[] {
|
|
const native = loadNative();
|
|
if (native) {
|
|
return native.gitForEachRef(basePath, prefix);
|
|
}
|
|
|
|
const result = gitFileExec(basePath, ["for-each-ref", prefix, "--format=%(refname)"], true);
|
|
if (!result) return [];
|
|
return result.split("\n").filter(Boolean);
|
|
}
|
|
|
|
/**
|
|
* Get list of files with unmerged (conflict) entries.
|
|
* Native: libgit2 index conflicts.
|
|
* Fallback: `git diff --name-only --diff-filter=U`.
|
|
*/
|
|
export function nativeConflictFiles(basePath: string): string[] {
|
|
const native = loadNative();
|
|
if (native) {
|
|
return native.gitConflictFiles(basePath);
|
|
}
|
|
|
|
const result = gitExec(basePath, ["diff", "--name-only", "--diff-filter=U"], true);
|
|
if (!result) return [];
|
|
return result.split("\n").filter(Boolean);
|
|
}
|
|
|
|
/**
|
|
* Get batch info: branch + status + change counts in ONE call.
|
|
* Native: single libgit2 call replaces 3-4 sequential execSync calls.
|
|
* Fallback: multiple git commands.
|
|
*/
|
|
export function nativeBatchInfo(basePath: string): GitBatchInfo {
|
|
const native = loadNative();
|
|
if (native) {
|
|
return native.gitBatchInfo(basePath);
|
|
}
|
|
|
|
const branch = gitExec(basePath, ["branch", "--show-current"], true);
|
|
const status = gitExec(basePath, ["status", "--porcelain"], true);
|
|
const hasChanges = status !== "";
|
|
|
|
// Parse porcelain status to count staged vs unstaged changes
|
|
let stagedCount = 0;
|
|
let unstagedCount = 0;
|
|
if (status) {
|
|
for (const line of status.split("\n")) {
|
|
if (!line || line.length < 2) continue;
|
|
const x = line[0]; // index (staged) status
|
|
const y = line[1]; // worktree (unstaged) status
|
|
if (x !== " " && x !== "?") stagedCount++;
|
|
if (y !== " " && y !== "?") unstagedCount++;
|
|
if (x === "?" && y === "?") unstagedCount++; // untracked files
|
|
}
|
|
}
|
|
|
|
return {
|
|
branch,
|
|
hasChanges,
|
|
status,
|
|
stagedCount,
|
|
unstagedCount,
|
|
};
|
|
}
|
|
|
|
// ─── Write Functions ──────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Initialize a new git repository.
|
|
* Native: libgit2 Repository::init.
|
|
* Fallback: `git init -b <branch>`.
|
|
*/
|
|
export function nativeInit(basePath: string, initialBranch?: string): void {
|
|
const native = loadNative();
|
|
if (native) {
|
|
native.gitInit(basePath, initialBranch);
|
|
return;
|
|
}
|
|
|
|
const args = ["init"];
|
|
if (initialBranch) args.push("-b", initialBranch);
|
|
gitFileExec(basePath, args);
|
|
}
|
|
|
|
/**
|
|
* Stage all files (git add -A).
|
|
* Native: libgit2 index add_all + update_all.
|
|
* Fallback: `git add -A`.
|
|
*/
|
|
export function nativeAddAll(basePath: string): void {
|
|
const native = loadNative();
|
|
if (native) {
|
|
native.gitAddAll(basePath);
|
|
return;
|
|
}
|
|
gitFileExec(basePath, ["add", "-A"]);
|
|
}
|
|
|
|
/**
|
|
* Stage specific files.
|
|
* Native: libgit2 index add.
|
|
* Fallback: `git add -- <paths>`.
|
|
*/
|
|
export function nativeAddPaths(basePath: string, paths: string[]): void {
|
|
const native = loadNative();
|
|
if (native) {
|
|
native.gitAddPaths(basePath, paths);
|
|
return;
|
|
}
|
|
gitFileExec(basePath, ["add", "--", ...paths]);
|
|
}
|
|
|
|
/**
|
|
* Unstage files (reset index entries to HEAD).
|
|
* Native: libgit2 reset_default.
|
|
* Fallback: `git reset HEAD -- <paths>`.
|
|
*/
|
|
export function nativeResetPaths(basePath: string, paths: string[]): void {
|
|
const native = loadNative();
|
|
if (native) {
|
|
native.gitResetPaths(basePath, paths);
|
|
return;
|
|
}
|
|
for (const p of paths) {
|
|
gitExec(basePath, ["reset", "HEAD", "--", p], true);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a commit from the current index.
|
|
* Returns the commit SHA on success, or null if nothing to commit.
|
|
* Native: libgit2 commit create.
|
|
* Fallback: `git commit --no-verify -F -`.
|
|
*/
|
|
export function nativeCommit(
|
|
basePath: string,
|
|
message: string,
|
|
options?: { allowEmpty?: boolean; input?: string },
|
|
): string | null {
|
|
const native = loadNative();
|
|
if (native) {
|
|
try {
|
|
return native.gitCommit(basePath, message, options?.allowEmpty);
|
|
} catch (e) {
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
if (msg.includes("nothing to commit")) return null;
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
// Fallback: use git commit with stdin pipe for safe multi-line messages
|
|
try {
|
|
const result = execSync(
|
|
`git commit --no-verify -F -${options?.allowEmpty ? " --allow-empty" : ""}`,
|
|
{
|
|
cwd: basePath,
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
encoding: "utf-8",
|
|
env: GIT_NO_PROMPT_ENV,
|
|
input: message,
|
|
},
|
|
).trim();
|
|
return result;
|
|
} catch (err: unknown) {
|
|
const errObj = err as { stdout?: string; stderr?: string; message?: string };
|
|
const combined = [errObj.stdout, errObj.stderr, errObj.message].filter(Boolean).join(" ");
|
|
if (combined.includes("nothing to commit") || combined.includes("nothing added to commit") || combined.includes("no changes added")) {
|
|
return null;
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Checkout a branch (switch HEAD and update working tree).
|
|
* Native: libgit2 checkout + set_head.
|
|
* Fallback: `git checkout <branch>`.
|
|
*/
|
|
export function nativeCheckoutBranch(basePath: string, branch: string): void {
|
|
const native = loadNative();
|
|
if (native) {
|
|
native.gitCheckoutBranch(basePath, branch);
|
|
return;
|
|
}
|
|
execSync(`git checkout ${branch}`, {
|
|
cwd: basePath,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
encoding: "utf-8",
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Resolve index conflicts by accepting "theirs" version.
|
|
* Native: libgit2 index conflict resolution.
|
|
* Fallback: `git checkout --theirs -- <file>`.
|
|
*/
|
|
export function nativeCheckoutTheirs(basePath: string, paths: string[]): void {
|
|
const native = loadNative();
|
|
if (native) {
|
|
native.gitCheckoutTheirs(basePath, paths);
|
|
return;
|
|
}
|
|
for (const path of paths) {
|
|
gitFileExec(basePath, ["checkout", "--theirs", "--", path]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Squash-merge a branch (stages changes, does NOT commit).
|
|
* Native: libgit2 merge with squash semantics.
|
|
* Fallback: `git merge --squash <branch>`.
|
|
*/
|
|
export function nativeMergeSquash(basePath: string, branch: string): GitMergeResult {
|
|
const native = loadNative();
|
|
if (native) {
|
|
return native.gitMergeSquash(basePath, branch);
|
|
}
|
|
|
|
try {
|
|
execSync(`git merge --squash ${branch}`, {
|
|
cwd: basePath,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
encoding: "utf-8",
|
|
});
|
|
return { success: true, conflicts: [] };
|
|
} catch {
|
|
// Check for conflicts
|
|
const conflictOutput = gitExec(basePath, ["diff", "--name-only", "--diff-filter=U"], true);
|
|
const conflicts = conflictOutput ? conflictOutput.split("\n").filter(Boolean) : [];
|
|
return { success: conflicts.length === 0, conflicts };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Abort an in-progress merge.
|
|
* Native: libgit2 reset + cleanup.
|
|
* Fallback: `git merge --abort`.
|
|
*/
|
|
export function nativeMergeAbort(basePath: string): void {
|
|
const native = loadNative();
|
|
if (native) {
|
|
native.gitMergeAbort(basePath);
|
|
return;
|
|
}
|
|
gitExec(basePath, ["merge", "--abort"], true);
|
|
}
|
|
|
|
/**
|
|
* Abort an in-progress rebase.
|
|
* Native: libgit2 reset + cleanup.
|
|
* Fallback: `git rebase --abort`.
|
|
*/
|
|
export function nativeRebaseAbort(basePath: string): void {
|
|
const native = loadNative();
|
|
if (native) {
|
|
native.gitRebaseAbort(basePath);
|
|
return;
|
|
}
|
|
gitExec(basePath, ["rebase", "--abort"], true);
|
|
}
|
|
|
|
/**
|
|
* Hard reset to HEAD.
|
|
* Native: libgit2 reset(Hard).
|
|
* Fallback: `git reset --hard HEAD`.
|
|
*/
|
|
export function nativeResetHard(basePath: string): void {
|
|
const native = loadNative();
|
|
if (native) {
|
|
native.gitResetHard(basePath);
|
|
return;
|
|
}
|
|
execSync("git reset --hard HEAD", { cwd: basePath, stdio: "pipe" });
|
|
}
|
|
|
|
/**
|
|
* Delete a branch.
|
|
* Native: libgit2 branch delete.
|
|
* Fallback: `git branch -D/-d <branch>`.
|
|
*/
|
|
export function nativeBranchDelete(basePath: string, branch: string, force = true): void {
|
|
const native = loadNative();
|
|
if (native) {
|
|
native.gitBranchDelete(basePath, branch, force);
|
|
return;
|
|
}
|
|
gitFileExec(basePath, ["branch", force ? "-D" : "-d", branch], true);
|
|
}
|
|
|
|
/**
|
|
* Force-reset a branch to point at a target ref.
|
|
* Native: libgit2 branch create with force.
|
|
* Fallback: `git branch -f <branch> <target>`.
|
|
*/
|
|
export function nativeBranchForceReset(basePath: string, branch: string, target: string): void {
|
|
const native = loadNative();
|
|
if (native) {
|
|
native.gitBranchForceReset(basePath, branch, target);
|
|
return;
|
|
}
|
|
gitExec(basePath, ["branch", "-f", branch, target]);
|
|
}
|
|
|
|
/**
|
|
* Remove files from the index (cache) without touching the working tree.
|
|
* Returns list of removed files.
|
|
* Native: libgit2 index remove.
|
|
* Fallback: `git rm --cached -r --ignore-unmatch <path>`.
|
|
*/
|
|
export function nativeRmCached(basePath: string, paths: string[], recursive = true): string[] {
|
|
const native = loadNative();
|
|
if (native) {
|
|
return native.gitRmCached(basePath, paths, recursive);
|
|
}
|
|
|
|
const removed: string[] = [];
|
|
for (const path of paths) {
|
|
const result = gitExec(
|
|
basePath,
|
|
["rm", "--cached", ...(recursive ? ["-r"] : []), "--ignore-unmatch", path],
|
|
true,
|
|
);
|
|
if (result) removed.push(result);
|
|
}
|
|
return removed;
|
|
}
|
|
|
|
/**
|
|
* Force-remove files from both index and working tree.
|
|
* Native: libgit2 index remove + fs delete.
|
|
* Fallback: `git rm --force -- <file>`.
|
|
*/
|
|
export function nativeRmForce(basePath: string, paths: string[]): void {
|
|
const native = loadNative();
|
|
if (native) {
|
|
native.gitRmForce(basePath, paths);
|
|
return;
|
|
}
|
|
for (const path of paths) {
|
|
gitFileExec(basePath, ["rm", "--force", "--", path], true);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Add a new git worktree.
|
|
* Native: libgit2 worktree API.
|
|
* Fallback: `git worktree add`.
|
|
*/
|
|
export function nativeWorktreeAdd(
|
|
basePath: string,
|
|
wtPath: string,
|
|
branch: string,
|
|
createBranch?: boolean,
|
|
startPoint?: string,
|
|
): void {
|
|
const native = loadNative();
|
|
if (native) {
|
|
native.gitWorktreeAdd(basePath, wtPath, branch, createBranch, startPoint);
|
|
return;
|
|
}
|
|
|
|
if (createBranch) {
|
|
gitExec(basePath, ["worktree", "add", "-b", branch, wtPath, startPoint ?? "HEAD"]);
|
|
} else {
|
|
gitExec(basePath, ["worktree", "add", wtPath, branch]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove a git worktree.
|
|
* Native: libgit2 worktree prune + fs cleanup.
|
|
* Fallback: `git worktree remove [--force] <path>`.
|
|
*/
|
|
export function nativeWorktreeRemove(basePath: string, wtPath: string, force = false): void {
|
|
const native = loadNative();
|
|
if (native) {
|
|
native.gitWorktreeRemove(basePath, wtPath, force);
|
|
return;
|
|
}
|
|
|
|
const args = ["worktree", "remove"];
|
|
if (force) args.push("--force");
|
|
args.push(wtPath);
|
|
gitExec(basePath, args, true);
|
|
}
|
|
|
|
/**
|
|
* Prune stale worktree entries.
|
|
* Native: libgit2 worktree validation + prune.
|
|
* Fallback: `git worktree prune`.
|
|
*/
|
|
export function nativeWorktreePrune(basePath: string): void {
|
|
const native = loadNative();
|
|
if (native) {
|
|
native.gitWorktreePrune(basePath);
|
|
return;
|
|
}
|
|
gitExec(basePath, ["worktree", "prune"], true);
|
|
}
|
|
|
|
/**
|
|
* Revert a commit without auto-committing.
|
|
* Native: libgit2 revert.
|
|
* Fallback: `git revert --no-commit <sha>`.
|
|
*/
|
|
export function nativeRevertCommit(basePath: string, sha: string): void {
|
|
const native = loadNative();
|
|
if (native) {
|
|
native.gitRevertCommit(basePath, sha);
|
|
return;
|
|
}
|
|
gitFileExec(basePath, ["revert", "--no-commit", sha]);
|
|
}
|
|
|
|
/**
|
|
* Abort an in-progress revert.
|
|
* Native: libgit2 reset + cleanup.
|
|
* Fallback: `git revert --abort`.
|
|
*/
|
|
export function nativeRevertAbort(basePath: string): void {
|
|
const native = loadNative();
|
|
if (native) {
|
|
native.gitRevertAbort(basePath);
|
|
return;
|
|
}
|
|
gitFileExec(basePath, ["revert", "--abort"], true);
|
|
}
|
|
|
|
/**
|
|
* Create or delete a ref.
|
|
* When target is provided, creates/updates the ref. When undefined, deletes it.
|
|
* Native: libgit2 reference create/delete.
|
|
* Fallback: `git update-ref`.
|
|
*/
|
|
export function nativeUpdateRef(basePath: string, refname: string, target?: string): void {
|
|
const native = loadNative();
|
|
if (native) {
|
|
native.gitUpdateRef(basePath, refname, target);
|
|
return;
|
|
}
|
|
|
|
if (target !== undefined) {
|
|
gitExec(basePath, ["update-ref", refname, target]);
|
|
} else {
|
|
gitExec(basePath, ["update-ref", "-d", refname], true);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if the native git module is available.
|
|
*/
|
|
export function isNativeGitAvailable(): boolean {
|
|
return loadNative() !== null;
|
|
}
|
|
|
|
// ─── Re-exports for type consumers ──────────────────────────────────────
|
|
|
|
export type {
|
|
GitDiffStat,
|
|
GitNameStatus,
|
|
GitNumstat,
|
|
GitLogEntry,
|
|
GitWorktreeEntry,
|
|
GitBatchInfo,
|
|
GitMergeResult,
|
|
};
|