* 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
127 lines
4.2 KiB
TypeScript
127 lines
4.2 KiB
TypeScript
/**
|
|
* git-self-heal.ts — Automated git state recovery utilities.
|
|
*
|
|
* Four synchronous functions for recovering from broken git state
|
|
* during auto-mode operations. Uses only `git reset --hard HEAD` —
|
|
* never `git clean` (which would delete untracked .gsd/ dirs).
|
|
*
|
|
* Observability: Each function returns structured results describing
|
|
* what actions were taken. `formatGitError` maps raw git errors to
|
|
* user-friendly messages suggesting `/gsd doctor`.
|
|
*/
|
|
|
|
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 };
|
|
|
|
/** Result from abortAndReset describing what was cleaned up. */
|
|
export interface AbortAndResetResult {
|
|
/** List of actions taken, e.g. ["aborted merge", "removed SQUASH_MSG", "reset to HEAD"] */
|
|
cleaned: string[];
|
|
}
|
|
|
|
/**
|
|
* Detect and clean up leftover merge/rebase state, then hard-reset.
|
|
*
|
|
* Checks for: .git/MERGE_HEAD, .git/SQUASH_MSG, .git/rebase-apply.
|
|
* Aborts in-progress merge or rebase if detected. Always finishes
|
|
* with `git reset --hard HEAD`.
|
|
*
|
|
* @returns Structured result listing what was cleaned. Empty `cleaned`
|
|
* array means repo was already in a clean state.
|
|
*/
|
|
export function abortAndReset(cwd: string): AbortAndResetResult {
|
|
const gitDir = join(cwd, ".git");
|
|
const cleaned: string[] = [];
|
|
|
|
// Abort in-progress merge
|
|
if (existsSync(join(gitDir, "MERGE_HEAD"))) {
|
|
try {
|
|
nativeMergeAbort(cwd);
|
|
cleaned.push("aborted merge");
|
|
} catch {
|
|
// merge --abort can fail if state is really broken; continue to reset
|
|
cleaned.push("merge abort attempted (may have failed)");
|
|
}
|
|
}
|
|
|
|
// Remove leftover SQUASH_MSG (squash-merge leaves this without MERGE_HEAD)
|
|
const squashMsgPath = join(gitDir, "SQUASH_MSG");
|
|
if (existsSync(squashMsgPath)) {
|
|
try {
|
|
unlinkSync(squashMsgPath);
|
|
cleaned.push("removed SQUASH_MSG");
|
|
} catch {
|
|
// Not critical
|
|
}
|
|
}
|
|
|
|
// Abort in-progress rebase
|
|
if (existsSync(join(gitDir, "rebase-apply")) || existsSync(join(gitDir, "rebase-merge"))) {
|
|
try {
|
|
nativeRebaseAbort(cwd);
|
|
cleaned.push("aborted rebase");
|
|
} catch {
|
|
cleaned.push("rebase abort attempted (may have failed)");
|
|
}
|
|
}
|
|
|
|
// Always hard-reset to HEAD
|
|
try {
|
|
nativeResetHard(cwd);
|
|
if (cleaned.length > 0) {
|
|
cleaned.push("reset to HEAD");
|
|
}
|
|
} catch {
|
|
cleaned.push("reset to HEAD failed");
|
|
}
|
|
|
|
return { cleaned };
|
|
}
|
|
|
|
/** Known git error patterns mapped to user-friendly messages. */
|
|
const ERROR_PATTERNS: Array<{ pattern: RegExp; message: string }> = [
|
|
{
|
|
pattern: /conflict|CONFLICT|merge conflict/i,
|
|
message: "A merge conflict occurred. Code changes on different branches touched the same files. Run `/gsd doctor` to diagnose.",
|
|
},
|
|
{
|
|
pattern: /cannot checkout|did not match any|pathspec .* did not match/i,
|
|
message: "Git could not switch branches — the target branch may not exist or the working tree is dirty. Run `/gsd doctor` to diagnose.",
|
|
},
|
|
{
|
|
pattern: /HEAD detached|detached HEAD/i,
|
|
message: "Git is in a detached HEAD state — not on any branch. Run `/gsd doctor` to diagnose and reattach.",
|
|
},
|
|
{
|
|
pattern: /\.lock|Unable to create .* lock|lock file/i,
|
|
message: "A git lock file is blocking operations. Another git process may be running, or a previous one crashed. Run `/gsd doctor` to diagnose.",
|
|
},
|
|
{
|
|
pattern: /fatal: not a git repository/i,
|
|
message: "This directory is not a git repository. Run `/gsd doctor` to check your project setup.",
|
|
},
|
|
];
|
|
|
|
/**
|
|
* Translate raw git error strings into user-friendly messages.
|
|
*
|
|
* Pattern-matches against common git error strings and returns
|
|
* a non-technical message suggesting `/gsd doctor`. Returns the
|
|
* original message if no pattern matches.
|
|
*/
|
|
export function formatGitError(error: string | Error): string {
|
|
const errorStr = error instanceof Error ? error.message : error;
|
|
|
|
for (const { pattern, message } of ERROR_PATTERNS) {
|
|
if (pattern.test(errorStr)) {
|
|
return message;
|
|
}
|
|
}
|
|
|
|
return `A git error occurred: ${errorStr.slice(0, 200)}. Run \`/gsd doctor\` for help.`;
|
|
}
|