feat: move git operations to Rust via git2 crate (#572)
* feat: move git operations to Rust via git2 crate (#524) Eliminates ~70 execSync/execFileSync git CLI calls across 15 TypeScript files by implementing native libgit2 operations in Rust and routing all consumers through the native-git-bridge. Rust (native/crates/engine/src/git.rs): - Added 28 new NAPI functions covering both read and write operations - Read: git_is_repo, git_has_staged_changes, git_diff_stat, git_diff_name_status, git_diff_numstat, git_diff_content, git_log_oneline, git_worktree_list, git_branch_list, git_branch_list_merged, git_ls_files, git_for_each_ref, git_conflict_files, git_batch_info - Write: git_init, git_add_all, git_add_paths, git_reset_paths, git_commit, git_checkout_branch, git_checkout_theirs, git_merge_squash, git_merge_abort, git_rebase_abort, git_reset_hard, git_branch_delete, git_branch_force_reset, git_rm_cached, git_rm_force, git_worktree_add, git_worktree_remove, git_worktree_prune, git_revert_commit, git_revert_abort, git_update_ref TypeScript (native-git-bridge.ts): - Added 35 bridge functions with native-first + execSync fallback - New types: GitDiffStat, GitNameStatus, GitNumstat, GitLogEntry, GitWorktreeEntry, GitBatchInfo, GitMergeResult Consumer migrations (15 files): - worktree-manager.ts: removed local runGit/getMainBranch, all ops native - auto-worktree.ts: merge, checkout, conflict resolution all native - git-service.ts: smart staging, commits, snapshots all native - auto.ts, guided-flow.ts: repo init/bootstrap native - auto-supervisor.ts: working tree detection native - git-self-heal.ts: merge/rebase abort, reset all native - doctor.ts: health checks, branch listing, worktree cleanup native - commands.ts: branch/snapshot cleanup native - session-forensics.ts: diff stat queries native - auto-recovery.ts: merge state reconciliation native - gitignore.ts, undo.ts, worktree-command.ts: remaining ops native Kept as execSync (by design): - git push (credential handling too complex for libgit2) - native-git-bridge.ts fallbacks (graceful degradation) - runPreMergeCheck (runs arbitrary user commands) Closes #524 * fix: restore getMainBranch export from worktree-manager The agent migration removed getMainBranch from worktree-manager.ts but worktree-command.ts still imports it. Re-add as a thin wrapper around nativeDetectMainBranch. * fix: address PR #572 review feedback — security, correctness, error handling CRITICAL: - Path traversal protection via validate_path_within_repo() for git_rm_force and git_checkout_theirs - git_branch_delete defaults to safe delete (force=false) HIGH: - Replace silent .ok() with proper error propagation in git_commit, git_merge_abort, git_rebase_abort, git_rm_force, git_checkout_theirs - nativeDiffStat fallback parses numeric stats from git output - nativeBatchInfo fallback counts staged/unstaged from porcelain status MEDIUM: - Wire up dead force param in removeWorktree() - Read MERGE_MSG/SQUASH_MSG when commit message empty - nativeLsFiles uses gitFileExec without fragile quote wrapping - Fix operator precedence in git_ls_files
This commit is contained in:
parent
6d84d1c317
commit
343a43f028
17 changed files with 2876 additions and 466 deletions
282
.plans/issue-524-git2-migration.md
Normal file
282
.plans/issue-524-git2-migration.md
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
# Issue #524: Move Git Operations to Rust via git2 Crate
|
||||
|
||||
## Current State
|
||||
|
||||
- **git2** crate (v0.20) already a dependency with vendored libgit2
|
||||
- **7 read-only** functions already native in `git.rs` + `native-git-bridge.ts`:
|
||||
- `git_current_branch`, `git_main_branch`, `git_branch_exists`
|
||||
- `git_has_merge_conflicts`, `git_working_tree_status`, `git_has_changes`
|
||||
- `git_commit_count_between`
|
||||
- **~73 execSync/execFileSync git calls** remain across 14 TypeScript files
|
||||
- All native functions follow the same pattern: native-first with execSync fallback
|
||||
|
||||
## Scope
|
||||
|
||||
This plan covers **Phase 1**: migrate all remaining read operations and high-value
|
||||
write operations to native git2. Push operations stay as execSync (credential
|
||||
handling too complex for git2). The "Additional Rust Opportunities" (state
|
||||
derivation, JSONL parser) are out of scope for this PR.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: New Native Read Functions (git.rs)
|
||||
|
||||
### 1.1 — `git_is_repo(path: String) -> bool`
|
||||
Replaces: `git rev-parse --git-dir` (3 calls in auto.ts, guided-flow.ts, doctor.ts)
|
||||
Implementation: `Repository::open(path).is_ok()`
|
||||
|
||||
### 1.2 — `git_has_staged_changes(repo_path: String) -> bool`
|
||||
Replaces: `git diff --cached --stat` (2 calls in git-service.ts)
|
||||
Implementation: Diff index vs HEAD tree, check if delta count > 0
|
||||
|
||||
### 1.3 — `git_diff_stat(repo_path, from_ref?, to_ref?) -> GitDiffStat`
|
||||
Replaces: `git diff --stat HEAD`, `git diff --stat --cached HEAD` (session-forensics.ts)
|
||||
Returns: `{ files_changed: u32, insertions: u32, deletions: u32, summary: String }`
|
||||
Implementation: Diff between two trees/index/workdir, count deltas
|
||||
|
||||
### 1.4 — `git_diff_name_status(repo_path, from_ref, to_ref, pathspec?) -> Vec<GitNameStatus>`
|
||||
Replaces: `git diff --name-status main...branch -- .gsd/` (worktree-manager.ts, 3 calls)
|
||||
Returns: `Vec<{ status: String, path: String }>`
|
||||
Implementation: Tree-to-tree diff with pathspec filter
|
||||
|
||||
### 1.5 — `git_diff_numstat(repo_path, from_ref, to_ref) -> Vec<GitNumstat>`
|
||||
Replaces: `git diff --numstat main branch` (worktree-manager.ts, 1 call)
|
||||
Returns: `Vec<{ added: u32, removed: u32, path: String }>`
|
||||
|
||||
### 1.6 — `git_diff_content(repo_path, from_ref, to_ref, pathspec?, exclude?) -> String`
|
||||
Replaces: `git diff main...branch -- .gsd/` and `-- . :(exclude).gsd/` (worktree-manager.ts, 2 calls)
|
||||
Returns: Unified diff string
|
||||
|
||||
### 1.7 — `git_log_oneline(repo_path, from_ref, to_ref) -> Vec<GitLogEntry>`
|
||||
Replaces: `git log --oneline main..branch` (worktree-manager.ts, 1 call)
|
||||
Returns: `Vec<{ sha: String, message: String }>`
|
||||
|
||||
### 1.8 — `git_worktree_list(repo_path) -> Vec<GitWorktreeEntry>`
|
||||
Replaces: `git worktree list --porcelain` (worktree-manager.ts, 2 calls)
|
||||
Returns: `Vec<{ path: String, branch: String, is_bare: bool }>`
|
||||
Implementation: `Repository::worktrees()` + individual worktree info
|
||||
|
||||
### 1.9 — `git_branch_list(repo_path, pattern?) -> Vec<String>`
|
||||
Replaces: `git branch --list milestone/*`, `git branch --list gsd/*` (doctor.ts, commands.ts, 3 calls)
|
||||
Returns: Branch names matching pattern
|
||||
|
||||
### 1.10 — `git_branch_list_merged(repo_path, target, pattern?) -> Vec<String>`
|
||||
Replaces: `git branch --merged main --list gsd/*` (commands.ts, 1 call)
|
||||
Returns: Branch names merged into target
|
||||
|
||||
### 1.11 — `git_ls_files(repo_path, pathspec) -> Vec<String>`
|
||||
Replaces: `git ls-files "<exclusion>"` (doctor.ts, 1 call)
|
||||
Implementation: Read index, filter by pathspec
|
||||
|
||||
### 1.12 — `git_for_each_ref(repo_path, prefix) -> Vec<String>`
|
||||
Replaces: `git for-each-ref refs/gsd/snapshots/ --format=%(refname)` (commands.ts, 1 call)
|
||||
Implementation: `repo.references_glob(prefix/*)`
|
||||
|
||||
### 1.13 — `git_conflict_files(repo_path) -> Vec<String>`
|
||||
Replaces: `git diff --name-only --diff-filter=U` (auto-worktree.ts, 1 call)
|
||||
Implementation: Read index conflicts
|
||||
|
||||
### 1.14 — `git_batch_info(repo_path) -> GitBatchInfo`
|
||||
NEW batch function: status + branch + diff summary in ONE call
|
||||
Returns: `{ branch: String, has_changes: bool, status: String, staged_count: u32, unstaged_count: u32 }`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: New Native Write Functions (git.rs)
|
||||
|
||||
### 2.1 — `git_init(path, branch?) -> void`
|
||||
Replaces: `git init -b <branch>` (auto.ts, guided-flow.ts, 2 calls)
|
||||
Implementation: `Repository::init()` + set initial branch
|
||||
|
||||
### 2.2 — `git_add_all(repo_path) -> void`
|
||||
Replaces: `git add -A` (auto-worktree.ts, git-service.ts, 4 calls)
|
||||
Implementation: Add all to index via `repo.index().add_all()`
|
||||
|
||||
### 2.3 — `git_add_paths(repo_path, paths: Vec<String>) -> void`
|
||||
Replaces: `git add -- <file>` (auto-worktree.ts, git-service.ts, 3 calls)
|
||||
Implementation: Add specific paths to index
|
||||
|
||||
### 2.4 — `git_reset_paths(repo_path, paths: Vec<String>) -> void`
|
||||
Replaces: `git reset HEAD -- <path>` (git-service.ts, in loop)
|
||||
Implementation: Reset index entries to HEAD for specific paths
|
||||
|
||||
### 2.5 — `git_commit(repo_path, message, options?) -> String`
|
||||
Replaces: `git commit -m <msg>`, `git commit --no-verify -F -` (11+ calls across files)
|
||||
Returns: Commit SHA
|
||||
Implementation: Write index as tree → create commit → update HEAD
|
||||
Options: `{ allow_empty: bool }`
|
||||
|
||||
### 2.6 — `git_checkout_branch(repo_path, branch) -> void`
|
||||
Replaces: `git checkout <branch>` (auto-worktree.ts, 1 call)
|
||||
Implementation: Set HEAD + checkout tree
|
||||
|
||||
### 2.7 — `git_checkout_theirs(repo_path, paths: Vec<String>) -> void`
|
||||
Replaces: `git checkout --theirs -- <file>` (auto-worktree.ts, in loop)
|
||||
Implementation: Resolve index conflict with "theirs" strategy
|
||||
|
||||
### 2.8 — `git_merge_squash(repo_path, branch) -> GitMergeResult`
|
||||
Replaces: `git merge --squash <branch>` (auto-worktree.ts, worktree-manager.ts, 3 calls)
|
||||
Returns: `{ success: bool, conflicts: Vec<String> }`
|
||||
Implementation: Find merge base → merge trees → apply to index
|
||||
|
||||
### 2.9 — `git_merge_abort(repo_path) -> void`
|
||||
Replaces: `git merge --abort` (git-self-heal.ts, worktree-command.ts, 2 calls)
|
||||
Implementation: Reset to ORIG_HEAD, clean merge state
|
||||
|
||||
### 2.10 — `git_rebase_abort(repo_path) -> void`
|
||||
Replaces: `git rebase --abort` (git-self-heal.ts, 1 call)
|
||||
|
||||
### 2.11 — `git_reset_hard(repo_path) -> void`
|
||||
Replaces: `git reset --hard HEAD` (git-self-heal.ts, 1 call)
|
||||
Implementation: `repo.reset(HEAD, Hard)`
|
||||
|
||||
### 2.12 — `git_branch_delete(repo_path, branch, force: bool) -> void`
|
||||
Replaces: `git branch -D/-d <branch>` (5 calls across files)
|
||||
Implementation: `repo.find_branch().delete()`
|
||||
|
||||
### 2.13 — `git_branch_force_reset(repo_path, branch, target) -> void`
|
||||
Replaces: `git branch -f <branch> <target>` (worktree-manager.ts, 1 call)
|
||||
|
||||
### 2.14 — `git_rm_cached(repo_path, paths: Vec<String>, recursive: bool) -> Vec<String>`
|
||||
Replaces: `git rm --cached -r --ignore-unmatch` (git-service.ts, doctor.ts, gitignore.ts, 6 calls)
|
||||
Returns: List of removed paths
|
||||
|
||||
### 2.15 — `git_rm_force(repo_path, paths: Vec<String>) -> void`
|
||||
Replaces: `git rm --force -- <file>` (auto-worktree.ts, 1 call)
|
||||
|
||||
### 2.16 — `git_worktree_add(repo_path, path, branch, create_from?) -> void`
|
||||
Replaces: `git worktree add` commands (worktree-manager.ts, 2 calls)
|
||||
Implementation: `repo.worktree()` API
|
||||
|
||||
### 2.17 — `git_worktree_remove(repo_path, path, force: bool) -> void`
|
||||
Replaces: `git worktree remove --force` (worktree-manager.ts, doctor.ts, 3 calls)
|
||||
|
||||
### 2.18 — `git_worktree_prune(repo_path) -> void`
|
||||
Replaces: `git worktree prune` (worktree-manager.ts, 3 calls)
|
||||
|
||||
### 2.19 — `git_revert_commit(repo_path, sha, no_commit: bool) -> void`
|
||||
Replaces: `git revert --no-commit <sha>` (undo.ts, 1 call)
|
||||
|
||||
### 2.20 — `git_revert_abort(repo_path) -> void`
|
||||
Replaces: `git revert --abort` (undo.ts, 1 call)
|
||||
|
||||
### 2.21 — `git_update_ref(repo_path, refname, target?) -> void`
|
||||
Replaces: `git update-ref <ref> HEAD` and `git update-ref -d <ref>` (git-service.ts, commands.ts, 2 calls)
|
||||
When target is null/empty, deletes the ref.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: TypeScript Bridge Updates (native-git-bridge.ts)
|
||||
|
||||
Add bridge functions for ALL new native functions, each with:
|
||||
1. Native-first implementation
|
||||
2. execSync fallback for when native module unavailable
|
||||
3. Proper error handling
|
||||
4. Type definitions
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Consumer Migration
|
||||
|
||||
Update each TypeScript file to use native bridge functions:
|
||||
|
||||
### 4.1 — git-service.ts
|
||||
- `smartStage()` → use `nativeAddAll()` + `nativeResetPaths()`
|
||||
- `commit()` → use `nativeCommit()`
|
||||
- `autoCommit()` → use `nativeHasStagedChanges()`
|
||||
- `createSnapshot()` → use `nativeUpdateRef()`
|
||||
- Runtime file cleanup → use `nativeRmCached()`
|
||||
- `runPreMergeCheck()` → use `nativeReadFile()` or keep fs.readFileSync (not git)
|
||||
|
||||
### 4.2 — worktree-manager.ts
|
||||
- `getMainBranch()` → use `nativeDetectMainBranch()` (already exists!)
|
||||
- `createWorktree()` → use `nativeWorktreeAdd()`, `nativeBranchForceReset()`
|
||||
- `listWorktrees()` → use `nativeWorktreeList()`
|
||||
- `removeWorktree()` → use `nativeWorktreeRemove()`, `nativeWorktreePrune()`, `nativeBranchDelete()`
|
||||
- `diffWorktreeGSD()` → use `nativeDiffNameStatus()`
|
||||
- `diffWorktreeAll()` → use `nativeDiffNameStatus()`
|
||||
- `diffWorktreeNumstat()` → use `nativeDiffNumstat()`
|
||||
- `getWorktreeGSDDiff()` → use `nativeDiffContent()`
|
||||
- `getWorktreeCodeDiff()` → use `nativeDiffContent()`
|
||||
- `getWorktreeLog()` → use `nativeLogOneline()`
|
||||
- `mergeWorktreeToMain()` → use `nativeMergeSquash()` + `nativeCommit()`
|
||||
|
||||
### 4.3 — auto-worktree.ts
|
||||
- `getCurrentBranch()` → use `nativeGetCurrentBranch()` (already exists!)
|
||||
- `autoCommitDirtyState()` → use `nativeWorkingTreeStatus()` + `nativeAddAll()` + `nativeCommit()`
|
||||
- `mergeMilestoneToMain()` → use native merge, checkout, commit, branch delete
|
||||
|
||||
### 4.4 — auto.ts
|
||||
- `git rev-parse --git-dir` → use `nativeIsRepo()`
|
||||
- `git init -b` → use `nativeInit()`
|
||||
- `git add -A .gsd .gitignore && git commit` → use `nativeAddPaths()` + `nativeCommit()`
|
||||
|
||||
### 4.5 — auto-supervisor.ts
|
||||
- `detectWorkingTreeActivity()` → use `nativeHasChanges()` (already exists!)
|
||||
|
||||
### 4.6 — git-self-heal.ts
|
||||
- `abortAndReset()` → use `nativeMergeAbort()` + `nativeRebaseAbort()` + `nativeResetHard()`
|
||||
|
||||
### 4.7 — guided-flow.ts
|
||||
- Same pattern as auto.ts for init + bootstrap
|
||||
|
||||
### 4.8 — doctor.ts
|
||||
- `git rev-parse --git-dir` → use `nativeIsRepo()`
|
||||
- `git worktree remove --force` → use `nativeWorktreeRemove()`
|
||||
- `git branch --list milestone/*` → use `nativeBranchList()`
|
||||
- `git branch -D` → use `nativeBranchDelete()`
|
||||
- `git ls-files` → use `nativeLsFiles()`
|
||||
- `git rm --cached` → use `nativeRmCached()`
|
||||
- `git branch --format...` → use `nativeBranchList()`
|
||||
|
||||
### 4.9 — gitignore.ts
|
||||
- `untrackRuntimeFiles()` → use `nativeRmCached()`
|
||||
|
||||
### 4.10 — commands.ts
|
||||
- `handleCleanupBranches()` → use `nativeBranchList()`, `nativeBranchListMerged()`, `nativeBranchDelete()`
|
||||
- `handleCleanupSnapshots()` → use `nativeForEachRef()`, `nativeUpdateRef()`
|
||||
|
||||
### 4.11 — undo.ts
|
||||
- `git revert --no-commit` → use `nativeRevertCommit()`
|
||||
- `git revert --abort` → use `nativeRevertAbort()`
|
||||
|
||||
### 4.12 — session-forensics.ts
|
||||
- `getGitChanges()` → use `nativeWorkingTreeStatus()` + `nativeDiffStat()`
|
||||
|
||||
### 4.13 — worktree-command.ts
|
||||
- `git merge --abort` → use `nativeMergeAbort()`
|
||||
|
||||
---
|
||||
|
||||
## Kept as execSync (out of scope)
|
||||
|
||||
- `git push <remote> <branch>` — Credential handling too complex for git2
|
||||
- `cat package.json` — Not a git command (already just fs.readFileSync)
|
||||
- `npm test` / custom commands — Not git operations
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. **Rust functions** (git.rs) — all read functions first, then write functions
|
||||
2. **TypeScript bridge** (native-git-bridge.ts) — add all new bridge functions
|
||||
3. **Consumer migration** — update each .ts file to use bridge functions
|
||||
4. **Remove dead code** — delete local `runGit()` helpers from files that no longer need them
|
||||
5. **Testing** — build native module, run CI, verify all operations work
|
||||
|
||||
---
|
||||
|
||||
## Risk Mitigation
|
||||
|
||||
- Every native function has an execSync fallback in the bridge
|
||||
- Write operations are tested by existing integration tests
|
||||
- git2's vendored libgit2 matches git CLI behavior for standard operations
|
||||
- The `loadNative()` pattern means if ANY native function crashes, ALL functions fall back to CLI
|
||||
|
||||
## Expected Impact
|
||||
|
||||
- **~70 execSync calls eliminated** when native module is available
|
||||
- **Zero process spawns** for git operations in the common path
|
||||
- **Batch operations** (git_batch_info) reduce 3-4 calls to 1
|
||||
- **Type-safe errors** instead of parsing stderr strings
|
||||
- **Consistent cross-platform** behavior via libgit2
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 */ }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import { handleRemote } from "../remote-questions/remote-command.js";
|
|||
import { handleHistory } from "./history.js";
|
||||
import { handleUndo } from "./undo.js";
|
||||
import { handleExport } from "./export.js";
|
||||
import { nativeBranchList, nativeDetectMainBranch, nativeBranchListMerged, nativeBranchDelete, nativeForEachRef, nativeUpdateRef } from "./native-git-bridge.js";
|
||||
|
||||
function dispatchDoctorHeal(pi: ExtensionAPI, scope: string | undefined, reportText: string, structuredIssues: string): void {
|
||||
const workflowPath = process.env.GSD_WORKFLOW_PATH ?? join(process.env.HOME ?? "~", ".pi", "GSD-WORKFLOW.md");
|
||||
|
|
@ -877,12 +878,9 @@ async function handleDryRun(ctx: ExtensionCommandContext, basePath: string): Pro
|
|||
// ─── Branch cleanup handler ──────────────────────────────────────────────────
|
||||
|
||||
async function handleCleanupBranches(ctx: ExtensionCommandContext, basePath: string): Promise<void> {
|
||||
const { execFileSync } = await import("node:child_process");
|
||||
|
||||
let branches: string[];
|
||||
try {
|
||||
const output = execFileSync("git", ["branch", "--list", "gsd/*"], { cwd: basePath, timeout: 10000, encoding: "utf-8" });
|
||||
branches = output.split("\n").map(b => b.trim().replace(/^\* /, "")).filter(Boolean);
|
||||
branches = nativeBranchList(basePath, "gsd/*");
|
||||
} catch {
|
||||
ctx.ui.notify("No GSD branches found.", "info");
|
||||
return;
|
||||
|
|
@ -893,18 +891,11 @@ async function handleCleanupBranches(ctx: ExtensionCommandContext, basePath: str
|
|||
return;
|
||||
}
|
||||
|
||||
let mainBranch: string;
|
||||
try {
|
||||
mainBranch = execFileSync("git", ["symbolic-ref", "refs/remotes/origin/HEAD", "--short"], { cwd: basePath, timeout: 5000, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] })
|
||||
.trim().replace("origin/", "");
|
||||
} catch {
|
||||
mainBranch = "main";
|
||||
}
|
||||
const mainBranch = nativeDetectMainBranch(basePath);
|
||||
|
||||
let merged: string[];
|
||||
try {
|
||||
const output = execFileSync("git", ["branch", "--merged", mainBranch, "--list", "gsd/*"], { cwd: basePath, timeout: 10000, encoding: "utf-8" });
|
||||
merged = output.split("\n").map(b => b.trim()).filter(Boolean);
|
||||
merged = nativeBranchListMerged(basePath, mainBranch, "gsd/*");
|
||||
} catch {
|
||||
merged = [];
|
||||
}
|
||||
|
|
@ -917,7 +908,7 @@ async function handleCleanupBranches(ctx: ExtensionCommandContext, basePath: str
|
|||
let deleted = 0;
|
||||
for (const branch of merged) {
|
||||
try {
|
||||
execFileSync("git", ["branch", "-d", branch], { cwd: basePath, timeout: 5000, stdio: "ignore" });
|
||||
nativeBranchDelete(basePath, branch, false);
|
||||
deleted++;
|
||||
} catch { /* skip branches that can't be deleted */ }
|
||||
}
|
||||
|
|
@ -928,12 +919,9 @@ async function handleCleanupBranches(ctx: ExtensionCommandContext, basePath: str
|
|||
// ─── Snapshot cleanup handler ─────────────────────────────────────────────────
|
||||
|
||||
async function handleCleanupSnapshots(ctx: ExtensionCommandContext, basePath: string): Promise<void> {
|
||||
const { execFileSync } = await import("node:child_process");
|
||||
|
||||
let refs: string[];
|
||||
try {
|
||||
const output = execFileSync("git", ["for-each-ref", "refs/gsd/snapshots/", "--format=%(refname)"], { cwd: basePath, timeout: 10000, encoding: "utf-8" });
|
||||
refs = output.split("\n").filter(Boolean);
|
||||
refs = nativeForEachRef(basePath, "refs/gsd/snapshots/");
|
||||
} catch {
|
||||
ctx.ui.notify("No snapshot refs found.", "info");
|
||||
return;
|
||||
|
|
@ -957,7 +945,7 @@ async function handleCleanupSnapshots(ctx: ExtensionCommandContext, basePath: st
|
|||
const sorted = labelRefs.sort();
|
||||
for (const old of sorted.slice(0, -5)) {
|
||||
try {
|
||||
execFileSync("git", ["update-ref", "-d", old], { cwd: basePath, timeout: 5000, stdio: "ignore" });
|
||||
nativeUpdateRef(basePath, old);
|
||||
pruned++;
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { execSync } from "node:child_process";
|
||||
import { existsSync, mkdirSync } from "node:fs";
|
||||
import { join, sep } from "node:path";
|
||||
|
||||
|
|
@ -9,6 +8,7 @@ import { loadEffectiveGSDPreferences, type GSDPreferences } from "./preferences.
|
|||
import { listWorktrees } from "./worktree-manager.js";
|
||||
import { abortAndReset } from "./git-self-heal.js";
|
||||
import { RUNTIME_EXCLUSION_PATHS } from "./git-service.js";
|
||||
import { nativeIsRepo, nativeWorktreeRemove, nativeBranchList, nativeBranchDelete, nativeLsFiles, nativeRmCached } from "./native-git-bridge.js";
|
||||
|
||||
export type DoctorSeverity = "info" | "warning" | "error";
|
||||
export type DoctorIssueCode =
|
||||
|
|
@ -467,9 +467,7 @@ async function checkGitHealth(
|
|||
shouldFix: (code: DoctorIssueCode) => boolean,
|
||||
): Promise<void> {
|
||||
// Degrade gracefully if not a git repo
|
||||
try {
|
||||
execSync("git rev-parse --git-dir", { cwd: basePath, stdio: "pipe" });
|
||||
} catch {
|
||||
if (!nativeIsRepo(basePath)) {
|
||||
return; // Not a git repo — skip all git health checks
|
||||
}
|
||||
|
||||
|
|
@ -516,7 +514,7 @@ async function checkGitHealth(
|
|||
fixesApplied.push(`skipped removing worktree at ${wt.path} (is cwd)`);
|
||||
} else {
|
||||
try {
|
||||
execSync(`git worktree remove --force "${wt.path}"`, { cwd: basePath, stdio: "pipe" });
|
||||
nativeWorktreeRemove(basePath, wt.path, true);
|
||||
fixesApplied.push(`removed orphaned worktree ${wt.path}`);
|
||||
} catch {
|
||||
fixesApplied.push(`failed to remove worktree ${wt.path}`);
|
||||
|
|
@ -528,11 +526,8 @@ async function checkGitHealth(
|
|||
|
||||
// ── Stale milestone branches ─────────────────────────────────────────
|
||||
try {
|
||||
// Use unquoted glob — single quotes are not interpreted by cmd.exe on Windows,
|
||||
// causing the pattern to match literally instead of as a glob.
|
||||
const branchOutput = execSync("git branch --list milestone/*", { cwd: basePath, stdio: "pipe" }).toString().trim();
|
||||
if (branchOutput) {
|
||||
const branches = branchOutput.split("\n").map(b => b.trim().replace(/^\*\s*/, "")).filter(Boolean);
|
||||
const branches = nativeBranchList(basePath, "milestone/*");
|
||||
if (branches.length > 0) {
|
||||
const worktreeBranches = new Set(milestoneWorktrees.map(wt => wt.branch));
|
||||
|
||||
for (const branch of branches) {
|
||||
|
|
@ -557,7 +552,7 @@ async function checkGitHealth(
|
|||
|
||||
if (shouldFix("stale_milestone_branch")) {
|
||||
try {
|
||||
execSync(`git branch -D "${branch}"`, { cwd: basePath, stdio: "pipe" });
|
||||
nativeBranchDelete(basePath, branch, true);
|
||||
fixesApplied.push(`deleted stale branch ${branch}`);
|
||||
} catch {
|
||||
fixesApplied.push(`failed to delete branch ${branch}`);
|
||||
|
|
@ -610,9 +605,9 @@ async function checkGitHealth(
|
|||
const trackedPaths: string[] = [];
|
||||
for (const exclusion of RUNTIME_EXCLUSION_PATHS) {
|
||||
try {
|
||||
const output = execSync(`git ls-files "${exclusion}"`, { cwd: basePath, stdio: "pipe" }).toString().trim();
|
||||
if (output) {
|
||||
trackedPaths.push(...output.split("\n").filter(Boolean));
|
||||
const files = nativeLsFiles(basePath, exclusion);
|
||||
if (files.length > 0) {
|
||||
trackedPaths.push(...files);
|
||||
}
|
||||
} catch {
|
||||
// Individual ls-files can fail — continue
|
||||
|
|
@ -632,7 +627,7 @@ async function checkGitHealth(
|
|||
if (shouldFix("tracked_runtime_files")) {
|
||||
try {
|
||||
for (const exclusion of RUNTIME_EXCLUSION_PATHS) {
|
||||
execSync(`git rm --cached -r --ignore-unmatch "${exclusion}"`, { cwd: basePath, stdio: "pipe" });
|
||||
nativeRmCached(basePath, [exclusion]);
|
||||
}
|
||||
fixesApplied.push(`untracked ${trackedPaths.length} runtime file(s)`);
|
||||
} catch {
|
||||
|
|
@ -646,13 +641,8 @@ async function checkGitHealth(
|
|||
|
||||
// ── Legacy slice branches ──────────────────────────────────────────────
|
||||
try {
|
||||
const sliceBranches = execSync('git branch --format="%(refname:short)" --list "gsd/*/*"', {
|
||||
cwd: basePath,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
encoding: "utf-8",
|
||||
}).trim();
|
||||
if (sliceBranches) {
|
||||
const branchList = sliceBranches.split("\n").map(b => b.trim()).filter(Boolean);
|
||||
const branchList = nativeBranchList(basePath, "gsd/*/*");
|
||||
if (branchList.length > 0) {
|
||||
issues.push({
|
||||
severity: "info",
|
||||
code: "legacy_slice_branches",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
// Native Git Bridge
|
||||
// Provides fast READ-ONLY git operations backed by libgit2 via the Rust native module.
|
||||
// Falls back to execSync git commands when the native module is unavailable.
|
||||
// Provides high-performance git operations backed by libgit2 via the Rust native module.
|
||||
// Falls back to execSync/execFileSync git commands when the native module is unavailable.
|
||||
//
|
||||
// Only READ operations are native — WRITE operations (commit, merge, checkout, push)
|
||||
// remain as execSync calls in git-service.ts.
|
||||
// Both READ and WRITE operations are native — push operations remain as
|
||||
// execSync calls because git2 credential handling is too complex.
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { execSync, execFileSync } from "node:child_process";
|
||||
import { existsSync, readFileSync, unlinkSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
/** Env overlay that suppresses interactive git credential prompts and git-svn noise. */
|
||||
const GIT_NO_PROMPT_ENV = {
|
||||
|
|
@ -15,7 +17,54 @@ const GIT_NO_PROMPT_ENV = {
|
|||
GIT_SVN_ID: "",
|
||||
};
|
||||
|
||||
// ─── Native Module Types ──────────────────────────────────────────────────
|
||||
|
||||
interface GitDiffStat {
|
||||
filesChanged: number;
|
||||
insertions: number;
|
||||
deletions: number;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
interface GitNameStatus {
|
||||
status: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface GitNumstat {
|
||||
added: number;
|
||||
removed: number;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface GitLogEntry {
|
||||
sha: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface GitWorktreeEntry {
|
||||
path: string;
|
||||
branch: string;
|
||||
isBare: boolean;
|
||||
}
|
||||
|
||||
interface GitBatchInfo {
|
||||
branch: string;
|
||||
hasChanges: boolean;
|
||||
status: string;
|
||||
stagedCount: number;
|
||||
unstagedCount: number;
|
||||
}
|
||||
|
||||
interface GitMergeResult {
|
||||
success: boolean;
|
||||
conflicts: string[];
|
||||
}
|
||||
|
||||
// ─── Native Module Loading ──────────────────────────────────────────────────
|
||||
|
||||
let nativeModule: {
|
||||
// Existing read functions
|
||||
gitCurrentBranch: (repoPath: string) => string | null;
|
||||
gitMainBranch: (repoPath: string) => string;
|
||||
gitBranchExists: (repoPath: string, branch: string) => boolean;
|
||||
|
|
@ -23,6 +72,43 @@ let nativeModule: {
|
|||
gitWorkingTreeStatus: (repoPath: string) => string;
|
||||
gitHasChanges: (repoPath: string) => boolean;
|
||||
gitCommitCountBetween: (repoPath: string, fromRef: string, toRef: string) => number;
|
||||
// New read functions
|
||||
gitIsRepo: (path: string) => boolean;
|
||||
gitHasStagedChanges: (repoPath: string) => boolean;
|
||||
gitDiffStat: (repoPath: string, fromRef: string, toRef: string) => GitDiffStat;
|
||||
gitDiffNameStatus: (repoPath: string, fromRef: string, toRef: string, pathspec?: string, useMergeBase?: boolean) => GitNameStatus[];
|
||||
gitDiffNumstat: (repoPath: string, fromRef: string, toRef: string) => GitNumstat[];
|
||||
gitDiffContent: (repoPath: string, fromRef: string, toRef: string, pathspec?: string, exclude?: string, useMergeBase?: boolean) => string;
|
||||
gitLogOneline: (repoPath: string, fromRef: string, toRef: string) => GitLogEntry[];
|
||||
gitWorktreeList: (repoPath: string) => GitWorktreeEntry[];
|
||||
gitBranchList: (repoPath: string, pattern?: string) => string[];
|
||||
gitBranchListMerged: (repoPath: string, target: string, pattern?: string) => string[];
|
||||
gitLsFiles: (repoPath: string, pathspec: string) => string[];
|
||||
gitForEachRef: (repoPath: string, prefix: string) => string[];
|
||||
gitConflictFiles: (repoPath: string) => string[];
|
||||
gitBatchInfo: (repoPath: string) => GitBatchInfo;
|
||||
// Write functions
|
||||
gitInit: (path: string, initialBranch?: string) => void;
|
||||
gitAddAll: (repoPath: string) => void;
|
||||
gitAddPaths: (repoPath: string, paths: string[]) => void;
|
||||
gitResetPaths: (repoPath: string, paths: string[]) => void;
|
||||
gitCommit: (repoPath: string, message: string, allowEmpty?: boolean) => string;
|
||||
gitCheckoutBranch: (repoPath: string, branch: string) => void;
|
||||
gitCheckoutTheirs: (repoPath: string, paths: string[]) => void;
|
||||
gitMergeSquash: (repoPath: string, branch: string) => GitMergeResult;
|
||||
gitMergeAbort: (repoPath: string) => void;
|
||||
gitRebaseAbort: (repoPath: string) => void;
|
||||
gitResetHard: (repoPath: string) => void;
|
||||
gitBranchDelete: (repoPath: string, branch: string, force?: boolean) => void;
|
||||
gitBranchForceReset: (repoPath: string, branch: string, target: string) => void;
|
||||
gitRmCached: (repoPath: string, paths: string[], recursive?: boolean) => string[];
|
||||
gitRmForce: (repoPath: string, paths: string[]) => void;
|
||||
gitWorktreeAdd: (repoPath: string, wtPath: string, branch: string, createBranch?: boolean, startPoint?: string) => void;
|
||||
gitWorktreeRemove: (repoPath: string, wtPath: string, force?: boolean) => void;
|
||||
gitWorktreePrune: (repoPath: string) => void;
|
||||
gitRevertCommit: (repoPath: string, sha: string) => void;
|
||||
gitRevertAbort: (repoPath: string) => void;
|
||||
gitUpdateRef: (repoPath: string, refname: string, target?: string) => void;
|
||||
} | null = null;
|
||||
|
||||
let loadAttempted = false;
|
||||
|
|
@ -44,6 +130,8 @@ function loadNative(): typeof nativeModule {
|
|||
return nativeModule;
|
||||
}
|
||||
|
||||
// ─── Fallback Helpers ──────────────────────────────────────────────────────
|
||||
|
||||
/** Run a git command via execFileSync. Returns trimmed stdout. */
|
||||
function gitExec(basePath: string, args: string[], allowFailure = false): string {
|
||||
try {
|
||||
|
|
@ -59,6 +147,22 @@ function gitExec(basePath: string, args: string[], allowFailure = false): string
|
|||
}
|
||||
}
|
||||
|
||||
/** Run a git command via execFileSync. Returns trimmed stdout. */
|
||||
function gitFileExec(basePath: string, args: string[], allowFailure = false): string {
|
||||
try {
|
||||
return execFileSync("git", args, {
|
||||
cwd: basePath,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
encoding: "utf-8",
|
||||
}).trim();
|
||||
} catch {
|
||||
if (allowFailure) return "";
|
||||
throw new Error(`git ${args.join(" ")} failed in ${basePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Existing Read Functions ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get the current branch name.
|
||||
* Native: reads HEAD symbolic ref via libgit2.
|
||||
|
|
@ -77,10 +181,6 @@ export function nativeGetCurrentBranch(basePath: string): string {
|
|||
* Detect the repo-level main branch (origin/HEAD → main → master → current).
|
||||
* Native: checks refs via libgit2.
|
||||
* Fallback: `git symbolic-ref` + `git show-ref` chain.
|
||||
*
|
||||
* Note: milestone integration branch and worktree detection are handled
|
||||
* by the caller (GitServiceImpl.getMainBranch) — this only covers the
|
||||
* repo-level default detection that spawned multiple git processes.
|
||||
*/
|
||||
export function nativeDetectMainBranch(basePath: string): string {
|
||||
const native = loadNative();
|
||||
|
|
@ -88,7 +188,6 @@ export function nativeDetectMainBranch(basePath: string): string {
|
|||
return native.gitMainBranch(basePath);
|
||||
}
|
||||
|
||||
// Fallback: same logic as GitServiceImpl.getMainBranch() repo-level detection
|
||||
const symbolic = gitExec(basePath, ["symbolic-ref", "refs/remotes/origin/HEAD"], true);
|
||||
if (symbolic) {
|
||||
const match = symbolic.match(/refs\/remotes\/origin\/(.+)$/);
|
||||
|
|
@ -173,9 +272,741 @@ export function nativeCommitCountBetween(basePath: string, fromRef: string, toRe
|
|||
return parseInt(result, 10) || 0;
|
||||
}
|
||||
|
||||
// ─── New Read Functions ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Check if a path is inside a git repository.
|
||||
* Native: Repository::open() check.
|
||||
* Fallback: `git rev-parse --git-dir`.
|
||||
*/
|
||||
export function nativeIsRepo(basePath: string): boolean {
|
||||
const native = loadNative();
|
||||
if (native) {
|
||||
return native.gitIsRepo(basePath);
|
||||
}
|
||||
try {
|
||||
execSync("git rev-parse --git-dir", { cwd: basePath, stdio: "pipe" });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there are staged changes (index differs from HEAD).
|
||||
* Native: libgit2 tree-to-index diff.
|
||||
* Fallback: `git diff --cached --stat`.
|
||||
*/
|
||||
export function nativeHasStagedChanges(basePath: string): boolean {
|
||||
const native = loadNative();
|
||||
if (native) {
|
||||
return native.gitHasStagedChanges(basePath);
|
||||
}
|
||||
const result = gitExec(basePath, ["diff", "--cached", "--stat"], true);
|
||||
return result !== "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get diff statistics.
|
||||
* Use fromRef="HEAD", toRef="WORKDIR" for working tree diff.
|
||||
* Use fromRef="HEAD", toRef="INDEX" for staged diff.
|
||||
* Native: libgit2 diff stats.
|
||||
* Fallback: `git diff --stat`.
|
||||
*/
|
||||
export function nativeDiffStat(basePath: string, fromRef: string, toRef: string): GitDiffStat {
|
||||
const native = loadNative();
|
||||
if (native) {
|
||||
return native.gitDiffStat(basePath, fromRef, toRef);
|
||||
}
|
||||
|
||||
// Fallback
|
||||
let args: string[];
|
||||
if (fromRef === "HEAD" && toRef === "WORKDIR") {
|
||||
args = ["diff", "--stat", "HEAD"];
|
||||
} else if (fromRef === "HEAD" && toRef === "INDEX") {
|
||||
args = ["diff", "--stat", "--cached", "HEAD"];
|
||||
} else {
|
||||
args = ["diff", "--stat", fromRef, toRef];
|
||||
}
|
||||
|
||||
const result = gitExec(basePath, args, true);
|
||||
// Parse numeric stats from the summary line (e.g. "3 files changed, 10 insertions(+), 2 deletions(-)")
|
||||
let filesChanged = 0, insertions = 0, deletions = 0;
|
||||
const statsMatch = result.match(/(\d+) files? changed(?:, (\d+) insertions?\(\+\))?(?:, (\d+) deletions?\(-\))?/);
|
||||
if (statsMatch) {
|
||||
filesChanged = parseInt(statsMatch[1] ?? "0", 10);
|
||||
insertions = parseInt(statsMatch[2] ?? "0", 10);
|
||||
deletions = parseInt(statsMatch[3] ?? "0", 10);
|
||||
}
|
||||
return { filesChanged, insertions, deletions, summary: result };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get name-status diff between two refs with optional pathspec filter.
|
||||
* useMergeBase: if true, uses three-dot semantics (main...branch).
|
||||
* Native: libgit2 tree-to-tree diff.
|
||||
* Fallback: `git diff --name-status`.
|
||||
*/
|
||||
export function nativeDiffNameStatus(
|
||||
basePath: string,
|
||||
fromRef: string,
|
||||
toRef: string,
|
||||
pathspec?: string,
|
||||
useMergeBase?: boolean,
|
||||
): GitNameStatus[] {
|
||||
const native = loadNative();
|
||||
if (native) {
|
||||
return native.gitDiffNameStatus(basePath, fromRef, toRef, pathspec, useMergeBase);
|
||||
}
|
||||
|
||||
// Fallback
|
||||
const separator = useMergeBase ? "..." : " ";
|
||||
const args = ["diff", "--name-status", `${fromRef}${separator}${toRef}`];
|
||||
if (pathspec) args.push("--", pathspec);
|
||||
|
||||
const result = gitExec(basePath, args, true);
|
||||
if (!result) return [];
|
||||
|
||||
return result.split("\n").filter(Boolean).map(line => {
|
||||
const [status, ...pathParts] = line.split("\t");
|
||||
return { status: status ?? "", path: pathParts.join("\t") };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get numstat diff between two refs.
|
||||
* Native: libgit2 patch line stats.
|
||||
* Fallback: `git diff --numstat`.
|
||||
*/
|
||||
export function nativeDiffNumstat(basePath: string, fromRef: string, toRef: string): GitNumstat[] {
|
||||
const native = loadNative();
|
||||
if (native) {
|
||||
return native.gitDiffNumstat(basePath, fromRef, toRef);
|
||||
}
|
||||
|
||||
const result = gitExec(basePath, ["diff", "--numstat", fromRef, toRef], true);
|
||||
if (!result) return [];
|
||||
|
||||
return result.split("\n").filter(Boolean).map(line => {
|
||||
const [a, r, ...pathParts] = line.split("\t");
|
||||
return {
|
||||
added: a === "-" ? 0 : parseInt(a ?? "0", 10),
|
||||
removed: r === "-" ? 0 : parseInt(r ?? "0", 10),
|
||||
path: pathParts.join("\t"),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unified diff content between two refs.
|
||||
* useMergeBase: if true, uses three-dot semantics.
|
||||
* Native: libgit2 diff print.
|
||||
* Fallback: `git diff`.
|
||||
*/
|
||||
export function nativeDiffContent(
|
||||
basePath: string,
|
||||
fromRef: string,
|
||||
toRef: string,
|
||||
pathspec?: string,
|
||||
exclude?: string,
|
||||
useMergeBase?: boolean,
|
||||
): string {
|
||||
const native = loadNative();
|
||||
if (native) {
|
||||
return native.gitDiffContent(basePath, fromRef, toRef, pathspec, exclude, useMergeBase);
|
||||
}
|
||||
|
||||
const separator = useMergeBase ? "..." : " ";
|
||||
const args = ["diff", `${fromRef}${separator}${toRef}`];
|
||||
if (pathspec) {
|
||||
args.push("--", pathspec);
|
||||
} else if (exclude) {
|
||||
args.push("--", ".", `:(exclude)${exclude}`);
|
||||
}
|
||||
|
||||
return gitExec(basePath, args, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get commit log between two refs (from..to).
|
||||
* Native: libgit2 revwalk.
|
||||
* Fallback: `git log --oneline from..to`.
|
||||
*/
|
||||
export function nativeLogOneline(basePath: string, fromRef: string, toRef: string): GitLogEntry[] {
|
||||
const native = loadNative();
|
||||
if (native) {
|
||||
return native.gitLogOneline(basePath, fromRef, toRef);
|
||||
}
|
||||
|
||||
const result = gitExec(basePath, ["log", "--oneline", `${fromRef}..${toRef}`], true);
|
||||
if (!result) return [];
|
||||
|
||||
return result.split("\n").filter(Boolean).map(line => {
|
||||
const sha = line.substring(0, 7);
|
||||
const message = line.substring(8);
|
||||
return { sha, message };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* List git worktrees.
|
||||
* Native: libgit2 worktree API.
|
||||
* Fallback: `git worktree list --porcelain`.
|
||||
*/
|
||||
export function nativeWorktreeList(basePath: string): GitWorktreeEntry[] {
|
||||
const native = loadNative();
|
||||
if (native) {
|
||||
return native.gitWorktreeList(basePath);
|
||||
}
|
||||
|
||||
const result = gitExec(basePath, ["worktree", "list", "--porcelain"], true);
|
||||
if (!result) return [];
|
||||
|
||||
const entries: GitWorktreeEntry[] = [];
|
||||
const blocks = result.replaceAll("\r\n", "\n").split("\n\n").filter(Boolean);
|
||||
|
||||
for (const block of blocks) {
|
||||
const lines = block.split("\n");
|
||||
const wtLine = lines.find(l => l.startsWith("worktree "));
|
||||
const branchLine = lines.find(l => l.startsWith("branch "));
|
||||
const isBare = lines.some(l => l === "bare");
|
||||
|
||||
if (wtLine) {
|
||||
entries.push({
|
||||
path: wtLine.replace("worktree ", ""),
|
||||
branch: branchLine ? branchLine.replace("branch refs/heads/", "") : "",
|
||||
isBare,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* List branches matching an optional pattern.
|
||||
* Native: libgit2 branch iterator.
|
||||
* Fallback: `git branch --list <pattern>`.
|
||||
*/
|
||||
export function nativeBranchList(basePath: string, pattern?: string): string[] {
|
||||
const native = loadNative();
|
||||
if (native) {
|
||||
return native.gitBranchList(basePath, pattern);
|
||||
}
|
||||
|
||||
const args = ["branch", "--list"];
|
||||
if (pattern) args.push(pattern);
|
||||
|
||||
const result = gitFileExec(basePath, args, true);
|
||||
if (!result) return [];
|
||||
|
||||
return result.split("\n").map(b => b.trim().replace(/^\* /, "")).filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* List branches merged into target.
|
||||
* Native: libgit2 merge-base check.
|
||||
* Fallback: `git branch --merged <target> --list <pattern>`.
|
||||
*/
|
||||
export function nativeBranchListMerged(basePath: string, target: string, pattern?: string): string[] {
|
||||
const native = loadNative();
|
||||
if (native) {
|
||||
return native.gitBranchListMerged(basePath, target, pattern);
|
||||
}
|
||||
|
||||
const args = ["branch", "--merged", target];
|
||||
if (pattern) args.push("--list", pattern);
|
||||
|
||||
const result = gitFileExec(basePath, args, true);
|
||||
if (!result) return [];
|
||||
|
||||
return result.split("\n").map(b => b.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* List tracked files matching a pathspec.
|
||||
* Native: libgit2 index iteration.
|
||||
* Fallback: `git ls-files <pathspec>`.
|
||||
*/
|
||||
export function nativeLsFiles(basePath: string, pathspec: string): string[] {
|
||||
const native = loadNative();
|
||||
if (native) {
|
||||
return native.gitLsFiles(basePath, pathspec);
|
||||
}
|
||||
|
||||
const result = gitFileExec(basePath, ["ls-files", pathspec], true);
|
||||
if (!result) return [];
|
||||
return result.split("\n").filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* List references matching a prefix.
|
||||
* Native: libgit2 references_glob.
|
||||
* Fallback: `git for-each-ref <prefix> --format=%(refname)`.
|
||||
*/
|
||||
export function nativeForEachRef(basePath: string, prefix: string): string[] {
|
||||
const native = loadNative();
|
||||
if (native) {
|
||||
return native.gitForEachRef(basePath, prefix);
|
||||
}
|
||||
|
||||
const result = gitFileExec(basePath, ["for-each-ref", prefix, "--format=%(refname)"], true);
|
||||
if (!result) return [];
|
||||
return result.split("\n").filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of files with unmerged (conflict) entries.
|
||||
* Native: libgit2 index conflicts.
|
||||
* Fallback: `git diff --name-only --diff-filter=U`.
|
||||
*/
|
||||
export function nativeConflictFiles(basePath: string): string[] {
|
||||
const native = loadNative();
|
||||
if (native) {
|
||||
return native.gitConflictFiles(basePath);
|
||||
}
|
||||
|
||||
const result = gitExec(basePath, ["diff", "--name-only", "--diff-filter=U"], true);
|
||||
if (!result) return [];
|
||||
return result.split("\n").filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get batch info: branch + status + change counts in ONE call.
|
||||
* Native: single libgit2 call replaces 3-4 sequential execSync calls.
|
||||
* Fallback: multiple git commands.
|
||||
*/
|
||||
export function nativeBatchInfo(basePath: string): GitBatchInfo {
|
||||
const native = loadNative();
|
||||
if (native) {
|
||||
return native.gitBatchInfo(basePath);
|
||||
}
|
||||
|
||||
const branch = gitExec(basePath, ["branch", "--show-current"], true);
|
||||
const status = gitExec(basePath, ["status", "--porcelain"], true);
|
||||
const hasChanges = status !== "";
|
||||
|
||||
// Parse porcelain status to count staged vs unstaged changes
|
||||
let stagedCount = 0;
|
||||
let unstagedCount = 0;
|
||||
if (status) {
|
||||
for (const line of status.split("\n")) {
|
||||
if (!line || line.length < 2) continue;
|
||||
const x = line[0]; // index (staged) status
|
||||
const y = line[1]; // worktree (unstaged) status
|
||||
if (x !== " " && x !== "?") stagedCount++;
|
||||
if (y !== " " && y !== "?") unstagedCount++;
|
||||
if (x === "?" && y === "?") unstagedCount++; // untracked files
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
branch,
|
||||
hasChanges,
|
||||
status,
|
||||
stagedCount,
|
||||
unstagedCount,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Write Functions ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Initialize a new git repository.
|
||||
* Native: libgit2 Repository::init.
|
||||
* Fallback: `git init -b <branch>`.
|
||||
*/
|
||||
export function nativeInit(basePath: string, initialBranch?: string): void {
|
||||
const native = loadNative();
|
||||
if (native) {
|
||||
native.gitInit(basePath, initialBranch);
|
||||
return;
|
||||
}
|
||||
|
||||
const args = ["init"];
|
||||
if (initialBranch) args.push("-b", initialBranch);
|
||||
gitFileExec(basePath, args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage all files (git add -A).
|
||||
* Native: libgit2 index add_all + update_all.
|
||||
* Fallback: `git add -A`.
|
||||
*/
|
||||
export function nativeAddAll(basePath: string): void {
|
||||
const native = loadNative();
|
||||
if (native) {
|
||||
native.gitAddAll(basePath);
|
||||
return;
|
||||
}
|
||||
gitFileExec(basePath, ["add", "-A"]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage specific files.
|
||||
* Native: libgit2 index add.
|
||||
* Fallback: `git add -- <paths>`.
|
||||
*/
|
||||
export function nativeAddPaths(basePath: string, paths: string[]): void {
|
||||
const native = loadNative();
|
||||
if (native) {
|
||||
native.gitAddPaths(basePath, paths);
|
||||
return;
|
||||
}
|
||||
gitFileExec(basePath, ["add", "--", ...paths]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unstage files (reset index entries to HEAD).
|
||||
* Native: libgit2 reset_default.
|
||||
* Fallback: `git reset HEAD -- <paths>`.
|
||||
*/
|
||||
export function nativeResetPaths(basePath: string, paths: string[]): void {
|
||||
const native = loadNative();
|
||||
if (native) {
|
||||
native.gitResetPaths(basePath, paths);
|
||||
return;
|
||||
}
|
||||
for (const p of paths) {
|
||||
gitExec(basePath, ["reset", "HEAD", "--", p], true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a commit from the current index.
|
||||
* Returns the commit SHA on success, or null if nothing to commit.
|
||||
* Native: libgit2 commit create.
|
||||
* Fallback: `git commit --no-verify -F -`.
|
||||
*/
|
||||
export function nativeCommit(
|
||||
basePath: string,
|
||||
message: string,
|
||||
options?: { allowEmpty?: boolean; input?: string },
|
||||
): string | null {
|
||||
const native = loadNative();
|
||||
if (native) {
|
||||
try {
|
||||
return native.gitCommit(basePath, message, options?.allowEmpty);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
if (msg.includes("nothing to commit")) return null;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: use git commit with stdin pipe for safe multi-line messages
|
||||
try {
|
||||
const result = execSync(
|
||||
`git commit --no-verify -F -${options?.allowEmpty ? " --allow-empty" : ""}`,
|
||||
{
|
||||
cwd: basePath,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
encoding: "utf-8",
|
||||
env: GIT_NO_PROMPT_ENV,
|
||||
input: message,
|
||||
},
|
||||
).trim();
|
||||
return result;
|
||||
} catch (err: unknown) {
|
||||
const errObj = err as { stdout?: string; stderr?: string; message?: string };
|
||||
const combined = [errObj.stdout, errObj.stderr, errObj.message].filter(Boolean).join(" ");
|
||||
if (combined.includes("nothing to commit") || combined.includes("nothing added to commit") || combined.includes("no changes added")) {
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checkout a branch (switch HEAD and update working tree).
|
||||
* Native: libgit2 checkout + set_head.
|
||||
* Fallback: `git checkout <branch>`.
|
||||
*/
|
||||
export function nativeCheckoutBranch(basePath: string, branch: string): void {
|
||||
const native = loadNative();
|
||||
if (native) {
|
||||
native.gitCheckoutBranch(basePath, branch);
|
||||
return;
|
||||
}
|
||||
execSync(`git checkout ${branch}`, {
|
||||
cwd: basePath,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
encoding: "utf-8",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve index conflicts by accepting "theirs" version.
|
||||
* Native: libgit2 index conflict resolution.
|
||||
* Fallback: `git checkout --theirs -- <file>`.
|
||||
*/
|
||||
export function nativeCheckoutTheirs(basePath: string, paths: string[]): void {
|
||||
const native = loadNative();
|
||||
if (native) {
|
||||
native.gitCheckoutTheirs(basePath, paths);
|
||||
return;
|
||||
}
|
||||
for (const path of paths) {
|
||||
gitFileExec(basePath, ["checkout", "--theirs", "--", path]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Squash-merge a branch (stages changes, does NOT commit).
|
||||
* Native: libgit2 merge with squash semantics.
|
||||
* Fallback: `git merge --squash <branch>`.
|
||||
*/
|
||||
export function nativeMergeSquash(basePath: string, branch: string): GitMergeResult {
|
||||
const native = loadNative();
|
||||
if (native) {
|
||||
return native.gitMergeSquash(basePath, branch);
|
||||
}
|
||||
|
||||
try {
|
||||
execSync(`git merge --squash ${branch}`, {
|
||||
cwd: basePath,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
encoding: "utf-8",
|
||||
});
|
||||
return { success: true, conflicts: [] };
|
||||
} catch {
|
||||
// Check for conflicts
|
||||
const conflictOutput = gitExec(basePath, ["diff", "--name-only", "--diff-filter=U"], true);
|
||||
const conflicts = conflictOutput ? conflictOutput.split("\n").filter(Boolean) : [];
|
||||
return { success: conflicts.length === 0, conflicts };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort an in-progress merge.
|
||||
* Native: libgit2 reset + cleanup.
|
||||
* Fallback: `git merge --abort`.
|
||||
*/
|
||||
export function nativeMergeAbort(basePath: string): void {
|
||||
const native = loadNative();
|
||||
if (native) {
|
||||
native.gitMergeAbort(basePath);
|
||||
return;
|
||||
}
|
||||
gitExec(basePath, ["merge", "--abort"], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort an in-progress rebase.
|
||||
* Native: libgit2 reset + cleanup.
|
||||
* Fallback: `git rebase --abort`.
|
||||
*/
|
||||
export function nativeRebaseAbort(basePath: string): void {
|
||||
const native = loadNative();
|
||||
if (native) {
|
||||
native.gitRebaseAbort(basePath);
|
||||
return;
|
||||
}
|
||||
gitExec(basePath, ["rebase", "--abort"], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard reset to HEAD.
|
||||
* Native: libgit2 reset(Hard).
|
||||
* Fallback: `git reset --hard HEAD`.
|
||||
*/
|
||||
export function nativeResetHard(basePath: string): void {
|
||||
const native = loadNative();
|
||||
if (native) {
|
||||
native.gitResetHard(basePath);
|
||||
return;
|
||||
}
|
||||
execSync("git reset --hard HEAD", { cwd: basePath, stdio: "pipe" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a branch.
|
||||
* Native: libgit2 branch delete.
|
||||
* Fallback: `git branch -D/-d <branch>`.
|
||||
*/
|
||||
export function nativeBranchDelete(basePath: string, branch: string, force = true): void {
|
||||
const native = loadNative();
|
||||
if (native) {
|
||||
native.gitBranchDelete(basePath, branch, force);
|
||||
return;
|
||||
}
|
||||
gitFileExec(basePath, ["branch", force ? "-D" : "-d", branch], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Force-reset a branch to point at a target ref.
|
||||
* Native: libgit2 branch create with force.
|
||||
* Fallback: `git branch -f <branch> <target>`.
|
||||
*/
|
||||
export function nativeBranchForceReset(basePath: string, branch: string, target: string): void {
|
||||
const native = loadNative();
|
||||
if (native) {
|
||||
native.gitBranchForceReset(basePath, branch, target);
|
||||
return;
|
||||
}
|
||||
gitExec(basePath, ["branch", "-f", branch, target]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove files from the index (cache) without touching the working tree.
|
||||
* Returns list of removed files.
|
||||
* Native: libgit2 index remove.
|
||||
* Fallback: `git rm --cached -r --ignore-unmatch <path>`.
|
||||
*/
|
||||
export function nativeRmCached(basePath: string, paths: string[], recursive = true): string[] {
|
||||
const native = loadNative();
|
||||
if (native) {
|
||||
return native.gitRmCached(basePath, paths, recursive);
|
||||
}
|
||||
|
||||
const removed: string[] = [];
|
||||
for (const path of paths) {
|
||||
const result = gitExec(
|
||||
basePath,
|
||||
["rm", "--cached", ...(recursive ? ["-r"] : []), "--ignore-unmatch", path],
|
||||
true,
|
||||
);
|
||||
if (result) removed.push(result);
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force-remove files from both index and working tree.
|
||||
* Native: libgit2 index remove + fs delete.
|
||||
* Fallback: `git rm --force -- <file>`.
|
||||
*/
|
||||
export function nativeRmForce(basePath: string, paths: string[]): void {
|
||||
const native = loadNative();
|
||||
if (native) {
|
||||
native.gitRmForce(basePath, paths);
|
||||
return;
|
||||
}
|
||||
for (const path of paths) {
|
||||
gitFileExec(basePath, ["rm", "--force", "--", path], true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new git worktree.
|
||||
* Native: libgit2 worktree API.
|
||||
* Fallback: `git worktree add`.
|
||||
*/
|
||||
export function nativeWorktreeAdd(
|
||||
basePath: string,
|
||||
wtPath: string,
|
||||
branch: string,
|
||||
createBranch?: boolean,
|
||||
startPoint?: string,
|
||||
): void {
|
||||
const native = loadNative();
|
||||
if (native) {
|
||||
native.gitWorktreeAdd(basePath, wtPath, branch, createBranch, startPoint);
|
||||
return;
|
||||
}
|
||||
|
||||
if (createBranch) {
|
||||
gitExec(basePath, ["worktree", "add", "-b", branch, wtPath, startPoint ?? "HEAD"]);
|
||||
} else {
|
||||
gitExec(basePath, ["worktree", "add", wtPath, branch]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a git worktree.
|
||||
* Native: libgit2 worktree prune + fs cleanup.
|
||||
* Fallback: `git worktree remove [--force] <path>`.
|
||||
*/
|
||||
export function nativeWorktreeRemove(basePath: string, wtPath: string, force = false): void {
|
||||
const native = loadNative();
|
||||
if (native) {
|
||||
native.gitWorktreeRemove(basePath, wtPath, force);
|
||||
return;
|
||||
}
|
||||
|
||||
const args = ["worktree", "remove"];
|
||||
if (force) args.push("--force");
|
||||
args.push(wtPath);
|
||||
gitExec(basePath, args, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prune stale worktree entries.
|
||||
* Native: libgit2 worktree validation + prune.
|
||||
* Fallback: `git worktree prune`.
|
||||
*/
|
||||
export function nativeWorktreePrune(basePath: string): void {
|
||||
const native = loadNative();
|
||||
if (native) {
|
||||
native.gitWorktreePrune(basePath);
|
||||
return;
|
||||
}
|
||||
gitExec(basePath, ["worktree", "prune"], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revert a commit without auto-committing.
|
||||
* Native: libgit2 revert.
|
||||
* Fallback: `git revert --no-commit <sha>`.
|
||||
*/
|
||||
export function nativeRevertCommit(basePath: string, sha: string): void {
|
||||
const native = loadNative();
|
||||
if (native) {
|
||||
native.gitRevertCommit(basePath, sha);
|
||||
return;
|
||||
}
|
||||
gitFileExec(basePath, ["revert", "--no-commit", sha]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort an in-progress revert.
|
||||
* Native: libgit2 reset + cleanup.
|
||||
* Fallback: `git revert --abort`.
|
||||
*/
|
||||
export function nativeRevertAbort(basePath: string): void {
|
||||
const native = loadNative();
|
||||
if (native) {
|
||||
native.gitRevertAbort(basePath);
|
||||
return;
|
||||
}
|
||||
gitFileExec(basePath, ["revert", "--abort"], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or delete a ref.
|
||||
* When target is provided, creates/updates the ref. When undefined, deletes it.
|
||||
* Native: libgit2 reference create/delete.
|
||||
* Fallback: `git update-ref`.
|
||||
*/
|
||||
export function nativeUpdateRef(basePath: string, refname: string, target?: string): void {
|
||||
const native = loadNative();
|
||||
if (native) {
|
||||
native.gitUpdateRef(basePath, refname, target);
|
||||
return;
|
||||
}
|
||||
|
||||
if (target !== undefined) {
|
||||
gitExec(basePath, ["update-ref", refname, target]);
|
||||
} else {
|
||||
gitExec(basePath, ["update-ref", "-d", refname], true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the native git module is available.
|
||||
*/
|
||||
export function isNativeGitAvailable(): boolean {
|
||||
return loadNative() !== null;
|
||||
}
|
||||
|
||||
// ─── Re-exports for type consumers ──────────────────────────────────────
|
||||
|
||||
export type {
|
||||
GitDiffStat,
|
||||
GitNameStatus,
|
||||
GitNumstat,
|
||||
GitLogEntry,
|
||||
GitWorktreeEntry,
|
||||
GitBatchInfo,
|
||||
GitMergeResult,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue