singularity-forge/src/resources/extensions/gsd/git-self-heal.ts
Flux Labs 343a43f028 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
2026-03-15 20:02:10 -06:00

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.`;
}