feat: move git operations to Rust via git2 crate (#572)

* feat: move git operations to Rust via git2 crate (#524)

Eliminates ~70 execSync/execFileSync git CLI calls across 15 TypeScript
files by implementing native libgit2 operations in Rust and routing all
consumers through the native-git-bridge.

Rust (native/crates/engine/src/git.rs):
- Added 28 new NAPI functions covering both read and write operations
- Read: git_is_repo, git_has_staged_changes, git_diff_stat,
  git_diff_name_status, git_diff_numstat, git_diff_content,
  git_log_oneline, git_worktree_list, git_branch_list,
  git_branch_list_merged, git_ls_files, git_for_each_ref,
  git_conflict_files, git_batch_info
- Write: git_init, git_add_all, git_add_paths, git_reset_paths,
  git_commit, git_checkout_branch, git_checkout_theirs,
  git_merge_squash, git_merge_abort, git_rebase_abort,
  git_reset_hard, git_branch_delete, git_branch_force_reset,
  git_rm_cached, git_rm_force, git_worktree_add,
  git_worktree_remove, git_worktree_prune, git_revert_commit,
  git_revert_abort, git_update_ref

TypeScript (native-git-bridge.ts):
- Added 35 bridge functions with native-first + execSync fallback
- New types: GitDiffStat, GitNameStatus, GitNumstat, GitLogEntry,
  GitWorktreeEntry, GitBatchInfo, GitMergeResult

Consumer migrations (15 files):
- worktree-manager.ts: removed local runGit/getMainBranch, all ops native
- auto-worktree.ts: merge, checkout, conflict resolution all native
- git-service.ts: smart staging, commits, snapshots all native
- auto.ts, guided-flow.ts: repo init/bootstrap native
- auto-supervisor.ts: working tree detection native
- git-self-heal.ts: merge/rebase abort, reset all native
- doctor.ts: health checks, branch listing, worktree cleanup native
- commands.ts: branch/snapshot cleanup native
- session-forensics.ts: diff stat queries native
- auto-recovery.ts: merge state reconciliation native
- gitignore.ts, undo.ts, worktree-command.ts: remaining ops native

Kept as execSync (by design):
- git push (credential handling too complex for libgit2)
- native-git-bridge.ts fallbacks (graceful degradation)
- runPreMergeCheck (runs arbitrary user commands)

Closes #524

* fix: restore getMainBranch export from worktree-manager

The agent migration removed getMainBranch from worktree-manager.ts but
worktree-command.ts still imports it. Re-add as a thin wrapper around
nativeDetectMainBranch.

* fix: address PR #572 review feedback — security, correctness, error handling

CRITICAL:
- Path traversal protection via validate_path_within_repo() for
  git_rm_force and git_checkout_theirs
- git_branch_delete defaults to safe delete (force=false)

HIGH:
- Replace silent .ok() with proper error propagation in git_commit,
  git_merge_abort, git_rebase_abort, git_rm_force, git_checkout_theirs
- nativeDiffStat fallback parses numeric stats from git output
- nativeBatchInfo fallback counts staged/unstaged from porcelain status

MEDIUM:
- Wire up dead force param in removeWorktree()
- Read MERGE_MSG/SQUASH_MSG when commit message empty
- nativeLsFiles uses gitFileExec without fragile quote wrapping
- Fix operator precedence in git_ls_files
This commit is contained in:
Flux Labs 2026-03-15 21:02:10 -05:00 committed by GitHub
parent 6d84d1c317
commit 343a43f028
17 changed files with 2876 additions and 466 deletions

View file

@ -0,0 +1,282 @@
# Issue #524: Move Git Operations to Rust via git2 Crate
## Current State
- **git2** crate (v0.20) already a dependency with vendored libgit2
- **7 read-only** functions already native in `git.rs` + `native-git-bridge.ts`:
- `git_current_branch`, `git_main_branch`, `git_branch_exists`
- `git_has_merge_conflicts`, `git_working_tree_status`, `git_has_changes`
- `git_commit_count_between`
- **~73 execSync/execFileSync git calls** remain across 14 TypeScript files
- All native functions follow the same pattern: native-first with execSync fallback
## Scope
This plan covers **Phase 1**: migrate all remaining read operations and high-value
write operations to native git2. Push operations stay as execSync (credential
handling too complex for git2). The "Additional Rust Opportunities" (state
derivation, JSONL parser) are out of scope for this PR.
---
## Phase 1: New Native Read Functions (git.rs)
### 1.1 — `git_is_repo(path: String) -> bool`
Replaces: `git rev-parse --git-dir` (3 calls in auto.ts, guided-flow.ts, doctor.ts)
Implementation: `Repository::open(path).is_ok()`
### 1.2 — `git_has_staged_changes(repo_path: String) -> bool`
Replaces: `git diff --cached --stat` (2 calls in git-service.ts)
Implementation: Diff index vs HEAD tree, check if delta count > 0
### 1.3 — `git_diff_stat(repo_path, from_ref?, to_ref?) -> GitDiffStat`
Replaces: `git diff --stat HEAD`, `git diff --stat --cached HEAD` (session-forensics.ts)
Returns: `{ files_changed: u32, insertions: u32, deletions: u32, summary: String }`
Implementation: Diff between two trees/index/workdir, count deltas
### 1.4 — `git_diff_name_status(repo_path, from_ref, to_ref, pathspec?) -> Vec<GitNameStatus>`
Replaces: `git diff --name-status main...branch -- .gsd/` (worktree-manager.ts, 3 calls)
Returns: `Vec<{ status: String, path: String }>`
Implementation: Tree-to-tree diff with pathspec filter
### 1.5 — `git_diff_numstat(repo_path, from_ref, to_ref) -> Vec<GitNumstat>`
Replaces: `git diff --numstat main branch` (worktree-manager.ts, 1 call)
Returns: `Vec<{ added: u32, removed: u32, path: String }>`
### 1.6 — `git_diff_content(repo_path, from_ref, to_ref, pathspec?, exclude?) -> String`
Replaces: `git diff main...branch -- .gsd/` and `-- . :(exclude).gsd/` (worktree-manager.ts, 2 calls)
Returns: Unified diff string
### 1.7 — `git_log_oneline(repo_path, from_ref, to_ref) -> Vec<GitLogEntry>`
Replaces: `git log --oneline main..branch` (worktree-manager.ts, 1 call)
Returns: `Vec<{ sha: String, message: String }>`
### 1.8 — `git_worktree_list(repo_path) -> Vec<GitWorktreeEntry>`
Replaces: `git worktree list --porcelain` (worktree-manager.ts, 2 calls)
Returns: `Vec<{ path: String, branch: String, is_bare: bool }>`
Implementation: `Repository::worktrees()` + individual worktree info
### 1.9 — `git_branch_list(repo_path, pattern?) -> Vec<String>`
Replaces: `git branch --list milestone/*`, `git branch --list gsd/*` (doctor.ts, commands.ts, 3 calls)
Returns: Branch names matching pattern
### 1.10 — `git_branch_list_merged(repo_path, target, pattern?) -> Vec<String>`
Replaces: `git branch --merged main --list gsd/*` (commands.ts, 1 call)
Returns: Branch names merged into target
### 1.11 — `git_ls_files(repo_path, pathspec) -> Vec<String>`
Replaces: `git ls-files "<exclusion>"` (doctor.ts, 1 call)
Implementation: Read index, filter by pathspec
### 1.12 — `git_for_each_ref(repo_path, prefix) -> Vec<String>`
Replaces: `git for-each-ref refs/gsd/snapshots/ --format=%(refname)` (commands.ts, 1 call)
Implementation: `repo.references_glob(prefix/*)`
### 1.13 — `git_conflict_files(repo_path) -> Vec<String>`
Replaces: `git diff --name-only --diff-filter=U` (auto-worktree.ts, 1 call)
Implementation: Read index conflicts
### 1.14 — `git_batch_info(repo_path) -> GitBatchInfo`
NEW batch function: status + branch + diff summary in ONE call
Returns: `{ branch: String, has_changes: bool, status: String, staged_count: u32, unstaged_count: u32 }`
---
## Phase 2: New Native Write Functions (git.rs)
### 2.1 — `git_init(path, branch?) -> void`
Replaces: `git init -b <branch>` (auto.ts, guided-flow.ts, 2 calls)
Implementation: `Repository::init()` + set initial branch
### 2.2 — `git_add_all(repo_path) -> void`
Replaces: `git add -A` (auto-worktree.ts, git-service.ts, 4 calls)
Implementation: Add all to index via `repo.index().add_all()`
### 2.3 — `git_add_paths(repo_path, paths: Vec<String>) -> void`
Replaces: `git add -- <file>` (auto-worktree.ts, git-service.ts, 3 calls)
Implementation: Add specific paths to index
### 2.4 — `git_reset_paths(repo_path, paths: Vec<String>) -> void`
Replaces: `git reset HEAD -- <path>` (git-service.ts, in loop)
Implementation: Reset index entries to HEAD for specific paths
### 2.5 — `git_commit(repo_path, message, options?) -> String`
Replaces: `git commit -m <msg>`, `git commit --no-verify -F -` (11+ calls across files)
Returns: Commit SHA
Implementation: Write index as tree → create commit → update HEAD
Options: `{ allow_empty: bool }`
### 2.6 — `git_checkout_branch(repo_path, branch) -> void`
Replaces: `git checkout <branch>` (auto-worktree.ts, 1 call)
Implementation: Set HEAD + checkout tree
### 2.7 — `git_checkout_theirs(repo_path, paths: Vec<String>) -> void`
Replaces: `git checkout --theirs -- <file>` (auto-worktree.ts, in loop)
Implementation: Resolve index conflict with "theirs" strategy
### 2.8 — `git_merge_squash(repo_path, branch) -> GitMergeResult`
Replaces: `git merge --squash <branch>` (auto-worktree.ts, worktree-manager.ts, 3 calls)
Returns: `{ success: bool, conflicts: Vec<String> }`
Implementation: Find merge base → merge trees → apply to index
### 2.9 — `git_merge_abort(repo_path) -> void`
Replaces: `git merge --abort` (git-self-heal.ts, worktree-command.ts, 2 calls)
Implementation: Reset to ORIG_HEAD, clean merge state
### 2.10 — `git_rebase_abort(repo_path) -> void`
Replaces: `git rebase --abort` (git-self-heal.ts, 1 call)
### 2.11 — `git_reset_hard(repo_path) -> void`
Replaces: `git reset --hard HEAD` (git-self-heal.ts, 1 call)
Implementation: `repo.reset(HEAD, Hard)`
### 2.12 — `git_branch_delete(repo_path, branch, force: bool) -> void`
Replaces: `git branch -D/-d <branch>` (5 calls across files)
Implementation: `repo.find_branch().delete()`
### 2.13 — `git_branch_force_reset(repo_path, branch, target) -> void`
Replaces: `git branch -f <branch> <target>` (worktree-manager.ts, 1 call)
### 2.14 — `git_rm_cached(repo_path, paths: Vec<String>, recursive: bool) -> Vec<String>`
Replaces: `git rm --cached -r --ignore-unmatch` (git-service.ts, doctor.ts, gitignore.ts, 6 calls)
Returns: List of removed paths
### 2.15 — `git_rm_force(repo_path, paths: Vec<String>) -> void`
Replaces: `git rm --force -- <file>` (auto-worktree.ts, 1 call)
### 2.16 — `git_worktree_add(repo_path, path, branch, create_from?) -> void`
Replaces: `git worktree add` commands (worktree-manager.ts, 2 calls)
Implementation: `repo.worktree()` API
### 2.17 — `git_worktree_remove(repo_path, path, force: bool) -> void`
Replaces: `git worktree remove --force` (worktree-manager.ts, doctor.ts, 3 calls)
### 2.18 — `git_worktree_prune(repo_path) -> void`
Replaces: `git worktree prune` (worktree-manager.ts, 3 calls)
### 2.19 — `git_revert_commit(repo_path, sha, no_commit: bool) -> void`
Replaces: `git revert --no-commit <sha>` (undo.ts, 1 call)
### 2.20 — `git_revert_abort(repo_path) -> void`
Replaces: `git revert --abort` (undo.ts, 1 call)
### 2.21 — `git_update_ref(repo_path, refname, target?) -> void`
Replaces: `git update-ref <ref> HEAD` and `git update-ref -d <ref>` (git-service.ts, commands.ts, 2 calls)
When target is null/empty, deletes the ref.
---
## Phase 3: TypeScript Bridge Updates (native-git-bridge.ts)
Add bridge functions for ALL new native functions, each with:
1. Native-first implementation
2. execSync fallback for when native module unavailable
3. Proper error handling
4. Type definitions
---
## Phase 4: Consumer Migration
Update each TypeScript file to use native bridge functions:
### 4.1 — git-service.ts
- `smartStage()` → use `nativeAddAll()` + `nativeResetPaths()`
- `commit()` → use `nativeCommit()`
- `autoCommit()` → use `nativeHasStagedChanges()`
- `createSnapshot()` → use `nativeUpdateRef()`
- Runtime file cleanup → use `nativeRmCached()`
- `runPreMergeCheck()` → use `nativeReadFile()` or keep fs.readFileSync (not git)
### 4.2 — worktree-manager.ts
- `getMainBranch()` → use `nativeDetectMainBranch()` (already exists!)
- `createWorktree()` → use `nativeWorktreeAdd()`, `nativeBranchForceReset()`
- `listWorktrees()` → use `nativeWorktreeList()`
- `removeWorktree()` → use `nativeWorktreeRemove()`, `nativeWorktreePrune()`, `nativeBranchDelete()`
- `diffWorktreeGSD()` → use `nativeDiffNameStatus()`
- `diffWorktreeAll()` → use `nativeDiffNameStatus()`
- `diffWorktreeNumstat()` → use `nativeDiffNumstat()`
- `getWorktreeGSDDiff()` → use `nativeDiffContent()`
- `getWorktreeCodeDiff()` → use `nativeDiffContent()`
- `getWorktreeLog()` → use `nativeLogOneline()`
- `mergeWorktreeToMain()` → use `nativeMergeSquash()` + `nativeCommit()`
### 4.3 — auto-worktree.ts
- `getCurrentBranch()` → use `nativeGetCurrentBranch()` (already exists!)
- `autoCommitDirtyState()` → use `nativeWorkingTreeStatus()` + `nativeAddAll()` + `nativeCommit()`
- `mergeMilestoneToMain()` → use native merge, checkout, commit, branch delete
### 4.4 — auto.ts
- `git rev-parse --git-dir` → use `nativeIsRepo()`
- `git init -b` → use `nativeInit()`
- `git add -A .gsd .gitignore && git commit` → use `nativeAddPaths()` + `nativeCommit()`
### 4.5 — auto-supervisor.ts
- `detectWorkingTreeActivity()` → use `nativeHasChanges()` (already exists!)
### 4.6 — git-self-heal.ts
- `abortAndReset()` → use `nativeMergeAbort()` + `nativeRebaseAbort()` + `nativeResetHard()`
### 4.7 — guided-flow.ts
- Same pattern as auto.ts for init + bootstrap
### 4.8 — doctor.ts
- `git rev-parse --git-dir` → use `nativeIsRepo()`
- `git worktree remove --force` → use `nativeWorktreeRemove()`
- `git branch --list milestone/*` → use `nativeBranchList()`
- `git branch -D` → use `nativeBranchDelete()`
- `git ls-files` → use `nativeLsFiles()`
- `git rm --cached` → use `nativeRmCached()`
- `git branch --format...` → use `nativeBranchList()`
### 4.9 — gitignore.ts
- `untrackRuntimeFiles()` → use `nativeRmCached()`
### 4.10 — commands.ts
- `handleCleanupBranches()` → use `nativeBranchList()`, `nativeBranchListMerged()`, `nativeBranchDelete()`
- `handleCleanupSnapshots()` → use `nativeForEachRef()`, `nativeUpdateRef()`
### 4.11 — undo.ts
- `git revert --no-commit` → use `nativeRevertCommit()`
- `git revert --abort` → use `nativeRevertAbort()`
### 4.12 — session-forensics.ts
- `getGitChanges()` → use `nativeWorkingTreeStatus()` + `nativeDiffStat()`
### 4.13 — worktree-command.ts
- `git merge --abort` → use `nativeMergeAbort()`
---
## Kept as execSync (out of scope)
- `git push <remote> <branch>` — Credential handling too complex for git2
- `cat package.json` — Not a git command (already just fs.readFileSync)
- `npm test` / custom commands — Not git operations
---
## Implementation Order
1. **Rust functions** (git.rs) — all read functions first, then write functions
2. **TypeScript bridge** (native-git-bridge.ts) — add all new bridge functions
3. **Consumer migration** — update each .ts file to use bridge functions
4. **Remove dead code** — delete local `runGit()` helpers from files that no longer need them
5. **Testing** — build native module, run CI, verify all operations work
---
## Risk Mitigation
- Every native function has an execSync fallback in the bridge
- Write operations are tested by existing integration tests
- git2's vendored libgit2 matches git CLI behavior for standard operations
- The `loadNative()` pattern means if ANY native function crashes, ALL functions fall back to CLI
## Expected Impact
- **~70 execSync calls eliminated** when native module is available
- **Zero process spawns** for git operations in the common path
- **Batch operations** (git_batch_info) reduce 3-4 calls to 1
- **Type-safe errors** instead of parsing stderr strings
- **Consistent cross-platform** behavior via libgit2

File diff suppressed because it is too large Load diff

View file

@ -11,7 +11,14 @@ import type { ExtensionContext } from "@gsd/pi-coding-agent";
import {
clearUnitRuntimeRecord,
} from "./unit-runtime.js";
import { runGit } from "./git-service.js";
import {
nativeConflictFiles,
nativeCommit,
nativeCheckoutTheirs,
nativeAddPaths,
nativeMergeAbort,
nativeResetHard,
} from "./native-git-bridge.js";
import {
resolveMilestonePath,
resolveSlicePath,
@ -351,11 +358,11 @@ export function reconcileMergeState(basePath: string, ctx: ExtensionContext): bo
const hasSquashMsg = existsSync(squashMsgPath);
if (!hasMergeHead && !hasSquashMsg) return false;
const unmerged = runGit(basePath, ["diff", "--name-only", "--diff-filter=U"], { allowFailure: true });
if (!unmerged || !unmerged.trim()) {
const conflictedFiles = nativeConflictFiles(basePath);
if (conflictedFiles.length === 0) {
// All conflicts resolved — finalize the merge/squash commit
try {
runGit(basePath, ["commit", "--no-edit"], { allowFailure: false });
nativeCommit(basePath, ""); // --no-edit equivalent: use empty message placeholder
const mode = hasMergeHead ? "merge" : "squash commit";
ctx.ui.notify(`Finalized leftover ${mode} from prior session.`, "info");
} catch {
@ -363,28 +370,21 @@ export function reconcileMergeState(basePath: string, ctx: ExtensionContext): bo
}
} else {
// Still conflicted — try auto-resolving .gsd/ state file conflicts (#530)
const conflictedFiles = unmerged.trim().split("\n").filter(Boolean);
const gsdConflicts: string[] = [];
const codeConflicts: string[] = [];
for (const f of conflictedFiles) {
(f.startsWith(".gsd/") ? gsdConflicts : codeConflicts).push(f);
}
const gsdConflicts = conflictedFiles.filter(f => f.startsWith(".gsd/"));
const codeConflicts = conflictedFiles.filter(f => !f.startsWith(".gsd/"));
if (gsdConflicts.length > 0 && codeConflicts.length === 0) {
// All conflicts are in .gsd/ state files — auto-resolve by accepting theirs
let resolved = true;
for (const gsdFile of gsdConflicts) {
try {
runGit(basePath, ["checkout", "--theirs", "--", gsdFile], { allowFailure: false });
runGit(basePath, ["add", "--", gsdFile], { allowFailure: false });
} catch {
resolved = false;
break;
}
try {
nativeCheckoutTheirs(basePath, gsdConflicts);
nativeAddPaths(basePath, gsdConflicts);
} catch {
resolved = false;
}
if (resolved) {
try {
runGit(basePath, ["commit", "--no-edit"], { allowFailure: false });
nativeCommit(basePath, "chore: auto-resolve .gsd/ state file conflicts");
ctx.ui.notify(
`Auto-resolved ${gsdConflicts.length} .gsd/ state file conflict(s) from prior merge.`,
"info",
@ -395,11 +395,11 @@ export function reconcileMergeState(basePath: string, ctx: ExtensionContext): bo
}
if (!resolved) {
if (hasMergeHead) {
runGit(basePath, ["merge", "--abort"], { allowFailure: true });
try { nativeMergeAbort(basePath); } catch { /* best-effort */ }
} else if (hasSquashMsg) {
try { unlinkSync(squashMsgPath); } catch { /* best-effort */ }
}
runGit(basePath, ["reset", "--hard", "HEAD"], { allowFailure: true });
try { nativeResetHard(basePath); } catch { /* best-effort */ }
ctx.ui.notify(
"Detected leftover merge state — auto-resolve failed, cleaned up. Re-deriving state.",
"warning",
@ -408,11 +408,11 @@ export function reconcileMergeState(basePath: string, ctx: ExtensionContext): bo
} else {
// Code conflicts present — abort and reset
if (hasMergeHead) {
runGit(basePath, ["merge", "--abort"], { allowFailure: true });
try { nativeMergeAbort(basePath); } catch { /* best-effort */ }
} else if (hasSquashMsg) {
try { unlinkSync(squashMsgPath); } catch { /* best-effort */ }
}
runGit(basePath, ["reset", "--hard", "HEAD"], { allowFailure: true });
try { nativeResetHard(basePath); } catch { /* best-effort */ }
ctx.ui.notify(
"Detected leftover merge state with unresolved conflicts — cleaned up. Re-deriving state.",
"warning",

View file

@ -5,7 +5,7 @@
*/
import { clearLock } from "./crash-recovery.js";
import { execSync } from "node:child_process";
import { nativeHasChanges } from "./native-git-bridge.js";
// ─── SIGTERM Handling ─────────────────────────────────────────────────────────
@ -47,12 +47,7 @@ export function deregisterSigtermHandler(handler: (() => void) | null): void {
*/
export function detectWorkingTreeActivity(cwd: string): boolean {
try {
const out = execSync("git status --porcelain", {
cwd,
stdio: ["pipe", "pipe", "pipe"],
timeout: 5000,
});
return out.toString().trim().length > 0;
return nativeHasChanges(cwd);
} catch {
return false;
}

View file

@ -8,7 +8,7 @@
import { existsSync, cpSync, readFileSync, realpathSync, utimesSync } from "node:fs";
import { join, resolve } from "node:path";
import { execSync, execFileSync } from "node:child_process";
import { execSync } from "node:child_process";
import {
createWorktree,
removeWorktree,
@ -19,6 +19,19 @@ import {
} from "./git-service.js";
import { parseRoadmap } from "./files.js";
import { loadEffectiveGSDPreferences } from "./preferences.js";
import {
nativeGetCurrentBranch,
nativeWorkingTreeStatus,
nativeAddAll,
nativeCommit,
nativeCheckoutBranch,
nativeMergeSquash,
nativeConflictFiles,
nativeCheckoutTheirs,
nativeAddPaths,
nativeRmForce,
nativeBranchDelete,
} from "./native-git-bridge.js";
// ─── Module State ──────────────────────────────────────────────────────────
@ -60,18 +73,6 @@ function nudgeGitBranchCache(previousCwd: string): void {
}
}
function getCurrentBranch(cwd: string): string {
try {
return execSync("git branch --show-current", {
cwd,
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf-8",
}).trim();
} catch {
return "";
}
}
// ─── Auto-Worktree Branch Naming ───────────────────────────────────────────
export function autoWorktreeBranch(milestoneId: string): string {
@ -176,7 +177,7 @@ export function isInAutoWorktree(basePath: string): boolean {
const resolvedBase = existsSync(basePath) ? realpathSync(basePath) : basePath;
const wtDir = join(resolvedBase, ".gsd", "worktrees");
if (!cwd.startsWith(wtDir)) return false;
const branch = getCurrentBranch(cwd);
const branch = nativeGetCurrentBranch(cwd);
return branch.startsWith("milestone/");
}
@ -231,19 +232,11 @@ export function getAutoWorktreeOriginalBase(): string | null {
*/
function autoCommitDirtyState(cwd: string): boolean {
try {
const status = execSync("git status --porcelain", {
cwd,
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf-8",
}).trim();
const status = nativeWorkingTreeStatus(cwd);
if (!status) return false;
execFileSync("git", ["add", "-A"], { cwd, stdio: "pipe" });
execFileSync("git", ["commit", "-m", "chore: auto-commit before milestone merge"], {
cwd,
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf-8",
});
return true;
nativeAddAll(cwd);
const result = nativeCommit(cwd, "chore: auto-commit before milestone merge");
return result !== null;
} catch {
return false;
}
@ -291,11 +284,7 @@ export function mergeMilestoneToMain(
const mainBranch = prefs.main_branch || "main";
// 5. Checkout main
execSync(`git checkout ${mainBranch}`, {
cwd: originalBasePath_,
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf-8",
});
nativeCheckoutBranch(originalBasePath_, mainBranch);
// 6. Build rich commit message
const milestoneTitle = roadmap.title.replace(/^M\d+:\s*/, "").trim() || milestoneId;
@ -308,85 +297,47 @@ export function mergeMilestoneToMain(
const commitMessage = subject + body;
// 7. Squash merge — auto-resolve .gsd/ state file conflicts (#530)
try {
execSync(`git merge --squash ${milestoneBranch}`, {
cwd: originalBasePath_,
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf-8",
});
} catch (mergeErr) {
// Check for conflicts — auto-resolve .gsd/ state files, escalate the rest
try {
const conflictOutput = execSync("git diff --name-only --diff-filter=U", {
cwd: originalBasePath_,
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
}).trim();
if (conflictOutput) {
const conflictedFiles = conflictOutput.split("\n").filter(Boolean);
const mergeResult = nativeMergeSquash(originalBasePath_, milestoneBranch);
// Separate .gsd/ state file conflicts from real code conflicts.
// GSD state files (STATE.md, completed-units.json, auto.lock, etc.)
// diverge between branches during normal operation — always prefer the
// milestone branch version since it has the latest execution state.
const gsdConflicts = conflictedFiles.filter(f => f.startsWith(".gsd/"));
const codeConflicts = conflictedFiles.filter(f => !f.startsWith(".gsd/"));
if (!mergeResult.success) {
// Check for conflicts — use merge result first, fall back to nativeConflictFiles
const conflictedFiles = mergeResult.conflicts.length > 0
? mergeResult.conflicts
: nativeConflictFiles(originalBasePath_);
// Auto-resolve .gsd/ conflicts by accepting the milestone branch version
if (gsdConflicts.length > 0) {
for (const gsdFile of gsdConflicts) {
try {
execFileSync("git", ["checkout", "--theirs", "--", gsdFile], {
cwd: originalBasePath_,
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf-8",
});
execFileSync("git", ["add", "--", gsdFile], {
cwd: originalBasePath_,
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf-8",
});
} catch {
// If checkout --theirs fails, try removing the file from the merge
// (it's a runtime file that shouldn't be committed anyway)
execFileSync("git", ["rm", "--force", "--", gsdFile], {
cwd: originalBasePath_,
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf-8",
});
}
if (conflictedFiles.length > 0) {
// Separate .gsd/ state file conflicts from real code conflicts.
// GSD state files (STATE.md, completed-units.json, auto.lock, etc.)
// diverge between branches during normal operation — always prefer the
// milestone branch version since it has the latest execution state.
const gsdConflicts = conflictedFiles.filter(f => f.startsWith(".gsd/"));
const codeConflicts = conflictedFiles.filter(f => !f.startsWith(".gsd/"));
// Auto-resolve .gsd/ conflicts by accepting the milestone branch version
if (gsdConflicts.length > 0) {
for (const gsdFile of gsdConflicts) {
try {
nativeCheckoutTheirs(originalBasePath_, [gsdFile]);
nativeAddPaths(originalBasePath_, [gsdFile]);
} catch {
// If checkout --theirs fails, try removing the file from the merge
// (it's a runtime file that shouldn't be committed anyway)
nativeRmForce(originalBasePath_, [gsdFile]);
}
}
// If there are still non-.gsd conflicts, escalate
if (codeConflicts.length > 0) {
throw new MergeConflictError(codeConflicts, "squash", milestoneBranch, mainBranch);
}
}
} catch (diffErr) {
if (diffErr instanceof MergeConflictError) throw diffErr;
// If there are still non-.gsd conflicts, escalate
if (codeConflicts.length > 0) {
throw new MergeConflictError(codeConflicts, "squash", milestoneBranch, mainBranch);
}
}
// No conflicts detected — possibly "already up to date", fall through to commit
}
// 8. Commit (handle nothing-to-commit gracefully)
let nothingToCommit = false;
try {
execFileSync("git", ["commit", "-m", commitMessage], {
cwd: originalBasePath_,
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf-8",
});
} catch (err: unknown) {
// execSync errors have stdout/stderr as properties -- check those for git's message
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")) {
nothingToCommit = true;
} else {
throw err;
}
}
const commitResult = nativeCommit(originalBasePath_, commitMessage);
const nothingToCommit = commitResult === null;
// 9. Auto-push if enabled
let pushed = false;
@ -413,11 +364,7 @@ export function mergeMilestoneToMain(
// 11. Delete milestone branch (after worktree removal so ref is unlocked)
try {
execSync(`git branch -D ${milestoneBranch}`, {
cwd: originalBasePath_,
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf-8",
});
nativeBranchDelete(originalBasePath_, milestoneBranch);
} catch {
// Best-effort
}

View file

@ -70,7 +70,7 @@ import { join } from "node:path";
import { sep as pathSep } from "node:path";
import { homedir } from "node:os";
import { readdirSync, readFileSync, existsSync, mkdirSync, writeFileSync, unlinkSync, statSync } from "node:fs";
import { execSync, execFileSync } from "node:child_process";
import { nativeIsRepo, nativeInit, nativeAddPaths, nativeCommit } from "./native-git-bridge.js";
import {
autoCommitCurrentBranch,
captureIntegrationBranch,
@ -81,7 +81,7 @@ import {
parseSliceBranch,
setActiveMilestoneId,
} from "./worktree.js";
import { GitServiceImpl, runGit } from "./git-service.js";
import { GitServiceImpl } from "./git-service.js";
import { getPriorSliceCompletionBlocker } from "./dispatch-guard.js";
import { formatGitError } from "./git-self-heal.js";
import {
@ -551,11 +551,9 @@ export async function startAuto(
}
// Ensure git repo exists — GSD needs it for worktree isolation
try {
execSync("git rev-parse --git-dir", { cwd: base, stdio: "pipe" });
} catch {
if (!nativeIsRepo(base)) {
const mainBranch = loadEffectiveGSDPreferences()?.preferences?.git?.main_branch || "main";
execFileSync("git", ["init", "-b", mainBranch], { cwd: base, stdio: "pipe" });
nativeInit(base, mainBranch);
}
// Ensure .gitignore has baseline patterns
@ -567,9 +565,8 @@ export async function startAuto(
if (!existsSync(gsdDir)) {
mkdirSync(join(gsdDir, "milestones"), { recursive: true });
try {
execSync("git add -A .gsd .gitignore && git commit -m 'chore: init gsd'", {
cwd: base, stdio: "pipe",
});
nativeAddPaths(base, [".gsd", ".gitignore"]);
nativeCommit(base, "chore: init gsd");
} catch { /* nothing to commit */ }
}

View file

@ -36,6 +36,7 @@ import { handleRemote } from "../remote-questions/remote-command.js";
import { handleHistory } from "./history.js";
import { handleUndo } from "./undo.js";
import { handleExport } from "./export.js";
import { nativeBranchList, nativeDetectMainBranch, nativeBranchListMerged, nativeBranchDelete, nativeForEachRef, nativeUpdateRef } from "./native-git-bridge.js";
function dispatchDoctorHeal(pi: ExtensionAPI, scope: string | undefined, reportText: string, structuredIssues: string): void {
const workflowPath = process.env.GSD_WORKFLOW_PATH ?? join(process.env.HOME ?? "~", ".pi", "GSD-WORKFLOW.md");
@ -877,12 +878,9 @@ async function handleDryRun(ctx: ExtensionCommandContext, basePath: string): Pro
// ─── Branch cleanup handler ──────────────────────────────────────────────────
async function handleCleanupBranches(ctx: ExtensionCommandContext, basePath: string): Promise<void> {
const { execFileSync } = await import("node:child_process");
let branches: string[];
try {
const output = execFileSync("git", ["branch", "--list", "gsd/*"], { cwd: basePath, timeout: 10000, encoding: "utf-8" });
branches = output.split("\n").map(b => b.trim().replace(/^\* /, "")).filter(Boolean);
branches = nativeBranchList(basePath, "gsd/*");
} catch {
ctx.ui.notify("No GSD branches found.", "info");
return;
@ -893,18 +891,11 @@ async function handleCleanupBranches(ctx: ExtensionCommandContext, basePath: str
return;
}
let mainBranch: string;
try {
mainBranch = execFileSync("git", ["symbolic-ref", "refs/remotes/origin/HEAD", "--short"], { cwd: basePath, timeout: 5000, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] })
.trim().replace("origin/", "");
} catch {
mainBranch = "main";
}
const mainBranch = nativeDetectMainBranch(basePath);
let merged: string[];
try {
const output = execFileSync("git", ["branch", "--merged", mainBranch, "--list", "gsd/*"], { cwd: basePath, timeout: 10000, encoding: "utf-8" });
merged = output.split("\n").map(b => b.trim()).filter(Boolean);
merged = nativeBranchListMerged(basePath, mainBranch, "gsd/*");
} catch {
merged = [];
}
@ -917,7 +908,7 @@ async function handleCleanupBranches(ctx: ExtensionCommandContext, basePath: str
let deleted = 0;
for (const branch of merged) {
try {
execFileSync("git", ["branch", "-d", branch], { cwd: basePath, timeout: 5000, stdio: "ignore" });
nativeBranchDelete(basePath, branch, false);
deleted++;
} catch { /* skip branches that can't be deleted */ }
}
@ -928,12 +919,9 @@ async function handleCleanupBranches(ctx: ExtensionCommandContext, basePath: str
// ─── Snapshot cleanup handler ─────────────────────────────────────────────────
async function handleCleanupSnapshots(ctx: ExtensionCommandContext, basePath: string): Promise<void> {
const { execFileSync } = await import("node:child_process");
let refs: string[];
try {
const output = execFileSync("git", ["for-each-ref", "refs/gsd/snapshots/", "--format=%(refname)"], { cwd: basePath, timeout: 10000, encoding: "utf-8" });
refs = output.split("\n").filter(Boolean);
refs = nativeForEachRef(basePath, "refs/gsd/snapshots/");
} catch {
ctx.ui.notify("No snapshot refs found.", "info");
return;
@ -957,7 +945,7 @@ async function handleCleanupSnapshots(ctx: ExtensionCommandContext, basePath: st
const sorted = labelRefs.sort();
for (const old of sorted.slice(0, -5)) {
try {
execFileSync("git", ["update-ref", "-d", old], { cwd: basePath, timeout: 5000, stdio: "ignore" });
nativeUpdateRef(basePath, old);
pruned++;
} catch { /* skip */ }
}

View file

@ -1,4 +1,3 @@
import { execSync } from "node:child_process";
import { existsSync, mkdirSync } from "node:fs";
import { join, sep } from "node:path";
@ -9,6 +8,7 @@ import { loadEffectiveGSDPreferences, type GSDPreferences } from "./preferences.
import { listWorktrees } from "./worktree-manager.js";
import { abortAndReset } from "./git-self-heal.js";
import { RUNTIME_EXCLUSION_PATHS } from "./git-service.js";
import { nativeIsRepo, nativeWorktreeRemove, nativeBranchList, nativeBranchDelete, nativeLsFiles, nativeRmCached } from "./native-git-bridge.js";
export type DoctorSeverity = "info" | "warning" | "error";
export type DoctorIssueCode =
@ -467,9 +467,7 @@ async function checkGitHealth(
shouldFix: (code: DoctorIssueCode) => boolean,
): Promise<void> {
// Degrade gracefully if not a git repo
try {
execSync("git rev-parse --git-dir", { cwd: basePath, stdio: "pipe" });
} catch {
if (!nativeIsRepo(basePath)) {
return; // Not a git repo — skip all git health checks
}
@ -516,7 +514,7 @@ async function checkGitHealth(
fixesApplied.push(`skipped removing worktree at ${wt.path} (is cwd)`);
} else {
try {
execSync(`git worktree remove --force "${wt.path}"`, { cwd: basePath, stdio: "pipe" });
nativeWorktreeRemove(basePath, wt.path, true);
fixesApplied.push(`removed orphaned worktree ${wt.path}`);
} catch {
fixesApplied.push(`failed to remove worktree ${wt.path}`);
@ -528,11 +526,8 @@ async function checkGitHealth(
// ── Stale milestone branches ─────────────────────────────────────────
try {
// Use unquoted glob — single quotes are not interpreted by cmd.exe on Windows,
// causing the pattern to match literally instead of as a glob.
const branchOutput = execSync("git branch --list milestone/*", { cwd: basePath, stdio: "pipe" }).toString().trim();
if (branchOutput) {
const branches = branchOutput.split("\n").map(b => b.trim().replace(/^\*\s*/, "")).filter(Boolean);
const branches = nativeBranchList(basePath, "milestone/*");
if (branches.length > 0) {
const worktreeBranches = new Set(milestoneWorktrees.map(wt => wt.branch));
for (const branch of branches) {
@ -557,7 +552,7 @@ async function checkGitHealth(
if (shouldFix("stale_milestone_branch")) {
try {
execSync(`git branch -D "${branch}"`, { cwd: basePath, stdio: "pipe" });
nativeBranchDelete(basePath, branch, true);
fixesApplied.push(`deleted stale branch ${branch}`);
} catch {
fixesApplied.push(`failed to delete branch ${branch}`);
@ -610,9 +605,9 @@ async function checkGitHealth(
const trackedPaths: string[] = [];
for (const exclusion of RUNTIME_EXCLUSION_PATHS) {
try {
const output = execSync(`git ls-files "${exclusion}"`, { cwd: basePath, stdio: "pipe" }).toString().trim();
if (output) {
trackedPaths.push(...output.split("\n").filter(Boolean));
const files = nativeLsFiles(basePath, exclusion);
if (files.length > 0) {
trackedPaths.push(...files);
}
} catch {
// Individual ls-files can fail — continue
@ -632,7 +627,7 @@ async function checkGitHealth(
if (shouldFix("tracked_runtime_files")) {
try {
for (const exclusion of RUNTIME_EXCLUSION_PATHS) {
execSync(`git rm --cached -r --ignore-unmatch "${exclusion}"`, { cwd: basePath, stdio: "pipe" });
nativeRmCached(basePath, [exclusion]);
}
fixesApplied.push(`untracked ${trackedPaths.length} runtime file(s)`);
} catch {
@ -646,13 +641,8 @@ async function checkGitHealth(
// ── Legacy slice branches ──────────────────────────────────────────────
try {
const sliceBranches = execSync('git branch --format="%(refname:short)" --list "gsd/*/*"', {
cwd: basePath,
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf-8",
}).trim();
if (sliceBranches) {
const branchList = sliceBranches.split("\n").map(b => b.trim()).filter(Boolean);
const branchList = nativeBranchList(basePath, "gsd/*/*");
if (branchList.length > 0) {
issues.push({
severity: "info",
code: "legacy_slice_branches",

View file

@ -10,10 +10,10 @@
* user-friendly messages suggesting `/gsd doctor`.
*/
import { execSync } from "node:child_process";
import { existsSync, unlinkSync } from "node:fs";
import { join } from "node:path";
import { MergeConflictError } from "./git-service.js";
import { nativeMergeAbort, nativeRebaseAbort, nativeResetHard } from "./native-git-bridge.js";
// Re-export for consumers
export { MergeConflictError };
@ -41,7 +41,7 @@ export function abortAndReset(cwd: string): AbortAndResetResult {
// Abort in-progress merge
if (existsSync(join(gitDir, "MERGE_HEAD"))) {
try {
execSync("git merge --abort", { cwd, stdio: "pipe" });
nativeMergeAbort(cwd);
cleaned.push("aborted merge");
} catch {
// merge --abort can fail if state is really broken; continue to reset
@ -63,7 +63,7 @@ export function abortAndReset(cwd: string): AbortAndResetResult {
// Abort in-progress rebase
if (existsSync(join(gitDir, "rebase-apply")) || existsSync(join(gitDir, "rebase-merge"))) {
try {
execSync("git rebase --abort", { cwd, stdio: "pipe" });
nativeRebaseAbort(cwd);
cleaned.push("aborted rebase");
} catch {
cleaned.push("rebase abort attempted (may have failed)");
@ -72,7 +72,7 @@ export function abortAndReset(cwd: string): AbortAndResetResult {
// Always hard-reset to HEAD
try {
execSync("git reset --hard HEAD", { cwd, stdio: "pipe" });
nativeResetHard(cwd);
if (cleaned.length > 0) {
cleaned.push("reset to HEAD");
}

View file

@ -10,7 +10,7 @@
import { execFileSync, execSync } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join, sep } from "node:path";
import { join } from "node:path";
import {
detectWorktreeName,
@ -21,6 +21,13 @@ import {
nativeDetectMainBranch,
nativeBranchExists,
nativeHasChanges,
nativeAddAll,
nativeResetPaths,
nativeHasStagedChanges,
nativeCommit,
nativeRmCached,
nativeUpdateRef,
nativeAddPaths,
} from "./native-git-bridge.js";
import { GSDError, GSD_MERGE_CONFLICT } from "./errors.js";
@ -172,10 +179,8 @@ export function writeIntegrationBranch(basePath: string, milestoneId: string, br
// Commit immediately so the metadata is persisted in git.
try {
runGit(basePath, ["add", metaFile]);
runGit(basePath, ["commit", "--no-verify", "-F", "-"], {
input: `chore(${milestoneId}): record integration branch`,
});
nativeAddPaths(basePath, [metaFile]);
nativeCommit(basePath, `chore(${milestoneId}): record integration branch`, { allowEmpty: false });
} catch {
// Non-fatal — file is on disk even if commit fails (e.g. nothing to commit
// because the file was already tracked with identical content)
@ -288,11 +293,11 @@ export class GitServiceImpl {
if (!this._runtimeFilesCleanedUp) {
let cleaned = false;
for (const exclusion of RUNTIME_EXCLUSION_PATHS) {
const result = this.git(["rm", "--cached", "-r", "--ignore-unmatch", exclusion], { allowFailure: true });
if (result && result.includes("rm '")) cleaned = true;
const removed = nativeRmCached(this.basePath, [exclusion]);
if (removed.length > 0) cleaned = true;
}
if (cleaned) {
this.git(["commit", "--no-verify", "-F", "-"], { input: "chore: untrack .gsd/ runtime files from git index" });
nativeCommit(this.basePath, "chore: untrack .gsd/ runtime files from git index", { allowEmpty: false });
}
this._runtimeFilesCleanedUp = true;
}
@ -307,10 +312,10 @@ export class GitServiceImpl {
//
// git reset HEAD silently succeeds when the path isn't staged, so no
// error handling is needed per-path.
this.git(["add", "-A"]);
nativeAddAll(this.basePath);
for (const exclusion of allExclusions) {
this.git(["reset", "HEAD", "--", exclusion], { allowFailure: true });
try { nativeResetPaths(this.basePath, [exclusion]); } catch { /* path not staged — ignore */ }
}
}
@ -326,13 +331,9 @@ export class GitServiceImpl {
this.smartStage();
// Check if anything was actually staged
const staged = this.git(["diff", "--cached", "--stat"], { allowFailure: true });
if (!staged && !opts.allowEmpty) return null;
if (!nativeHasStagedChanges(this.basePath) && !opts.allowEmpty) return null;
this.git(
["commit", "--no-verify", "-F", "-", ...(opts.allowEmpty ? ["--allow-empty"] : [])],
{ input: opts.message },
);
nativeCommit(this.basePath, opts.message, { allowEmpty: opts.allowEmpty ?? false });
return opts.message;
}
@ -350,11 +351,10 @@ export class GitServiceImpl {
// After smart staging, check if anything was actually staged
// (all changes might have been runtime files that got excluded)
const staged = this.git(["diff", "--cached", "--stat"], { allowFailure: true });
if (!staged) return null;
if (!nativeHasStagedChanges(this.basePath)) return null;
const message = `chore(${unitId}): auto-commit after ${unitType}`;
this.git(["commit", "--no-verify", "-F", "-"], { input: message });
nativeCommit(this.basePath, message, { allowEmpty: false });
return message;
}
@ -431,7 +431,7 @@ export class GitServiceImpl {
+ String(now.getSeconds()).padStart(2, "0");
const refPath = `refs/gsd/snapshots/${label}/${ts}`;
this.git(["update-ref", refPath, "HEAD"]);
nativeUpdateRef(this.basePath, refPath, "HEAD");
}
/**
@ -452,7 +452,7 @@ export class GitServiceImpl {
} else {
// Auto-detect: look for package.json with a test script
try {
const pkg = execFileSync("cat", ["package.json"], { cwd: this.basePath, encoding: "utf-8" });
const pkg = readFileSync(join(this.basePath, "package.json"), "utf-8");
const parsed = JSON.parse(pkg);
if (parsed.scripts?.test) {
command = "npm test";

View file

@ -8,7 +8,7 @@
import { join } from "node:path";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { execSync } from "node:child_process";
import { nativeRmCached } from "./native-git-bridge.js";
/**
* Patterns that are always correct regardless of project type.
@ -152,10 +152,7 @@ export function untrackRuntimeFiles(basePath: string): void {
// Use -r for directory patterns (trailing slash), strip the slash for the command
const target = pattern.endsWith("/") ? pattern.slice(0, -1) : pattern;
try {
execSync(`git rm -r --cached ${target}`, {
cwd: basePath,
stdio: ["ignore", "ignore", "ignore"],
});
nativeRmCached(basePath, [target]);
} catch {
// File not tracked or doesn't exist — expected, ignore
}

View file

@ -23,7 +23,7 @@ import {
import { randomInt } from "node:crypto";
import { join } from "node:path";
import { readFileSync, existsSync, mkdirSync, readdirSync, rmSync, unlinkSync } from "node:fs";
import { execSync, execFileSync } from "node:child_process";
import { nativeIsRepo, nativeInit, nativeAddPaths, nativeCommit } from "./native-git-bridge.js";
import { ensureGitignore, ensurePreferences, untrackRuntimeFiles } from "./gitignore.js";
import { loadEffectiveGSDPreferences } from "./preferences.js";
import { showConfirm } from "../shared/confirm-ui.js";
@ -704,11 +704,9 @@ export async function showSmartEntry(
const stepMode = options?.step;
// ── Ensure git repo exists — GSD needs it for worktree isolation ──────
try {
execSync("git rev-parse --git-dir", { cwd: basePath, stdio: "pipe" });
} catch {
if (!nativeIsRepo(basePath)) {
const mainBranch = loadEffectiveGSDPreferences()?.preferences?.git?.main_branch || "main";
execFileSync("git", ["init", "-b", mainBranch], { cwd: basePath, stdio: "pipe" });
nativeInit(basePath, mainBranch);
}
// ── Ensure .gitignore has baseline patterns ──────────────────────────
@ -724,10 +722,8 @@ export async function showSmartEntry(
// ── Create PREFERENCES.md template ────────────────────────────────
ensurePreferences(basePath);
try {
execSync("git add -A .gsd .gitignore && git commit -m 'chore: init gsd'", {
cwd: basePath,
stdio: "pipe",
});
nativeAddPaths(basePath, [".gsd", ".gitignore"]);
nativeCommit(basePath, "chore: init gsd");
} catch {
// nothing to commit — that's fine
}

View file

@ -1,11 +1,13 @@
// Native Git Bridge
// Provides fast READ-ONLY git operations backed by libgit2 via the Rust native module.
// Falls back to execSync git commands when the native module is unavailable.
// 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.
//
// Only READ operations are native — WRITE operations (commit, merge, checkout, push)
// remain as execSync calls in git-service.ts.
// Both READ and WRITE operations are native — push operations remain as
// execSync calls because git2 credential handling is too complex.
import { execFileSync } from "node:child_process";
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 = {
@ -15,7 +17,54 @@ const GIT_NO_PROMPT_ENV = {
GIT_SVN_ID: "",
};
// ─── 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;
@ -23,6 +72,43 @@ let nativeModule: {
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;
@ -44,6 +130,8 @@ function loadNative(): typeof nativeModule {
return nativeModule;
}
// ─── Fallback Helpers ──────────────────────────────────────────────────────
/** Run a git command via execFileSync. Returns trimmed stdout. */
function gitExec(basePath: string, args: string[], allowFailure = false): string {
try {
@ -59,6 +147,22 @@ function gitExec(basePath: string, args: string[], allowFailure = false): string
}
}
/** 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.
@ -77,10 +181,6 @@ export function nativeGetCurrentBranch(basePath: string): string {
* Detect the repo-level main branch (origin/HEAD main master current).
* Native: checks refs via libgit2.
* Fallback: `git symbolic-ref` + `git show-ref` chain.
*
* Note: milestone integration branch and worktree detection are handled
* by the caller (GitServiceImpl.getMainBranch) this only covers the
* repo-level default detection that spawned multiple git processes.
*/
export function nativeDetectMainBranch(basePath: string): string {
const native = loadNative();
@ -88,7 +188,6 @@ export function nativeDetectMainBranch(basePath: string): string {
return native.gitMainBranch(basePath);
}
// Fallback: same logic as GitServiceImpl.getMainBranch() repo-level detection
const symbolic = gitExec(basePath, ["symbolic-ref", "refs/remotes/origin/HEAD"], true);
if (symbolic) {
const match = symbolic.match(/refs\/remotes\/origin\/(.+)$/);
@ -173,9 +272,741 @@ export function nativeCommitCountBetween(basePath: string, fromRef: string, toRe
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,
};

View file

@ -19,8 +19,8 @@
*/
import { readFileSync, readdirSync, existsSync, statSync } from "node:fs";
import { execSync } from "node:child_process";
import { basename, join } from "node:path";
import { nativeWorkingTreeStatus, nativeDiffStat } from "./native-git-bridge.js";
// ─── Types ────────────────────────────────────────────────────────────────────
@ -210,11 +210,11 @@ export function extractTrace(entries: unknown[]): ExecutionTrace {
function getGitChanges(basePath: string): string | null {
try {
const status = execSync("git status --porcelain", { cwd: basePath, stdio: "pipe" }).toString().trim();
const status = nativeWorkingTreeStatus(basePath);
if (!status) return null;
const diffStat = execSync("git diff --stat HEAD 2>/dev/null || true", { cwd: basePath, stdio: "pipe" }).toString().trim();
const stagedStat = execSync("git diff --stat --cached HEAD 2>/dev/null || true", { cwd: basePath, stdio: "pipe" }).toString().trim();
const diffStat = nativeDiffStat(basePath, "HEAD", "WORKDIR").summary;
const stagedStat = nativeDiffStat(basePath, "HEAD", "INDEX").summary;
const parts: string[] = [];
if (status) parts.push(`Status:\n${status}`);

View file

@ -5,7 +5,7 @@
import type { ExtensionCommandContext, ExtensionAPI } from "@gsd/pi-coding-agent";
import { existsSync, readFileSync, writeFileSync, unlinkSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { execFileSync } from "node:child_process";
import { nativeRevertCommit, nativeRevertAbort } from "./native-git-bridge.js";
import { deriveState } from "./state.js";
import { invalidateAllCaches } from "./cache.js";
import { gsdRoot, resolveTasksDir, resolveSlicePath, buildTaskFileName } from "./paths.js";
@ -108,11 +108,11 @@ export async function handleUndo(args: string, ctx: ExtensionCommandContext, _pi
if (commits.length > 0) {
for (const sha of commits.reverse()) {
try {
execFileSync("git", ["revert", "--no-commit", sha], { cwd: basePath, timeout: 10000, stdio: "ignore" });
nativeRevertCommit(basePath, sha);
commitsReverted++;
} catch {
// Revert conflict or already reverted — skip
try { execFileSync("git", ["revert", "--abort"], { cwd: basePath, timeout: 5000, stdio: "ignore" }); } catch { /* no-op */ }
try { nativeRevertAbort(basePath); } catch { /* no-op */ }
break;
}
}

View file

@ -31,8 +31,8 @@ import {
} from "./worktree-manager.js";
import { inferCommitType } from "./git-service.js";
import type { FileLineStat } from "./worktree-manager.js";
import { execSync } from "node:child_process";
import { existsSync, realpathSync, readFileSync, readdirSync, rmSync, unlinkSync, utimesSync } from "node:fs";
import { nativeMergeAbort } from "./native-git-bridge.js";
import { join, resolve, sep } from "node:path";
/**
@ -691,7 +691,7 @@ async function handleMerge(
if (isConflict) {
// Abort the failed merge so the working tree is clean for LLM retry
try {
execSync("git merge --abort", { cwd: basePath, stdio: "pipe" });
nativeMergeAbort(basePath);
} catch { /* already clean */ }
ctx.ui.notify(

View file

@ -16,8 +16,24 @@
*/
import { existsSync, mkdirSync, realpathSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { join, resolve, sep } from "node:path";
import {
nativeBranchDelete,
nativeBranchExists,
nativeBranchForceReset,
nativeCommit,
nativeDetectMainBranch,
nativeDiffContent,
nativeDiffNameStatus,
nativeDiffNumstat,
nativeGetCurrentBranch,
nativeLogOneline,
nativeMergeSquash,
nativeWorktreeAdd,
nativeWorktreeList,
nativeWorktreePrune,
nativeWorktreeRemove,
} from "./native-git-bridge.js";
// ─── Types ─────────────────────────────────────────────────────────────────
@ -44,43 +60,7 @@ export interface WorktreeDiffSummary {
removed: string[];
}
// ─── Git Helpers ───────────────────────────────────────────────────────────
/** 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: "",
};
/**
* Strip git-svn noise from error messages.
* Some systems have a buggy git-svn Perl module that emits warnings
* on every git invocation. See #404.
*/
function filterGitSvnNoise(message: string): string {
return message
.replace(/Duplicate specification "[^"]*" for option "[^"]*"\n?/g, "")
.replace(/Unable to determine upstream SVN information from .*\n?/g, "")
.replace(/Perhaps the repository is empty\. at .*git-svn.*\n?/g, "")
.trim();
}
function runGit(cwd: string, args: string[], opts: { allowFailure?: boolean } = {}): string {
try {
return execFileSync("git", args, {
cwd,
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf-8",
env: GIT_NO_PROMPT_ENV,
}).trim();
} catch (error) {
if (opts.allowFailure) return "";
const message = error instanceof Error ? error.message : String(error);
throw new Error(`git ${args.join(" ")} failed in ${cwd}: ${filterGitSvnNoise(message)}`);
}
}
// ─── Path Helpers ──────────────────────────────────────────────────────────
function normalizePathForComparison(path: string): string {
const normalized = path
@ -91,18 +71,9 @@ function normalizePathForComparison(path: string): string {
}
export function getMainBranch(basePath: string): string {
const symbolic = runGit(basePath, ["symbolic-ref", "refs/remotes/origin/HEAD"], { allowFailure: true });
if (symbolic) {
const match = symbolic.match(/refs\/remotes\/origin\/(.+)$/);
if (match) return match[1]!;
}
if (runGit(basePath, ["show-ref", "--verify", "refs/heads/main"], { allowFailure: true })) return "main";
if (runGit(basePath, ["show-ref", "--verify", "refs/heads/master"], { allowFailure: true })) return "master";
return runGit(basePath, ["branch", "--show-current"]);
return nativeDetectMainBranch(basePath);
}
// ─── Path Helpers ──────────────────────────────────────────────────────────
export function worktreesDir(basePath: string): string {
return join(basePath, ".gsd", "worktrees");
}
@ -141,17 +112,16 @@ export function createWorktree(basePath: string, name: string, opts: { branch?:
mkdirSync(wtDir, { recursive: true });
// Prune any stale worktree entries from a previous removal
runGit(basePath, ["worktree", "prune"], { allowFailure: true });
nativeWorktreePrune(basePath);
// Check if the branch already exists (leftover from a previous worktree)
const branchExists = runGit(basePath, ["show-ref", "--verify", `refs/heads/${branch}`], { allowFailure: true });
const mainBranch = getMainBranch(basePath);
const branchAlreadyExists = nativeBranchExists(basePath, branch);
const mainBranch = nativeDetectMainBranch(basePath);
if (branchExists) {
if (branchAlreadyExists) {
// Check if the branch is actively used by an existing worktree.
// `git branch -f` will fail if the branch is checked out somewhere.
const worktreeUsing = runGit(basePath, ["worktree", "list", "--porcelain"], { allowFailure: true });
const branchInUse = worktreeUsing.includes(`branch refs/heads/${branch}`);
const worktreeEntries = nativeWorktreeList(basePath);
const branchInUse = worktreeEntries.some(entry => entry.branch === branch);
if (branchInUse) {
throw new Error(
@ -161,10 +131,10 @@ export function createWorktree(basePath: string, name: string, opts: { branch?:
}
// Reset the stale branch to current main, then attach worktree to it
runGit(basePath, ["branch", "-f", branch, mainBranch]);
runGit(basePath, ["worktree", "add", wtPath, branch]);
nativeBranchForceReset(basePath, branch, mainBranch);
nativeWorktreeAdd(basePath, wtPath, branch);
} else {
runGit(basePath, ["worktree", "add", "-b", branch, wtPath, mainBranch]);
nativeWorktreeAdd(basePath, wtPath, branch, true, mainBranch);
}
return {
@ -177,7 +147,7 @@ export function createWorktree(basePath: string, name: string, opts: { branch?:
/**
* List all GSD-managed worktrees.
* Parses `git worktree list` and filters to those under .gsd/worktrees/.
* Uses native worktree list and filters to those under .gsd/worktrees/.
*/
export function listWorktrees(basePath: string): WorktreeInfo[] {
const baseVariants = [resolve(basePath)];
@ -197,27 +167,27 @@ export function listWorktrees(basePath: string): WorktreeInfo[] {
seenRoots.add(root.normalized);
return true;
});
const rawList = runGit(basePath, ["worktree", "list", "--porcelain"]);
if (!rawList.trim()) return [];
const entries = nativeWorktreeList(basePath);
if (!entries.length) return [];
const worktrees: WorktreeInfo[] = [];
const entries = rawList.replaceAll("\r\n", "\n").split("\n\n").filter(Boolean);
for (const entry of entries) {
const lines = entry.split("\n");
const wtLine = lines.find(l => l.startsWith("worktree "));
const branchLine = lines.find(l => l.startsWith("branch "));
if (entry.isBare) continue;
if (!wtLine || !branchLine) continue;
const entryPath = entry.path;
const branch = entry.branch;
if (!branch) continue;
const entryPath = wtLine.replace("worktree ", "");
const branch = branchLine.replace("branch refs/heads/", "");
const branchWorktreeName = branch.startsWith("worktree/")
? branch.slice("worktree/".length)
: branch.startsWith("milestone/")
? branch.slice("milestone/".length)
: null;
const entryVariants = [resolve(entryPath)];
if (existsSync(entryPath)) {
entryVariants.push(realpathSync(entryPath));
@ -271,7 +241,7 @@ export function removeWorktree(
const wtPath = worktreePath(basePath, name);
const resolvedWtPath = existsSync(wtPath) ? realpathSync(wtPath) : wtPath;
const branch = opts.branch ?? worktreeBranchName(name);
const { deleteBranch = true, force = false } = opts;
const { deleteBranch = true, force = true } = opts;
// If we're inside the worktree, move out first — git can't remove an in-use directory
const cwd = process.cwd();
@ -281,26 +251,26 @@ export function removeWorktree(
}
if (!existsSync(wtPath)) {
runGit(basePath, ["worktree", "prune"], { allowFailure: true });
nativeWorktreePrune(basePath);
if (deleteBranch) {
runGit(basePath, ["branch", "-D", branch], { allowFailure: true });
try { nativeBranchDelete(basePath, branch, true); } catch { /* branch may not exist */ }
}
return;
}
// Force-remove to handle dirty worktrees
runGit(basePath, ["worktree", "remove", "--force", wtPath], { allowFailure: true });
// Remove worktree (force if requested, to handle dirty worktrees)
try { nativeWorktreeRemove(basePath, wtPath, force); } catch { /* may fail */ }
// If the directory is still there (e.g. locked), try harder
// If the directory is still there (e.g. locked), try harder with force
if (existsSync(wtPath)) {
runGit(basePath, ["worktree", "remove", "--force", "--force", wtPath], { allowFailure: true });
try { nativeWorktreeRemove(basePath, wtPath, true); } catch { /* may fail */ }
}
// Prune stale entries so git knows the worktree is gone
runGit(basePath, ["worktree", "prune"], { allowFailure: true });
nativeWorktreePrune(basePath);
if (deleteBranch) {
runGit(basePath, ["branch", "-D", branch], { allowFailure: true });
try { nativeBranchDelete(basePath, branch, true); } catch { /* branch may not exist */ }
}
}
@ -314,27 +284,22 @@ function shouldSkipPath(filePath: string): boolean {
return false;
}
function parseDiffNameStatus(diffOutput: string): WorktreeDiffSummary {
function parseDiffNameStatus(entries: { status: string; path: string }[]): WorktreeDiffSummary {
const added: string[] = [];
const modified: string[] = [];
const removed: string[] = [];
if (!diffOutput.trim()) return { added, modified, removed };
for (const line of diffOutput.split("\n").filter(Boolean)) {
const [status, ...pathParts] = line.split("\t");
const filePath = pathParts.join("\t");
if (shouldSkipPath(filePath)) continue;
for (const { status, path } of entries) {
if (shouldSkipPath(path)) continue;
switch (status) {
case "A": added.push(filePath); break;
case "M": modified.push(filePath); break;
case "D": removed.push(filePath); break;
case "A": added.push(path); break;
case "M": modified.push(path); break;
case "D": removed.push(path); break;
default:
// Renames, copies — treat as modified
if (status?.startsWith("R") || status?.startsWith("C")) {
modified.push(filePath);
modified.push(path);
}
}
}
@ -348,19 +313,13 @@ function parseDiffNameStatus(diffOutput: string): WorktreeDiffSummary {
*/
export function diffWorktreeGSD(basePath: string, name: string): WorktreeDiffSummary {
const branch = worktreeBranchName(name);
const mainBranch = getMainBranch(basePath);
const mainBranch = nativeDetectMainBranch(basePath);
const diffOutput = runGit(basePath, [
"diff", "--name-status", `${mainBranch}...${branch}`, "--", ".gsd/",
], { allowFailure: true });
const entries = nativeDiffNameStatus(basePath, mainBranch, branch, ".gsd/", true);
return parseDiffNameStatus(diffOutput);
return parseDiffNameStatus(entries);
}
/**
* Diff ALL files between the worktree branch and main branch.
* Returns a summary of added, modified, and removed files across the entire repo.
*/
/**
* Diff ALL files between the worktree branch and main branch.
* Uses direct diff (no merge-base) to show what will actually change
@ -369,13 +328,11 @@ export function diffWorktreeGSD(basePath: string, name: string): WorktreeDiffSum
*/
export function diffWorktreeAll(basePath: string, name: string): WorktreeDiffSummary {
const branch = worktreeBranchName(name);
const mainBranch = getMainBranch(basePath);
const mainBranch = nativeDetectMainBranch(basePath);
const diffOutput = runGit(basePath, [
"diff", "--name-status", mainBranch, branch,
], { allowFailure: true });
const entries = nativeDiffNameStatus(basePath, mainBranch, branch);
return parseDiffNameStatus(diffOutput);
return parseDiffNameStatus(entries);
}
/**
@ -384,22 +341,14 @@ export function diffWorktreeAll(basePath: string, name: string): WorktreeDiffSum
*/
export function diffWorktreeNumstat(basePath: string, name: string): FileLineStat[] {
const branch = worktreeBranchName(name);
const mainBranch = getMainBranch(basePath);
const mainBranch = nativeDetectMainBranch(basePath);
const raw = runGit(basePath, [
"diff", "--numstat", mainBranch, branch,
], { allowFailure: true });
if (!raw.trim()) return [];
const rawStats = nativeDiffNumstat(basePath, mainBranch, branch);
const stats: FileLineStat[] = [];
for (const line of raw.split("\n").filter(Boolean)) {
const [a, r, ...pathParts] = line.split("\t");
const file = pathParts.join("\t");
if (shouldSkipPath(file)) continue;
const added = a === "-" ? 0 : parseInt(a ?? "0", 10);
const removed = r === "-" ? 0 : parseInt(r ?? "0", 10);
stats.push({ file, added, removed });
for (const entry of rawStats) {
if (shouldSkipPath(entry.path)) continue;
stats.push({ file: entry.path, added: entry.added, removed: entry.removed });
}
return stats;
}
@ -410,11 +359,9 @@ export function diffWorktreeNumstat(basePath: string, name: string): FileLineSta
*/
export function getWorktreeGSDDiff(basePath: string, name: string): string {
const branch = worktreeBranchName(name);
const mainBranch = getMainBranch(basePath);
const mainBranch = nativeDetectMainBranch(basePath);
return runGit(basePath, [
"diff", `${mainBranch}...${branch}`, "--", ".gsd/",
], { allowFailure: true });
return nativeDiffContent(basePath, mainBranch, branch, ".gsd/", undefined, true);
}
/**
@ -423,13 +370,9 @@ export function getWorktreeGSDDiff(basePath: string, name: string): string {
*/
export function getWorktreeCodeDiff(basePath: string, name: string): string {
const branch = worktreeBranchName(name);
const mainBranch = getMainBranch(basePath);
const mainBranch = nativeDetectMainBranch(basePath);
// Get full diff, then exclude .gsd/ paths
// We use pathspec magic to exclude .gsd/
return runGit(basePath, [
"diff", `${mainBranch}...${branch}`, "--", ".", ":(exclude).gsd/",
], { allowFailure: true });
return nativeDiffContent(basePath, mainBranch, branch, undefined, ".gsd/", true);
}
/**
@ -437,11 +380,11 @@ export function getWorktreeCodeDiff(basePath: string, name: string): string {
*/
export function getWorktreeLog(basePath: string, name: string): string {
const branch = worktreeBranchName(name);
const mainBranch = getMainBranch(basePath);
const mainBranch = nativeDetectMainBranch(basePath);
return runGit(basePath, [
"log", "--oneline", `${mainBranch}..${branch}`,
], { allowFailure: true });
const entries = nativeLogOneline(basePath, mainBranch, branch);
return entries.map(e => `${e.sha} ${e.message}`).join("\n");
}
/**
@ -451,15 +394,19 @@ export function getWorktreeLog(basePath: string, name: string): string {
*/
export function mergeWorktreeToMain(basePath: string, name: string, commitMessage: string): string {
const branch = worktreeBranchName(name);
const mainBranch = getMainBranch(basePath);
const current = runGit(basePath, ["branch", "--show-current"]);
const mainBranch = nativeDetectMainBranch(basePath);
const current = nativeGetCurrentBranch(basePath);
if (current !== mainBranch) {
throw new Error(`Must be on ${mainBranch} to merge. Currently on ${current}.`);
}
runGit(basePath, ["merge", "--squash", branch]);
runGit(basePath, ["commit", "-m", commitMessage]);
const result = nativeMergeSquash(basePath, branch);
if (!result.success) {
throw new Error(`Merge conflicts detected in: ${result.conflicts.join(", ")}`);
}
nativeCommit(basePath, commitMessage);
return commitMessage;
}