From 343a43f028009249ee3f09285fa1702bb339a130 Mon Sep 17 00:00:00 2001 From: Flux Labs Date: Sun, 15 Mar 2026 21:02:10 -0500 Subject: [PATCH] feat: move git operations to Rust via git2 crate (#572) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .plans/issue-524-git2-migration.md | 282 +++ native/crates/engine/src/git.rs | 1618 ++++++++++++++++- src/resources/extensions/gsd/auto-recovery.ts | 46 +- .../extensions/gsd/auto-supervisor.ts | 9 +- src/resources/extensions/gsd/auto-worktree.ts | 157 +- src/resources/extensions/gsd/auto.ts | 15 +- src/resources/extensions/gsd/commands.ts | 26 +- src/resources/extensions/gsd/doctor.ts | 34 +- src/resources/extensions/gsd/git-self-heal.ts | 8 +- src/resources/extensions/gsd/git-service.ts | 42 +- src/resources/extensions/gsd/gitignore.ts | 7 +- src/resources/extensions/gsd/guided-flow.ts | 14 +- .../extensions/gsd/native-git-bridge.ts | 851 ++++++++- .../extensions/gsd/session-forensics.ts | 8 +- src/resources/extensions/gsd/undo.ts | 6 +- .../extensions/gsd/worktree-command.ts | 4 +- .../extensions/gsd/worktree-manager.ts | 215 +-- 17 files changed, 2876 insertions(+), 466 deletions(-) create mode 100644 .plans/issue-524-git2-migration.md diff --git a/.plans/issue-524-git2-migration.md b/.plans/issue-524-git2-migration.md new file mode 100644 index 000000000..40f2d2352 --- /dev/null +++ b/.plans/issue-524-git2-migration.md @@ -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` +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` +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` +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` +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` +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` +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` +Replaces: `git ls-files ""` (doctor.ts, 1 call) +Implementation: Read index, filter by pathspec + +### 1.12 — `git_for_each_ref(repo_path, prefix) -> Vec` +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` +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 ` (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) -> void` +Replaces: `git add -- ` (auto-worktree.ts, git-service.ts, 3 calls) +Implementation: Add specific paths to index + +### 2.4 — `git_reset_paths(repo_path, paths: Vec) -> void` +Replaces: `git reset HEAD -- ` (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 `, `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 ` (auto-worktree.ts, 1 call) +Implementation: Set HEAD + checkout tree + +### 2.7 — `git_checkout_theirs(repo_path, paths: Vec) -> void` +Replaces: `git checkout --theirs -- ` (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 ` (auto-worktree.ts, worktree-manager.ts, 3 calls) +Returns: `{ success: bool, conflicts: Vec }` +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 ` (5 calls across files) +Implementation: `repo.find_branch().delete()` + +### 2.13 — `git_branch_force_reset(repo_path, branch, target) -> void` +Replaces: `git branch -f ` (worktree-manager.ts, 1 call) + +### 2.14 — `git_rm_cached(repo_path, paths: Vec, recursive: bool) -> Vec` +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) -> void` +Replaces: `git rm --force -- ` (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 ` (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 HEAD` and `git update-ref -d ` (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 ` — 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 diff --git a/native/crates/engine/src/git.rs b/native/crates/engine/src/git.rs index 6012a53ae..a37d0c2ef 100644 --- a/native/crates/engine/src/git.rs +++ b/native/crates/engine/src/git.rs @@ -1,14 +1,21 @@ //! Native git operations via libgit2. //! -//! Provides fast READ-ONLY git queries for the GSD dispatch hotpath, -//! eliminating the need to spawn 25-40 `git` child processes per dispatch. +//! Provides high-performance git operations for GSD, eliminating the need +//! to spawn `git` child processes via execSync. Both read and write +//! operations are implemented natively. //! -//! WRITE operations (commit, merge, checkout, push) remain as execSync -//! calls in TypeScript — only status queries are native. +//! All functions have TypeScript fallbacks in `native-git-bridge.ts` for +//! environments where the native module is unavailable. -use git2::{Repository, StatusOptions}; +use git2::{ + build::CheckoutBuilder, BranchType, Delta, DiffOptions, IndexAddOption, MergeOptions, + ObjectType, Repository, ResetType, Sort, StatusOptions, +}; use napi::bindgen_prelude::*; use napi_derive::napi; +use std::path::Path; + +// ─── Helpers ──────────────────────────────────────────────────────────────── /// Open a git repository at the given path. fn open_repo(repo_path: &str) -> Result { @@ -20,17 +27,141 @@ fn open_repo(repo_path: &str) -> Result { }) } +/// Convert a git2 error to a napi error with context. +fn git_err(context: &str, e: git2::Error) -> Error { + Error::new(Status::GenericFailure, format!("{context}: {e}")) +} + +/// Validate that a file path stays within the repository boundary. +/// Prevents path traversal attacks via patterns like `../../etc/passwd`. +fn validate_path_within_repo(repo_path: &str, file_path: &str) -> Result { + let repo_dir = std::fs::canonicalize(repo_path).map_err(|e| { + Error::new(Status::GenericFailure, format!("Failed to canonicalize repo path '{repo_path}': {e}")) + })?; + let full_path = repo_dir.join(file_path); + let canonical = if full_path.exists() { + std::fs::canonicalize(&full_path).map_err(|e| { + Error::new(Status::GenericFailure, format!("Failed to canonicalize path '{file_path}': {e}")) + })? + } else if let Some(parent) = full_path.parent() { + if parent.exists() { + let cp = std::fs::canonicalize(parent).map_err(|e| { + Error::new(Status::GenericFailure, format!("Failed to canonicalize parent of '{file_path}': {e}")) + })?; + cp.join(full_path.file_name().unwrap_or_default()) + } else { + full_path.clone() + } + } else { + full_path.clone() + }; + if !canonical.starts_with(&repo_dir) { + return Err(Error::new(Status::GenericFailure, format!("Path '{file_path}' escapes repository boundary"))); + } + Ok(canonical) +} + +/// Resolve a ref string to an Oid. Supports branch names, tags, HEAD, etc. +fn resolve_ref(repo: &Repository, refspec: &str) -> Result { + repo.revparse_single(refspec) + .map(|obj| obj.id()) + .map_err(|e| git_err(&format!("Failed to resolve ref '{refspec}'"), e)) +} + +/// Get the tree for a given ref. +fn ref_tree<'a>(repo: &'a Repository, refspec: &str) -> Result> { + let obj = repo + .revparse_single(refspec) + .map_err(|e| git_err(&format!("Failed to resolve ref '{refspec}'"), e))?; + obj.peel_to_tree() + .map_err(|e| git_err(&format!("Failed to peel '{refspec}' to tree"), e)) +} + +/// Find the merge base between two refs (for three-dot diff semantics). +fn merge_base_tree<'a>( + repo: &'a Repository, + from_ref: &str, + to_ref: &str, +) -> Result> { + let from_oid = resolve_ref(repo, from_ref)?; + let to_oid = resolve_ref(repo, to_ref)?; + let base_oid = repo + .merge_base(from_oid, to_oid) + .map_err(|e| git_err("Failed to find merge base", e))?; + let base_commit = repo + .find_commit(base_oid) + .map_err(|e| git_err("Failed to find merge base commit", e))?; + base_commit + .tree() + .map_err(|e| git_err("Failed to get merge base tree", e)) +} + +// ─── NAPI Return Types ───────────────────────────────────────────────────── + +#[napi(object)] +pub struct GitDiffStat { + #[napi(js_name = "filesChanged")] + pub files_changed: u32, + pub insertions: u32, + pub deletions: u32, + pub summary: String, +} + +#[napi(object)] +pub struct GitNameStatus { + pub status: String, + pub path: String, +} + +#[napi(object)] +pub struct GitNumstat { + pub added: u32, + pub removed: u32, + pub path: String, +} + +#[napi(object)] +pub struct GitLogEntry { + pub sha: String, + pub message: String, +} + +#[napi(object)] +pub struct GitWorktreeEntry { + pub path: String, + pub branch: String, + #[napi(js_name = "isBare")] + pub is_bare: bool, +} + +#[napi(object)] +pub struct GitBatchInfo { + pub branch: String, + #[napi(js_name = "hasChanges")] + pub has_changes: bool, + pub status: String, + #[napi(js_name = "stagedCount")] + pub staged_count: u32, + #[napi(js_name = "unstagedCount")] + pub unstaged_count: u32, +} + +#[napi(object)] +pub struct GitMergeResult { + pub success: bool, + pub conflicts: Vec, +} + +// ─── Existing Read Functions (unchanged) ──────────────────────────────────── + /// Get the current branch name (HEAD symbolic ref). /// Returns None if HEAD is detached. #[napi] pub fn git_current_branch(repo_path: String) -> Result> { let repo = open_repo(&repo_path)?; - let head = repo.head().map_err(|e| { - Error::new( - Status::GenericFailure, - format!("Failed to read HEAD: {e}"), - ) - })?; + let head = repo + .head() + .map_err(|e| git_err("Failed to read HEAD", e))?; if head.is_branch() { Ok(head.shorthand().map(String::from)) @@ -42,14 +173,10 @@ pub fn git_current_branch(repo_path: String) -> Result> { /// Detect the main/integration branch for a repository. /// /// Resolution order: -/// 1. refs/remotes/origin/HEAD → extract branch name -/// 2. refs/heads/main exists → "main" -/// 3. refs/heads/master exists → "master" +/// 1. refs/remotes/origin/HEAD -> extract branch name +/// 2. refs/heads/main exists -> "main" +/// 3. refs/heads/master exists -> "master" /// 4. Fall back to current branch -/// -/// Note: milestone integration branch and worktree detection are handled -/// in TypeScript — this function covers the repo-level default detection -/// that previously spawned 4 `git show-ref` / `git symbolic-ref` calls. #[napi] pub fn git_main_branch(repo_path: String) -> Result { let repo = open_repo(&repo_path)?; @@ -65,24 +192,17 @@ pub fn git_main_branch(repo_path: String) -> Result { } } - // Check refs/heads/main if repo.find_reference("refs/heads/main").is_ok() { return Ok("main".to_string()); } - // Check refs/heads/master if repo.find_reference("refs/heads/master").is_ok() { return Ok("master".to_string()); } - // Fall back to current branch - let head = repo.head().map_err(|e| { - Error::new( - Status::GenericFailure, - format!("Failed to read HEAD: {e}"), - ) - })?; - + let head = repo + .head() + .map_err(|e| git_err("Failed to read HEAD", e))?; Ok(head.shorthand().unwrap_or("HEAD").to_string()) } @@ -99,13 +219,9 @@ pub fn git_branch_exists(repo_path: String, branch: String) -> Result { #[napi] pub fn git_has_merge_conflicts(repo_path: String) -> Result { let repo = open_repo(&repo_path)?; - let index = repo.index().map_err(|e| { - Error::new( - Status::GenericFailure, - format!("Failed to read index: {e}"), - ) - })?; - + let index = repo + .index() + .map_err(|e| git_err("Failed to read index", e))?; Ok(index.has_conflicts()) } @@ -115,15 +231,11 @@ pub fn git_has_merge_conflicts(repo_path: String) -> Result { pub fn git_working_tree_status(repo_path: String) -> Result { let repo = open_repo(&repo_path)?; let mut opts = StatusOptions::new(); - opts.include_untracked(true) - .recurse_untracked_dirs(true); + opts.include_untracked(true).recurse_untracked_dirs(true); - let statuses = repo.statuses(Some(&mut opts)).map_err(|e| { - Error::new( - Status::GenericFailure, - format!("Failed to get status: {e}"), - ) - })?; + let statuses = repo + .statuses(Some(&mut opts)) + .map_err(|e| git_err("Failed to get status", e))?; let mut lines = Vec::with_capacity(statuses.len()); for entry in statuses.iter() { @@ -171,12 +283,9 @@ pub fn git_has_changes(repo_path: String) -> Result { let mut opts = StatusOptions::new(); opts.include_untracked(true); - let statuses = repo.statuses(Some(&mut opts)).map_err(|e| { - Error::new( - Status::GenericFailure, - format!("Failed to get status: {e}"), - ) - })?; + let statuses = repo + .statuses(Some(&mut opts)) + .map_err(|e| git_err("Failed to get status", e))?; Ok(!statuses.is_empty()) } @@ -190,47 +299,1378 @@ pub fn git_commit_count_between( ) -> Result { let repo = open_repo(&repo_path)?; - let from_oid = repo - .revparse_single(&from_ref) - .map_err(|e| { - Error::new( - Status::GenericFailure, - format!("Failed to resolve ref '{from_ref}': {e}"), - ) - })? - .id(); + let from_oid = resolve_ref(&repo, &from_ref)?; + let to_oid = resolve_ref(&repo, &to_ref)?; - let to_oid = repo - .revparse_single(&to_ref) - .map_err(|e| { - Error::new( - Status::GenericFailure, - format!("Failed to resolve ref '{to_ref}': {e}"), - ) - })? - .id(); + let mut revwalk = repo + .revwalk() + .map_err(|e| git_err("Failed to create revwalk", e))?; - let mut revwalk = repo.revwalk().map_err(|e| { - Error::new( - Status::GenericFailure, - format!("Failed to create revwalk: {e}"), - ) - })?; + revwalk + .push(to_oid) + .map_err(|e| git_err("Failed to push to_ref", e))?; + revwalk + .hide(from_oid) + .map_err(|e| git_err("Failed to hide from_ref", e))?; - revwalk.push(to_oid).map_err(|e| { - Error::new( - Status::GenericFailure, - format!("Failed to push to_ref: {e}"), - ) - })?; - - revwalk.hide(from_oid).map_err(|e| { - Error::new( - Status::GenericFailure, - format!("Failed to hide from_ref: {e}"), - ) - })?; - - let count = revwalk.count() as u32; - Ok(count) + Ok(revwalk.count() as u32) +} + +// ─── New Read Functions ───────────────────────────────────────────────────── + +/// Check if a path is inside a git repository. +/// Replaces: `git rev-parse --git-dir` +#[napi] +pub fn git_is_repo(path: String) -> bool { + Repository::open(&path).is_ok() +} + +/// Check if there are any staged changes (index differs from HEAD). +/// Replaces: `git diff --cached --stat` check +#[napi] +pub fn git_has_staged_changes(repo_path: String) -> Result { + let repo = open_repo(&repo_path)?; + + // Get HEAD tree (may not exist for initial commit) + let head_tree = match repo.head() { + Ok(head) => { + let commit = head + .peel_to_commit() + .map_err(|e| git_err("Failed to peel HEAD to commit", e))?; + Some( + commit + .tree() + .map_err(|e| git_err("Failed to get HEAD tree", e))?, + ) + } + Err(_) => None, // No commits yet — everything in index is "staged" + }; + + let diff = repo + .diff_tree_to_index(head_tree.as_ref(), None, None) + .map_err(|e| git_err("Failed to diff tree to index", e))?; + + Ok(diff.deltas().len() > 0) +} + +/// Get diff statistics between two refs, or between HEAD and working tree. +/// When `from_ref` is "HEAD" and `to_ref` is "WORKDIR", diffs working tree vs HEAD. +/// When `from_ref` is "HEAD" and `to_ref` is "INDEX", diffs index vs HEAD (staged). +/// Replaces: `git diff --stat HEAD`, `git diff --stat --cached HEAD` +#[napi] +pub fn git_diff_stat( + repo_path: String, + from_ref: String, + to_ref: String, +) -> Result { + let repo = open_repo(&repo_path)?; + + let diff = match (from_ref.as_str(), to_ref.as_str()) { + ("HEAD", "WORKDIR") => { + let head_tree = match repo.head() { + Ok(head) => Some( + head.peel_to_tree() + .map_err(|e| git_err("Failed to peel HEAD to tree", e))?, + ), + Err(_) => None, + }; + repo.diff_tree_to_workdir_with_index(head_tree.as_ref(), None) + .map_err(|e| git_err("Failed to diff", e))? + } + ("HEAD", "INDEX") => { + let head_tree = match repo.head() { + Ok(head) => Some( + head.peel_to_tree() + .map_err(|e| git_err("Failed to peel HEAD to tree", e))?, + ), + Err(_) => None, + }; + repo.diff_tree_to_index(head_tree.as_ref(), None, None) + .map_err(|e| git_err("Failed to diff", e))? + } + _ => { + let from_tree = ref_tree(&repo, &from_ref)?; + let to_tree = ref_tree(&repo, &to_ref)?; + repo.diff_tree_to_tree(Some(&from_tree), Some(&to_tree), None) + .map_err(|e| git_err("Failed to diff", e))? + } + }; + + let stats = diff + .stats() + .map_err(|e| git_err("Failed to get diff stats", e))?; + + let summary = stats + .to_buf(git2::DiffStatsFormat::FULL, 80) + .map_err(|e| git_err("Failed to format diff stats", e))? + .as_str() + .unwrap_or("") + .to_string(); + + Ok(GitDiffStat { + files_changed: stats.files_changed() as u32, + insertions: stats.insertions() as u32, + deletions: stats.deletions() as u32, + summary, + }) +} + +/// Get name-status diff between two refs with optional pathspec filter. +/// `use_merge_base`: if true, uses three-dot semantics (diff from merge base). +/// Replaces: `git diff --name-status main...branch -- .gsd/` +#[napi] +pub fn git_diff_name_status( + repo_path: String, + from_ref: String, + to_ref: String, + pathspec: Option, + use_merge_base: Option, +) -> Result> { + let repo = open_repo(&repo_path)?; + + let mut diff_opts = DiffOptions::new(); + if let Some(ref ps) = pathspec { + diff_opts.pathspec(ps); + } + + let from_tree = if use_merge_base.unwrap_or(false) { + merge_base_tree(&repo, &from_ref, &to_ref)? + } else { + ref_tree(&repo, &from_ref)? + }; + let to_tree = ref_tree(&repo, &to_ref)?; + + let diff = repo + .diff_tree_to_tree(Some(&from_tree), Some(&to_tree), Some(&mut diff_opts)) + .map_err(|e| git_err("Failed to diff trees", e))?; + + let mut results = Vec::with_capacity(diff.deltas().len()); + for delta in diff.deltas() { + let status_char = match delta.status() { + Delta::Added => "A", + Delta::Deleted => "D", + Delta::Modified => "M", + Delta::Renamed => "R", + Delta::Copied => "C", + Delta::Typechange => "T", + _ => continue, + }; + let path = delta + .new_file() + .path() + .or_else(|| delta.old_file().path()) + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default(); + + results.push(GitNameStatus { + status: status_char.to_string(), + path, + }); + } + + Ok(results) +} + +/// Get numstat diff between two refs. +/// Replaces: `git diff --numstat main branch` +#[napi] +pub fn git_diff_numstat( + repo_path: String, + from_ref: String, + to_ref: String, +) -> Result> { + let repo = open_repo(&repo_path)?; + + let from_tree = ref_tree(&repo, &from_ref)?; + let to_tree = ref_tree(&repo, &to_ref)?; + + let diff = repo + .diff_tree_to_tree(Some(&from_tree), Some(&to_tree), None) + .map_err(|e| git_err("Failed to diff trees", e))?; + + // Collect paths per delta index, then count lines in a second pass + let mut results = Vec::new(); + for delta in diff.deltas() { + let path = delta + .new_file() + .path() + .or_else(|| delta.old_file().path()) + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default(); + results.push(GitNumstat { + added: 0, + removed: 0, + path, + }); + } + + // Count added/removed lines per file using the patch API + for (i, _) in diff.deltas().enumerate() { + if let Ok(patch) = git2::Patch::from_diff(&diff, i) { + if let Some(patch) = patch { + let (_, additions, deletions) = patch.line_stats() + .unwrap_or((0, 0, 0)); + if let Some(entry) = results.get_mut(i) { + entry.added = additions as u32; + entry.removed = deletions as u32; + } + } + } + } + + Ok(results) +} + +/// Get unified diff content between two refs with optional pathspec/exclude. +/// `use_merge_base`: if true, uses three-dot semantics. +/// `exclude`: optional pathspec to exclude (e.g., ".gsd/"). +/// Replaces: `git diff main...branch -- .gsd/` and `-- . :(exclude).gsd/` +#[napi] +pub fn git_diff_content( + repo_path: String, + from_ref: String, + to_ref: String, + pathspec: Option, + exclude: Option, + use_merge_base: Option, +) -> Result { + let repo = open_repo(&repo_path)?; + + let mut diff_opts = DiffOptions::new(); + if let Some(ref ps) = pathspec { + diff_opts.pathspec(ps); + } + + let from_tree = if use_merge_base.unwrap_or(false) { + merge_base_tree(&repo, &from_ref, &to_ref)? + } else { + ref_tree(&repo, &from_ref)? + }; + let to_tree = ref_tree(&repo, &to_ref)?; + + let diff = repo + .diff_tree_to_tree(Some(&from_tree), Some(&to_tree), Some(&mut diff_opts)) + .map_err(|e| git_err("Failed to diff trees", e))?; + + let exclude_prefix = exclude.as_deref(); + + let mut output = String::new(); + diff.print(git2::DiffFormat::Patch, |delta, _hunk, line| { + // Apply exclude filter + if let Some(excl) = exclude_prefix { + let path = delta + .new_file() + .path() + .or_else(|| delta.old_file().path()) + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default(); + if path.starts_with(excl) { + return true; + } + } + + let prefix = match line.origin() { + '+' | '-' | ' ' => { + output.push(line.origin()); + "" + } + 'F' | 'H' | 'B' => "", + _ => "", + }; + output.push_str(prefix); + if let Ok(content) = std::str::from_utf8(line.content()) { + output.push_str(content); + } + true + }) + .map_err(|e| git_err("Failed to print diff", e))?; + + Ok(output) +} + +/// Get commit log between two refs (from..to). +/// Replaces: `git log --oneline main..branch` +#[napi] +pub fn git_log_oneline( + repo_path: String, + from_ref: String, + to_ref: String, +) -> Result> { + let repo = open_repo(&repo_path)?; + + let from_oid = resolve_ref(&repo, &from_ref)?; + let to_oid = resolve_ref(&repo, &to_ref)?; + + let mut revwalk = repo + .revwalk() + .map_err(|e| git_err("Failed to create revwalk", e))?; + revwalk.set_sorting(Sort::TIME).ok(); + revwalk + .push(to_oid) + .map_err(|e| git_err("Failed to push to_ref", e))?; + revwalk + .hide(from_oid) + .map_err(|e| git_err("Failed to hide from_ref", e))?; + + let mut entries = Vec::new(); + for oid in revwalk.flatten() { + if let Ok(commit) = repo.find_commit(oid) { + let sha = format!("{:.7}", oid); + let message = commit.summary().unwrap_or("").to_string(); + entries.push(GitLogEntry { sha, message }); + } + } + + Ok(entries) +} + +/// List git worktrees in porcelain format. +/// Replaces: `git worktree list --porcelain` +#[napi] +pub fn git_worktree_list(repo_path: String) -> Result> { + let repo = open_repo(&repo_path)?; + + let mut entries = Vec::new(); + + // Add the main worktree + if let Some(workdir) = repo.workdir() { + let branch = match repo.head() { + Ok(head) => head.shorthand().unwrap_or("HEAD").to_string(), + Err(_) => "HEAD".to_string(), + }; + entries.push(GitWorktreeEntry { + path: workdir.to_string_lossy().to_string(), + branch, + is_bare: false, + }); + } else if repo.is_bare() { + entries.push(GitWorktreeEntry { + path: repo.path().to_string_lossy().to_string(), + branch: String::new(), + is_bare: true, + }); + } + + // List linked worktrees + if let Ok(worktrees) = repo.worktrees() { + for wt_name in worktrees.iter().flatten() { + if let Ok(wt) = repo.find_worktree(wt_name) { + let wt_path = wt.path().to_string_lossy().to_string(); + // Open the worktree's repo to read its HEAD + let branch = match Repository::open(&wt_path) { + Ok(wt_repo) => match wt_repo.head() { + Ok(head) => { + if let Some(name) = head.name() { + name.strip_prefix("refs/heads/") + .unwrap_or(head.shorthand().unwrap_or("HEAD")) + .to_string() + } else { + "HEAD".to_string() + } + } + Err(_) => "HEAD".to_string(), + }, + Err(_) => String::new(), + }; + entries.push(GitWorktreeEntry { + path: wt_path, + branch, + is_bare: false, + }); + } + } + } + + Ok(entries) +} + +/// List branches matching an optional glob pattern. +/// Replaces: `git branch --list milestone/*`, `git branch --list gsd/*` +#[napi] +pub fn git_branch_list(repo_path: String, pattern: Option) -> Result> { + let repo = open_repo(&repo_path)?; + let branches = repo + .branches(Some(BranchType::Local)) + .map_err(|e| git_err("Failed to list branches", e))?; + + let mut names = Vec::new(); + for branch_result in branches { + let (branch, _) = branch_result.map_err(|e| git_err("Failed to iterate branches", e))?; + if let Some(name) = branch.name().ok().flatten() { + if let Some(ref pat) = pattern { + // Simple glob matching: support "prefix/*" and "prefix/*/*" + if matches_branch_pattern(name, pat) { + names.push(name.to_string()); + } + } else { + names.push(name.to_string()); + } + } + } + + Ok(names) +} + +/// Simple branch pattern matching for patterns like "milestone/*", "gsd/*/*" +fn matches_branch_pattern(name: &str, pattern: &str) -> bool { + // Handle simple prefix/* patterns + if let Some(prefix) = pattern.strip_suffix("/*") { + // For "gsd/*/*", this becomes "gsd/*" after first strip + if prefix.contains('*') { + // Recursive: "gsd/*/*" → name must start with "gsd/" and have at least 2 segments after + if let Some(inner_prefix) = prefix.strip_suffix("/*") { + return name.starts_with(&format!("{inner_prefix}/")) + && name[inner_prefix.len() + 1..].contains('/'); + } + } + return name.starts_with(&format!("{prefix}/")); + } + // Exact match + name == pattern +} + +/// List branches that have been merged into the given target branch. +/// Replaces: `git branch --merged main --list gsd/*` +#[napi] +pub fn git_branch_list_merged( + repo_path: String, + target: String, + pattern: Option, +) -> Result> { + let repo = open_repo(&repo_path)?; + let target_oid = resolve_ref(&repo, &target)?; + + let branches = repo + .branches(Some(BranchType::Local)) + .map_err(|e| git_err("Failed to list branches", e))?; + + let mut merged = Vec::new(); + for branch_result in branches { + let (branch, _) = branch_result.map_err(|e| git_err("Failed to iterate branches", e))?; + if let Some(name) = branch.name().ok().flatten() { + // Apply pattern filter + if let Some(ref pat) = pattern { + if !matches_branch_pattern(name, pat) { + continue; + } + } + + // Check if merged: a branch is merged into target if the merge base + // of the branch tip and target equals the branch tip. + if let Ok(branch_ref) = branch.get().peel(ObjectType::Commit) { + let branch_oid = branch_ref.id(); + if let Ok(base) = repo.merge_base(target_oid, branch_oid) { + if base == branch_oid { + merged.push(name.to_string()); + } + } + } + } + } + + Ok(merged) +} + +/// List files tracked in the index matching a pathspec. +/// Replaces: `git ls-files ""` +#[napi] +pub fn git_ls_files(repo_path: String, pathspec: String) -> Result> { + let repo = open_repo(&repo_path)?; + let index = repo + .index() + .map_err(|e| git_err("Failed to read index", e))?; + + let mut files = Vec::new(); + for entry in index.iter() { + let path = String::from_utf8_lossy(&entry.path).to_string(); + if path.starts_with(&pathspec) || (pathspec.ends_with('/') && path.starts_with(pathspec.trim_end_matches('/'))) { + files.push(path); + } + } + + Ok(files) +} + +/// List references matching a prefix. +/// Replaces: `git for-each-ref refs/gsd/snapshots/ --format=%(refname)` +#[napi] +pub fn git_for_each_ref(repo_path: String, prefix: String) -> Result> { + let repo = open_repo(&repo_path)?; + let glob = if prefix.ends_with('/') { + format!("{prefix}*") + } else { + format!("{prefix}/*") + }; + + let refs = repo + .references_glob(&glob) + .map_err(|e| git_err("Failed to list references", e))?; + + let mut names = Vec::new(); + for r in refs.flatten() { + if let Some(name) = r.name() { + names.push(name.to_string()); + } + } + + Ok(names) +} + +/// Get list of files with unmerged (conflict) entries in the index. +/// Replaces: `git diff --name-only --diff-filter=U` +#[napi] +pub fn git_conflict_files(repo_path: String) -> Result> { + let repo = open_repo(&repo_path)?; + let index = repo + .index() + .map_err(|e| git_err("Failed to read index", e))?; + + if !index.has_conflicts() { + return Ok(Vec::new()); + } + + let conflicts = index + .conflicts() + .map_err(|e| git_err("Failed to read conflicts", e))?; + + let mut files = Vec::new(); + let mut seen = std::collections::HashSet::new(); + for conflict in conflicts.flatten() { + // A conflict has ancestor, our, theirs entries — get the path from whichever exists + let path = conflict + .our + .as_ref() + .or(conflict.their.as_ref()) + .or(conflict.ancestor.as_ref()) + .map(|entry| String::from_utf8_lossy(&entry.path).to_string()); + + if let Some(p) = path { + if seen.insert(p.clone()) { + files.push(p); + } + } + } + + Ok(files) +} + +/// Get batch info: branch + status + change counts in ONE call. +/// Replaces: sequential calls to getCurrentBranch + hasChanges + status. +#[napi] +pub fn git_batch_info(repo_path: String) -> Result { + let repo = open_repo(&repo_path)?; + + // Branch + let branch = match repo.head() { + Ok(head) => { + if head.is_branch() { + head.shorthand().unwrap_or("HEAD").to_string() + } else { + "HEAD".to_string() + } + } + Err(_) => String::new(), + }; + + // Status + let mut opts = StatusOptions::new(); + opts.include_untracked(true).recurse_untracked_dirs(true); + + let statuses = repo + .statuses(Some(&mut opts)) + .map_err(|e| git_err("Failed to get status", e))?; + + let has_changes = !statuses.is_empty(); + let mut staged_count: u32 = 0; + let mut unstaged_count: u32 = 0; + let mut lines = Vec::with_capacity(statuses.len()); + + for entry in statuses.iter() { + let status = entry.status(); + let path = entry.path().unwrap_or("?"); + + let index_char = if status.is_index_new() { + staged_count += 1; + 'A' + } else if status.is_index_modified() { + staged_count += 1; + 'M' + } else if status.is_index_deleted() { + staged_count += 1; + 'D' + } else if status.is_index_renamed() { + staged_count += 1; + 'R' + } else if status.is_index_typechange() { + staged_count += 1; + 'T' + } else { + ' ' + }; + + let wt_char = if status.is_wt_new() { + unstaged_count += 1; + '?' + } else if status.is_wt_modified() { + unstaged_count += 1; + 'M' + } else if status.is_wt_deleted() { + unstaged_count += 1; + 'D' + } else if status.is_wt_renamed() { + unstaged_count += 1; + 'R' + } else if status.is_wt_typechange() { + unstaged_count += 1; + 'T' + } else { + ' ' + }; + + lines.push(format!("{index_char}{wt_char} {path}")); + } + + Ok(GitBatchInfo { + branch, + has_changes, + status: lines.join("\n"), + staged_count, + unstaged_count, + }) +} + +// ─── Write Functions ──────────────────────────────────────────────────────── + +/// Initialize a new git repository. +/// Replaces: `git init -b ` +#[napi] +pub fn git_init(path: String, initial_branch: Option) -> Result<()> { + let repo = Repository::init(&path).map_err(|e| git_err("Failed to init repository", e))?; + + // Set initial branch name if specified + if let Some(branch_name) = initial_branch { + // For a new repo, HEAD points to refs/heads/master by default. + // We need to update the symbolic ref to point to the desired branch. + repo.set_head(&format!("refs/heads/{branch_name}")) + .map_err(|e| git_err("Failed to set initial branch", e))?; + } + + Ok(()) +} + +/// Stage all files (equivalent to `git add -A`). +/// Replaces: `git add -A` +#[napi] +pub fn git_add_all(repo_path: String) -> Result<()> { + let repo = open_repo(&repo_path)?; + let mut index = repo + .index() + .map_err(|e| git_err("Failed to read index", e))?; + + index + .add_all(["*"].iter(), IndexAddOption::DEFAULT, None) + .map_err(|e| git_err("Failed to add all files", e))?; + + // Also handle deletions: update the index to reflect removed files + index + .update_all(["*"].iter(), None) + .map_err(|e| git_err("Failed to update index for deletions", e))?; + + index + .write() + .map_err(|e| git_err("Failed to write index", e))?; + + Ok(()) +} + +/// Stage specific files. +/// Replaces: `git add -- ...` +#[napi] +pub fn git_add_paths(repo_path: String, paths: Vec) -> Result<()> { + let repo = open_repo(&repo_path)?; + let mut index = repo + .index() + .map_err(|e| git_err("Failed to read index", e))?; + + index + .add_all(paths.iter(), IndexAddOption::DEFAULT, None) + .map_err(|e| git_err("Failed to add paths", e))?; + + index + .write() + .map_err(|e| git_err("Failed to write index", e))?; + + Ok(()) +} + +/// Unstage files (reset index entries to HEAD for specific paths). +/// Replaces: `git reset HEAD -- ` +#[napi] +pub fn git_reset_paths(repo_path: String, paths: Vec) -> Result<()> { + let repo = open_repo(&repo_path)?; + + // Get HEAD commit's tree + let head_obj = match repo.head() { + Ok(head) => Some( + head.peel(ObjectType::Commit) + .map_err(|e| git_err("Failed to peel HEAD", e))?, + ), + Err(_) => None, + }; + + let pathspecs: Vec<&str> = paths.iter().map(|s| s.as_str()).collect(); + + repo.reset_default(head_obj.as_ref(), pathspecs.iter()) + .map_err(|e| git_err("Failed to reset paths", e))?; + + Ok(()) +} + +/// Create a commit from the current index. +/// Returns the commit SHA. +/// Replaces: `git commit -m `, `git commit --no-verify -F -` +#[napi] +pub fn git_commit( + repo_path: String, + message: String, + allow_empty: Option, +) -> Result { + let repo = open_repo(&repo_path)?; + let mut index = repo + .index() + .map_err(|e| git_err("Failed to read index", e))?; + + // If message is empty, read from MERGE_MSG or SQUASH_MSG (--no-edit equivalent) + let message = if message.is_empty() { + let merge_msg_path = repo.path().join("MERGE_MSG"); + let squash_msg_path = repo.path().join("SQUASH_MSG"); + if merge_msg_path.exists() { + std::fs::read_to_string(&merge_msg_path) + .unwrap_or_else(|_| "Merge commit".to_string()) + } else if squash_msg_path.exists() { + std::fs::read_to_string(&squash_msg_path) + .unwrap_or_else(|_| "Squash commit".to_string()) + } else { + "Merge commit".to_string() + } + } else { + message + }; + + // Write the index as a tree + let tree_oid = index + .write_tree() + .map_err(|e| git_err("Failed to write tree", e))?; + let tree = repo + .find_tree(tree_oid) + .map_err(|e| git_err("Failed to find tree", e))?; + + // Get parent commit(s) + let parent = match repo.head() { + Ok(head) => Some( + head.peel_to_commit() + .map_err(|e| git_err("Failed to peel HEAD to commit", e))?, + ), + Err(_) => None, // Initial commit + }; + + // Check if there are changes (unless allow_empty) + if !allow_empty.unwrap_or(false) { + if let Some(ref p) = parent { + let parent_tree = p + .tree() + .map_err(|e| git_err("Failed to get parent tree", e))?; + let diff = repo + .diff_tree_to_tree(Some(&parent_tree), Some(&tree), None) + .map_err(|e| git_err("Failed to diff for empty check", e))?; + if diff.deltas().len() == 0 { + return Err(Error::new( + Status::GenericFailure, + "nothing to commit, working tree clean", + )); + } + } + } + + // Create the signature from git config + let sig = repo + .signature() + .map_err(|e| git_err("Failed to get signature", e))?; + + let parents: Vec<&git2::Commit> = parent.iter().collect(); + + let oid = repo + .commit(Some("HEAD"), &sig, &sig, &message, &tree, &parents) + .map_err(|e| git_err("Failed to create commit", e))?; + + // Clean up merge/squash message files after commit + for msg_file in &["SQUASH_MSG", "MERGE_MSG"] { + let msg_path = repo.path().join(msg_file); + if msg_path.exists() { + std::fs::remove_file(&msg_path) + .map_err(|e| Error::new(Status::GenericFailure, format!("Failed to clean up {msg_file}: {e}")))?; + } + } + + Ok(format!("{oid}")) +} + +/// Checkout a branch (switch HEAD and update working tree). +/// Replaces: `git checkout ` +#[napi] +pub fn git_checkout_branch(repo_path: String, branch: String) -> Result<()> { + let repo = open_repo(&repo_path)?; + + let refname = format!("refs/heads/{branch}"); + let obj = repo + .revparse_single(&refname) + .map_err(|e| git_err(&format!("Branch '{branch}' not found"), e))?; + + repo.checkout_tree( + &obj, + Some(CheckoutBuilder::new().safe().recreate_missing(true)), + ) + .map_err(|e| git_err(&format!("Failed to checkout '{branch}'"), e))?; + + repo.set_head(&refname) + .map_err(|e| git_err(&format!("Failed to set HEAD to '{branch}'"), e))?; + + Ok(()) +} + +/// Resolve index conflicts by accepting "theirs" version for specific paths. +/// Replaces: `git checkout --theirs -- ` +#[napi] +pub fn git_checkout_theirs(repo_path: String, paths: Vec) -> Result<()> { + let repo = open_repo(&repo_path)?; + let mut index = repo + .index() + .map_err(|e| git_err("Failed to read index", e))?; + + for path in &paths { + // Find the "theirs" (stage 3) entry in the index + if let Some(entry) = index.get_path(Path::new(path), 3) { + // Copy the entry data we need before mutating the index + let blob_id = entry.id; + let entry_mode = entry.mode; + let entry_path = entry.path.clone(); + + // Remove all conflict stages + index.remove_path(Path::new(path)).ok(); + + // Create a new stage-0 entry with the "theirs" content + let resolved = git2::IndexEntry { + ctime: git2::IndexTime::new(0, 0), + mtime: git2::IndexTime::new(0, 0), + dev: 0, + ino: 0, + mode: entry_mode, + uid: 0, + gid: 0, + file_size: 0, + id: blob_id, + flags: 0, // stage 0 + flags_extended: 0, + path: entry_path, + }; + index + .add(&resolved) + .map_err(|e| git_err(&format!("Failed to add resolved '{path}'"), e))?; + + // Also checkout the file to working directory (with path traversal validation) + let blob = repo + .find_blob(blob_id) + .map_err(|e| git_err(&format!("Failed to find blob for '{path}'"), e))?; + let full_path = validate_path_within_repo(&repo_path, path)?; + if let Some(parent) = full_path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| Error::new(Status::GenericFailure, format!("Failed to create directory for '{path}': {e}")))?; + } + std::fs::write(&full_path, blob.content()) + .map_err(|e| Error::new(Status::GenericFailure, format!("Failed to write '{path}': {e}")))?; + } + } + + index + .write() + .map_err(|e| git_err("Failed to write index", e))?; + + Ok(()) +} + +/// Squash-merge a branch into the current branch. +/// Stages changes in the index but does NOT create a commit. +/// Replaces: `git merge --squash ` +#[napi] +pub fn git_merge_squash(repo_path: String, branch: String) -> Result { + let repo = open_repo(&repo_path)?; + + let refname = format!("refs/heads/{branch}"); + let their_commit = repo + .find_reference(&refname) + .map_err(|e| git_err(&format!("Branch '{branch}' not found"), e))? + .peel_to_commit() + .map_err(|e| git_err(&format!("Failed to peel '{branch}' to commit"), e))?; + + let annotated = repo + .find_annotated_commit(their_commit.id()) + .map_err(|e| git_err("Failed to create annotated commit", e))?; + + // Perform the merge analysis + let (analysis, _) = repo + .merge_analysis(&[&annotated]) + .map_err(|e| git_err("Failed to analyze merge", e))?; + + if analysis.is_up_to_date() { + return Ok(GitMergeResult { + success: true, + conflicts: vec![], + }); + } + + // Perform the merge into the index + let mut merge_opts = MergeOptions::new(); + let mut checkout_opts = CheckoutBuilder::new(); + checkout_opts.safe().allow_conflicts(true); + + repo.merge(&[&annotated], Some(&mut merge_opts), Some(&mut checkout_opts)) + .map_err(|e| git_err("Failed to merge", e))?; + + // Check for conflicts + let index = repo + .index() + .map_err(|e| git_err("Failed to read index after merge", e))?; + + let mut conflicts = Vec::new(); + if index.has_conflicts() { + if let Ok(conflict_iter) = index.conflicts() { + for conflict in conflict_iter.flatten() { + let path = conflict + .our + .as_ref() + .or(conflict.their.as_ref()) + .or(conflict.ancestor.as_ref()) + .map(|entry| String::from_utf8_lossy(&entry.path).to_string()); + + if let Some(p) = path { + conflicts.push(p); + } + } + } + } + + // For squash merge: clean up merge state (we don't want MERGE_HEAD) + // This mimics `git merge --squash` which doesn't record the merge + repo.cleanup_state() + .map_err(|e| git_err("Failed to cleanup merge state", e))?; + + Ok(GitMergeResult { + success: conflicts.is_empty(), + conflicts, + }) +} + +/// Abort an in-progress merge. +/// Replaces: `git merge --abort` +#[napi] +pub fn git_merge_abort(repo_path: String) -> Result<()> { + let repo = open_repo(&repo_path)?; + + // Reset to HEAD + let head = repo + .head() + .map_err(|e| git_err("Failed to read HEAD", e))?; + let obj = head + .peel(ObjectType::Commit) + .map_err(|e| git_err("Failed to peel HEAD", e))?; + + repo.reset(&obj, ResetType::Hard, None) + .map_err(|e| git_err("Failed to reset", e))?; + + // Clean up merge state files + repo.cleanup_state() + .map_err(|e| git_err("Failed to cleanup merge state", e))?; + + Ok(()) +} + +/// Abort an in-progress rebase. +/// Replaces: `git rebase --abort` +#[napi] +pub fn git_rebase_abort(repo_path: String) -> Result<()> { + let repo = open_repo(&repo_path)?; + + // Check for rebase state and abort + let git_dir = repo.path(); + let rebase_merge = git_dir.join("rebase-merge"); + let rebase_apply = git_dir.join("rebase-apply"); + + if rebase_merge.exists() || rebase_apply.exists() { + // Read ORIG_HEAD to know where to reset + let orig_head_path = git_dir.join("ORIG_HEAD"); + if let Ok(orig_ref) = std::fs::read_to_string(&orig_head_path) { + let oid_str = orig_ref.trim(); + if let Ok(oid) = git2::Oid::from_str(oid_str) { + if let Ok(commit) = repo.find_commit(oid) { + let obj = commit.as_object(); + repo.reset(obj, ResetType::Hard, None) + .map_err(|e| git_err("Failed to reset to ORIG_HEAD", e))?; + } + } + } + + // Clean up rebase state directories + if rebase_merge.exists() { + std::fs::remove_dir_all(&rebase_merge) + .map_err(|e| Error::new(Status::GenericFailure, format!("Failed to remove rebase-merge state: {e}")))?; + } + if rebase_apply.exists() { + std::fs::remove_dir_all(&rebase_apply) + .map_err(|e| Error::new(Status::GenericFailure, format!("Failed to remove rebase-apply state: {e}")))?; + } + } + + repo.cleanup_state() + .map_err(|e| git_err("Failed to cleanup repo state", e))?; + Ok(()) +} + +/// Hard reset to HEAD. +/// Replaces: `git reset --hard HEAD` +#[napi] +pub fn git_reset_hard(repo_path: String) -> Result<()> { + let repo = open_repo(&repo_path)?; + + let head = repo + .head() + .map_err(|e| git_err("Failed to read HEAD", e))?; + let obj = head + .peel(ObjectType::Commit) + .map_err(|e| git_err("Failed to peel HEAD", e))?; + + repo.reset(&obj, ResetType::Hard, None) + .map_err(|e| git_err("Failed to reset", e))?; + + Ok(()) +} + +/// Delete a branch. +/// Replaces: `git branch -D ` (force=true) or `git branch -d ` (force=false) +#[napi] +pub fn git_branch_delete(repo_path: String, branch: String, force: Option) -> Result<()> { + let repo = open_repo(&repo_path)?; + + let mut git_branch = repo + .find_branch(&branch, BranchType::Local) + .map_err(|e| git_err(&format!("Branch '{branch}' not found"), e))?; + + if force.unwrap_or(false) { + // Force delete (like -D): delete the ref directly + let refname = format!("refs/heads/{branch}"); + if let Ok(mut reference) = repo.find_reference(&refname) { + reference + .delete() + .map_err(|e| git_err(&format!("Failed to delete branch '{branch}'"), e))?; + } + } else { + // Safe delete (like -d): only if fully merged + git_branch + .delete() + .map_err(|e| git_err(&format!("Failed to delete branch '{branch}'"), e))?; + } + + Ok(()) +} + +/// Force-reset a branch to point at a target ref. +/// Replaces: `git branch -f ` +#[napi] +pub fn git_branch_force_reset( + repo_path: String, + branch: String, + target: String, +) -> Result<()> { + let repo = open_repo(&repo_path)?; + + let target_commit = repo + .revparse_single(&target) + .map_err(|e| git_err(&format!("Failed to resolve '{target}'"), e))? + .peel_to_commit() + .map_err(|e| git_err(&format!("Failed to peel '{target}' to commit"), e))?; + + repo.branch(&branch, &target_commit, true) + .map_err(|e| git_err(&format!("Failed to reset branch '{branch}'"), e))?; + + Ok(()) +} + +/// Remove files from the index (cache) without touching the working tree. +/// Returns the list of files that were actually removed. +/// Replaces: `git rm --cached -r --ignore-unmatch ` +#[napi] +pub fn git_rm_cached( + repo_path: String, + paths: Vec, + recursive: Option, +) -> Result> { + let repo = open_repo(&repo_path)?; + let mut index = repo + .index() + .map_err(|e| git_err("Failed to read index", e))?; + + let is_recursive = recursive.unwrap_or(true); + let mut removed = Vec::new(); + + for path in &paths { + if is_recursive && (path.ends_with('/') || Path::new(&repo_path).join(path).is_dir()) { + // Remove all entries under this directory + let prefix = if path.ends_with('/') { + path.clone() + } else { + format!("{path}/") + }; + let entries_to_remove: Vec = index + .iter() + .filter_map(|entry| { + let entry_path = String::from_utf8_lossy(&entry.path).to_string(); + if entry_path.starts_with(&prefix) || entry_path == path.trim_end_matches('/') { + Some(entry_path) + } else { + None + } + }) + .collect(); + + for entry_path in &entries_to_remove { + if index.remove_path(Path::new(entry_path)).is_ok() { + removed.push(format!("rm '{entry_path}'")); + } + } + } else { + if index.remove_path(Path::new(path)).is_ok() { + removed.push(format!("rm '{path}'")); + } + } + } + + if !removed.is_empty() { + index + .write() + .map_err(|e| git_err("Failed to write index", e))?; + } + + Ok(removed) +} + +/// Force-remove files from both index and working tree. +/// Replaces: `git rm --force -- ` +#[napi] +pub fn git_rm_force(repo_path: String, paths: Vec) -> Result<()> { + let repo = open_repo(&repo_path)?; + let mut index = repo + .index() + .map_err(|e| git_err("Failed to read index", e))?; + + for path in &paths { + index.remove_path(Path::new(path)) + .map_err(|e| git_err(&format!("Failed to remove '{path}' from index"), e))?; + // Also delete from working tree (with path traversal validation) + let full_path = validate_path_within_repo(&repo_path, path)?; + if full_path.exists() { + std::fs::remove_file(&full_path) + .map_err(|e| Error::new(Status::GenericFailure, format!("Failed to delete '{path}': {e}")))?; + } + } + + index + .write() + .map_err(|e| git_err("Failed to write index", e))?; + + Ok(()) +} + +/// Add a new git worktree. +/// Replaces: `git worktree add [-b ] ` +#[napi] +pub fn git_worktree_add( + repo_path: String, + wt_path: String, + branch: String, + create_branch: Option, + start_point: Option, +) -> Result<()> { + let repo = open_repo(&repo_path)?; + + if create_branch.unwrap_or(false) { + // Create a new branch from start_point, then add worktree + let start = start_point.as_deref().unwrap_or("HEAD"); + let start_commit = repo + .revparse_single(start) + .map_err(|e| git_err(&format!("Failed to resolve '{start}'"), e))? + .peel_to_commit() + .map_err(|e| git_err(&format!("Failed to peel '{start}' to commit"), e))?; + + repo.branch(&branch, &start_commit, false) + .map_err(|e| git_err(&format!("Failed to create branch '{branch}'"), e))?; + } + + // Use git worktree add via the worktree API + let refname = format!("refs/heads/{branch}"); + let reference = repo + .find_reference(&refname) + .map_err(|e| git_err(&format!("Branch '{branch}' not found"), e))?; + + repo.worktree( + &branch, // worktree name + Path::new(&wt_path), + Some( + git2::WorktreeAddOptions::new() + .reference(Some(&reference)), + ), + ) + .map_err(|e| git_err(&format!("Failed to add worktree at '{wt_path}'"), e))?; + + Ok(()) +} + +/// Remove a git worktree. +/// Replaces: `git worktree remove [--force] ` +#[napi] +pub fn git_worktree_remove(repo_path: String, wt_path: String, force: Option) -> Result<()> { + let repo = open_repo(&repo_path)?; + + // Find the worktree by path + if let Ok(worktrees) = repo.worktrees() { + for wt_name in worktrees.iter().flatten() { + if let Ok(wt) = repo.find_worktree(wt_name) { + let path_str = wt.path().to_string_lossy().to_string(); + let normalized_wt = path_str.trim_end_matches('/'); + let normalized_target = wt_path.trim_end_matches('/'); + if normalized_wt == normalized_target { + if force.unwrap_or(false) { + // Force: validate (which marks it as prunable) then remove dir + wt.validate().ok(); // May fail if already invalid — that's fine + if wt.path().exists() { + std::fs::remove_dir_all(wt.path()).ok(); + } + // Prune the entry + wt.prune(Some( + git2::WorktreePruneOptions::new() + .valid(true) + .locked(true) + .working_tree(true), + )) + .ok(); + } else if wt.validate().is_ok() { + // Only prune if the worktree is valid + if wt.path().exists() { + std::fs::remove_dir_all(wt.path()).ok(); + } + wt.prune(Some(git2::WorktreePruneOptions::new().valid(true))) + .ok(); + } + return Ok(()); + } + } + } + } + + // If worktree not found in git's list, try to clean up the directory anyway + let wt = Path::new(&wt_path); + if wt.exists() && force.unwrap_or(false) { + std::fs::remove_dir_all(wt).ok(); + } + + Ok(()) +} + +/// Prune stale worktree entries. +/// Replaces: `git worktree prune` +#[napi] +pub fn git_worktree_prune(repo_path: String) -> Result<()> { + let repo = open_repo(&repo_path)?; + + if let Ok(worktrees) = repo.worktrees() { + for wt_name in worktrees.iter().flatten() { + if let Ok(wt) = repo.find_worktree(wt_name) { + if wt.validate().is_err() { + // Worktree is invalid (directory missing, etc.) — prune it + wt.prune(Some( + git2::WorktreePruneOptions::new() + .valid(false) + .working_tree(true), + )) + .ok(); + } + } + } + } + + Ok(()) +} + +/// Revert a commit without auto-committing. +/// Replaces: `git revert --no-commit ` +#[napi] +pub fn git_revert_commit(repo_path: String, sha: String) -> Result<()> { + let repo = open_repo(&repo_path)?; + + let oid = git2::Oid::from_str(&sha) + .map_err(|e| git_err(&format!("Invalid SHA '{sha}'"), e))?; + + let commit = repo + .find_commit(oid) + .map_err(|e| git_err(&format!("Commit '{sha}' not found"), e))?; + + repo.revert(&commit, None) + .map_err(|e| git_err(&format!("Failed to revert commit '{sha}'"), e))?; + + // Clean up revert state since we don't want to auto-commit + // (git revert --no-commit semantics) + repo.cleanup_state().ok(); + + Ok(()) +} + +/// Abort an in-progress revert. +/// Replaces: `git revert --abort` +#[napi] +pub fn git_revert_abort(repo_path: String) -> Result<()> { + let repo = open_repo(&repo_path)?; + + // Reset to HEAD + if let Ok(head) = repo.head() { + if let Ok(obj) = head.peel(ObjectType::Commit) { + repo.reset(&obj, ResetType::Hard, None).ok(); + } + } + + repo.cleanup_state().ok(); + Ok(()) +} + +/// Create or delete a ref. +/// When `target` is provided, creates/updates the ref to point at target. +/// When `target` is None, deletes the ref. +/// Replaces: `git update-ref HEAD` and `git update-ref -d ` +#[napi] +pub fn git_update_ref(repo_path: String, refname: String, target: Option) -> Result<()> { + let repo = open_repo(&repo_path)?; + + match target { + Some(target_ref) => { + let oid = resolve_ref(&repo, &target_ref)?; + repo.reference(&refname, oid, true, "update-ref") + .map_err(|e| git_err(&format!("Failed to update ref '{refname}'"), e))?; + } + None => { + if let Ok(mut reference) = repo.find_reference(&refname) { + reference + .delete() + .map_err(|e| git_err(&format!("Failed to delete ref '{refname}'"), e))?; + } + } + } + + Ok(()) } diff --git a/src/resources/extensions/gsd/auto-recovery.ts b/src/resources/extensions/gsd/auto-recovery.ts index f3124a2da..ca23efebd 100644 --- a/src/resources/extensions/gsd/auto-recovery.ts +++ b/src/resources/extensions/gsd/auto-recovery.ts @@ -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", diff --git a/src/resources/extensions/gsd/auto-supervisor.ts b/src/resources/extensions/gsd/auto-supervisor.ts index 742d30b91..05e0713fb 100644 --- a/src/resources/extensions/gsd/auto-supervisor.ts +++ b/src/resources/extensions/gsd/auto-supervisor.ts @@ -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; } diff --git a/src/resources/extensions/gsd/auto-worktree.ts b/src/resources/extensions/gsd/auto-worktree.ts index ca75f944c..b788e6a79 100644 --- a/src/resources/extensions/gsd/auto-worktree.ts +++ b/src/resources/extensions/gsd/auto-worktree.ts @@ -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 } diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index 0e75abef0..8283a3f34 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -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 */ } } diff --git a/src/resources/extensions/gsd/commands.ts b/src/resources/extensions/gsd/commands.ts index 2563a0e55..1f6f2f563 100644 --- a/src/resources/extensions/gsd/commands.ts +++ b/src/resources/extensions/gsd/commands.ts @@ -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 { - 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 { - 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 */ } } diff --git a/src/resources/extensions/gsd/doctor.ts b/src/resources/extensions/gsd/doctor.ts index ae03220c7..189af7b4e 100644 --- a/src/resources/extensions/gsd/doctor.ts +++ b/src/resources/extensions/gsd/doctor.ts @@ -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 { // 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", diff --git a/src/resources/extensions/gsd/git-self-heal.ts b/src/resources/extensions/gsd/git-self-heal.ts index 305d01034..efe8d894d 100644 --- a/src/resources/extensions/gsd/git-self-heal.ts +++ b/src/resources/extensions/gsd/git-self-heal.ts @@ -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"); } diff --git a/src/resources/extensions/gsd/git-service.ts b/src/resources/extensions/gsd/git-service.ts index 36b018aa3..64acb359f 100644 --- a/src/resources/extensions/gsd/git-service.ts +++ b/src/resources/extensions/gsd/git-service.ts @@ -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"; diff --git a/src/resources/extensions/gsd/gitignore.ts b/src/resources/extensions/gsd/gitignore.ts index afde88d66..892efa34e 100644 --- a/src/resources/extensions/gsd/gitignore.ts +++ b/src/resources/extensions/gsd/gitignore.ts @@ -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 } diff --git a/src/resources/extensions/gsd/guided-flow.ts b/src/resources/extensions/gsd/guided-flow.ts index 198c8f7b3..c3140ef3c 100644 --- a/src/resources/extensions/gsd/guided-flow.ts +++ b/src/resources/extensions/gsd/guided-flow.ts @@ -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 } diff --git a/src/resources/extensions/gsd/native-git-bridge.ts b/src/resources/extensions/gsd/native-git-bridge.ts index 2e0e62d9b..a81fa3c81 100644 --- a/src/resources/extensions/gsd/native-git-bridge.ts +++ b/src/resources/extensions/gsd/native-git-bridge.ts @@ -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 `. + */ +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 --list `. + */ +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 `. + */ +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 --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 `. + */ +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 -- `. + */ +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 -- `. + */ +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 `. + */ +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 -- `. + */ +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 `. + */ +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 `. + */ +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 `. + */ +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 `. + */ +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 -- `. + */ +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] `. + */ +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 `. + */ +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, +}; diff --git a/src/resources/extensions/gsd/session-forensics.ts b/src/resources/extensions/gsd/session-forensics.ts index b3c5808f5..ac44711cf 100644 --- a/src/resources/extensions/gsd/session-forensics.ts +++ b/src/resources/extensions/gsd/session-forensics.ts @@ -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}`); diff --git a/src/resources/extensions/gsd/undo.ts b/src/resources/extensions/gsd/undo.ts index 89d52f4bf..73ab1e1f5 100644 --- a/src/resources/extensions/gsd/undo.ts +++ b/src/resources/extensions/gsd/undo.ts @@ -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; } } diff --git a/src/resources/extensions/gsd/worktree-command.ts b/src/resources/extensions/gsd/worktree-command.ts index 300ddfa9f..0401064c2 100644 --- a/src/resources/extensions/gsd/worktree-command.ts +++ b/src/resources/extensions/gsd/worktree-command.ts @@ -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( diff --git a/src/resources/extensions/gsd/worktree-manager.ts b/src/resources/extensions/gsd/worktree-manager.ts index 847dc4061..07979b8ad 100644 --- a/src/resources/extensions/gsd/worktree-manager.ts +++ b/src/resources/extensions/gsd/worktree-manager.ts @@ -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; }