feat(native): add libgit2-backed git read operations for dispatch hotpath (#388)

This commit is contained in:
TÂCHES 2026-03-14 13:07:02 -06:00 committed by GitHub
parent ea439b99e4
commit 33cf0dcabd
7 changed files with 805 additions and 39 deletions

362
native/Cargo.lock generated
View file

@ -149,6 +149,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2"
dependencies = [
"find-msvc-tools",
"jobserver",
"libc",
"shlex",
]
@ -188,7 +190,7 @@ version = "0.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e"
dependencies = [
"getrandom",
"getrandom 0.2.17",
"once_cell",
"tiny-keccak",
]
@ -276,6 +278,17 @@ dependencies = [
"objc2",
]
[[package]]
name = "displaydoc"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "either"
version = "1.15.0"
@ -396,6 +409,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "form_urlencoded"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
dependencies = [
"percent-encoding",
]
[[package]]
name = "gethostname"
version = "1.1.0"
@ -417,6 +439,18 @@ dependencies = [
"wasi",
]
[[package]]
name = "getrandom"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"wasip2",
]
[[package]]
name = "gif"
version = "0.14.1"
@ -427,6 +461,19 @@ dependencies = [
"weezl",
]
[[package]]
name = "git2"
version = "0.20.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b"
dependencies = [
"bitflags",
"libc",
"libgit2-sys",
"log",
"url",
]
[[package]]
name = "globset"
version = "0.4.18"
@ -533,6 +580,7 @@ version = "0.1.0"
dependencies = [
"arboard",
"dashmap",
"git2",
"globset",
"gsd-ast",
"gsd-grep",
@ -629,6 +677,108 @@ dependencies = [
"markup5ever",
]
[[package]]
name = "icu_collections"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43"
dependencies = [
"displaydoc",
"potential_utf",
"yoke",
"zerofrom",
"zerovec",
]
[[package]]
name = "icu_locale_core"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6"
dependencies = [
"displaydoc",
"litemap",
"tinystr",
"writeable",
"zerovec",
]
[[package]]
name = "icu_normalizer"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599"
dependencies = [
"icu_collections",
"icu_normalizer_data",
"icu_properties",
"icu_provider",
"smallvec",
"zerovec",
]
[[package]]
name = "icu_normalizer_data"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a"
[[package]]
name = "icu_properties"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec"
dependencies = [
"icu_collections",
"icu_locale_core",
"icu_properties_data",
"icu_provider",
"zerotrie",
"zerovec",
]
[[package]]
name = "icu_properties_data"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af"
[[package]]
name = "icu_provider"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614"
dependencies = [
"displaydoc",
"icu_locale_core",
"writeable",
"yoke",
"zerofrom",
"zerotrie",
"zerovec",
]
[[package]]
name = "idna"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
dependencies = [
"idna_adapter",
"smallvec",
"utf8_iter",
]
[[package]]
name = "idna_adapter"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344"
dependencies = [
"icu_normalizer",
"icu_properties",
]
[[package]]
name = "ignore"
version = "0.4.25"
@ -690,12 +840,34 @@ version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
[[package]]
name = "jobserver"
version = "0.1.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
dependencies = [
"getrandom 0.3.4",
"libc",
]
[[package]]
name = "libc"
version = "0.2.183"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
[[package]]
name = "libgit2-sys"
version = "0.18.3+1.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9b3acc4b91781bb0b3386669d325163746af5f6e4f73e6d2d630e09a35f3487"
dependencies = [
"cc",
"libc",
"libz-sys",
"pkg-config",
]
[[package]]
name = "libloading"
version = "0.8.9"
@ -706,12 +878,30 @@ dependencies = [
"windows-link",
]
[[package]]
name = "libz-sys"
version = "1.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d52f4c29e2a68ac30c9087e1b772dc9f44a2b66ed44edf2266cf2be9b03dafc1"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "litemap"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
[[package]]
name = "lock_api"
version = "0.4.14"
@ -1015,6 +1205,12 @@ dependencies = [
"siphasher",
]
[[package]]
name = "pkg-config"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "png"
version = "0.18.1"
@ -1028,6 +1224,15 @@ dependencies = [
"miniz_oxide",
]
[[package]]
name = "potential_utf"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77"
dependencies = [
"zerovec",
]
[[package]]
name = "precomputed-hash"
version = "0.1.1"
@ -1064,6 +1269,12 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "rayon"
version = "1.11.0"
@ -1229,6 +1440,12 @@ version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "streaming-iterator"
version = "0.1.9"
@ -1270,6 +1487,17 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "synstructure"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "syntect"
version = "5.3.0"
@ -1341,6 +1569,16 @@ dependencies = [
"crunchy",
]
[[package]]
name = "tinystr"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869"
dependencies = [
"displaydoc",
"zerovec",
]
[[package]]
name = "tree-sitter"
version = "0.25.10"
@ -1749,6 +1987,18 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
name = "url"
version = "2.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
dependencies = [
"form_urlencoded",
"idna",
"percent-encoding",
"serde",
]
[[package]]
name = "utf-8"
version = "0.7.6"
@ -1761,6 +2011,18 @@ version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091"
[[package]]
name = "utf8_iter"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version_check"
version = "0.9.5"
@ -1783,6 +2045,15 @@ version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasip2"
version = "1.0.2+wasi-0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "web_atoms"
version = "0.2.3"
@ -1899,6 +2170,18 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "wit-bindgen"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
[[package]]
name = "writeable"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
[[package]]
name = "x11rb"
version = "0.13.2"
@ -1922,6 +2205,29 @@ version = "0.8.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3"
[[package]]
name = "yoke"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954"
dependencies = [
"stable_deref_trait",
"yoke-derive",
"zerofrom",
]
[[package]]
name = "yoke-derive"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d"
dependencies = [
"proc-macro2",
"quote",
"syn",
"synstructure",
]
[[package]]
name = "zerocopy"
version = "0.8.42"
@ -1942,6 +2248,60 @@ dependencies = [
"syn",
]
[[package]]
name = "zerofrom"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5"
dependencies = [
"zerofrom-derive",
]
[[package]]
name = "zerofrom-derive"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
dependencies = [
"proc-macro2",
"quote",
"syn",
"synstructure",
]
[[package]]
name = "zerotrie"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851"
dependencies = [
"displaydoc",
"yoke",
"zerofrom",
]
[[package]]
name = "zerovec"
version = "0.11.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002"
dependencies = [
"yoke",
"zerofrom",
"zerovec-derive",
]
[[package]]
name = "zerovec-derive"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "zmij"
version = "1.0.21"

View file

@ -35,6 +35,7 @@ syntect = { version = "5", default-features = false, features = ["default-syntax
unicode-segmentation = "1"
unicode-width = "0.2"
xxhash-rust = { version = "0.8", features = ["xxh32"] }
git2 = { version = "0.20", default-features = false, features = ["vendored-libgit2"] }
[build-dependencies]
napi-build = "2"

View file

@ -0,0 +1,236 @@
//! Native git operations via libgit2.
//!
//! Provides fast READ-ONLY git queries for the GSD dispatch hotpath,
//! eliminating the need to spawn 25-40 `git` child processes per dispatch.
//!
//! WRITE operations (commit, merge, checkout, push) remain as execSync
//! calls in TypeScript — only status queries are native.
use git2::{Repository, StatusOptions};
use napi::bindgen_prelude::*;
use napi_derive::napi;
/// Open a git repository at the given path.
fn open_repo(repo_path: &str) -> Result<Repository> {
Repository::open(repo_path).map_err(|e| {
Error::new(
Status::GenericFailure,
format!("Failed to open git repository at {repo_path}: {e}"),
)
})
}
/// Get the current branch name (HEAD symbolic ref).
/// Returns None if HEAD is detached.
#[napi]
pub fn git_current_branch(repo_path: String) -> Result<Option<String>> {
let repo = open_repo(&repo_path)?;
let head = repo.head().map_err(|e| {
Error::new(
Status::GenericFailure,
format!("Failed to read HEAD: {e}"),
)
})?;
if head.is_branch() {
Ok(head.shorthand().map(String::from))
} else {
Ok(None)
}
}
/// Detect the main/integration branch for a repository.
///
/// Resolution order:
/// 1. refs/remotes/origin/HEAD → extract branch name
/// 2. refs/heads/main exists → "main"
/// 3. refs/heads/master exists → "master"
/// 4. Fall back to current branch
///
/// Note: milestone integration branch and worktree detection are handled
/// in TypeScript — this function covers the repo-level default detection
/// that previously spawned 4 `git show-ref` / `git symbolic-ref` calls.
#[napi]
pub fn git_main_branch(repo_path: String) -> Result<String> {
let repo = open_repo(&repo_path)?;
// Check origin/HEAD symbolic ref
if let Ok(reference) = repo.find_reference("refs/remotes/origin/HEAD") {
if let Ok(resolved) = reference.resolve() {
if let Some(name) = resolved.name() {
if let Some(branch) = name.strip_prefix("refs/remotes/origin/") {
return Ok(branch.to_string());
}
}
}
}
// Check refs/heads/main
if repo.find_reference("refs/heads/main").is_ok() {
return Ok("main".to_string());
}
// Check refs/heads/master
if repo.find_reference("refs/heads/master").is_ok() {
return Ok("master".to_string());
}
// Fall back to current branch
let head = repo.head().map_err(|e| {
Error::new(
Status::GenericFailure,
format!("Failed to read HEAD: {e}"),
)
})?;
Ok(head.shorthand().unwrap_or("HEAD").to_string())
}
/// Check if a local branch exists (refs/heads/<name>).
#[napi]
pub fn git_branch_exists(repo_path: String, branch: String) -> Result<bool> {
let repo = open_repo(&repo_path)?;
let refname = format!("refs/heads/{branch}");
let exists = repo.find_reference(&refname).is_ok();
Ok(exists)
}
/// Check if the repository index has unmerged entries (merge conflicts).
#[napi]
pub fn git_has_merge_conflicts(repo_path: String) -> Result<bool> {
let repo = open_repo(&repo_path)?;
let index = repo.index().map_err(|e| {
Error::new(
Status::GenericFailure,
format!("Failed to read index: {e}"),
)
})?;
Ok(index.has_conflicts())
}
/// Get working tree status in porcelain format.
/// Returns a string where each line is "XY path" (git status --porcelain).
#[napi]
pub fn git_working_tree_status(repo_path: String) -> Result<String> {
let repo = open_repo(&repo_path)?;
let mut opts = StatusOptions::new();
opts.include_untracked(true)
.recurse_untracked_dirs(true);
let statuses = repo.statuses(Some(&mut opts)).map_err(|e| {
Error::new(
Status::GenericFailure,
format!("Failed to get status: {e}"),
)
})?;
let mut lines = Vec::with_capacity(statuses.len());
for entry in statuses.iter() {
let status = entry.status();
let path = entry.path().unwrap_or("?");
let index_char = if status.is_index_new() {
'A'
} else if status.is_index_modified() {
'M'
} else if status.is_index_deleted() {
'D'
} else if status.is_index_renamed() {
'R'
} else if status.is_index_typechange() {
'T'
} else {
' '
};
let wt_char = if status.is_wt_new() {
'?'
} else if status.is_wt_modified() {
'M'
} else if status.is_wt_deleted() {
'D'
} else if status.is_wt_renamed() {
'R'
} else if status.is_wt_typechange() {
'T'
} else {
' '
};
lines.push(format!("{index_char}{wt_char} {path}"));
}
Ok(lines.join("\n"))
}
/// Quick check: are there any staged or unstaged changes in the working tree?
#[napi]
pub fn git_has_changes(repo_path: String) -> Result<bool> {
let repo = open_repo(&repo_path)?;
let mut opts = StatusOptions::new();
opts.include_untracked(true);
let statuses = repo.statuses(Some(&mut opts)).map_err(|e| {
Error::new(
Status::GenericFailure,
format!("Failed to get status: {e}"),
)
})?;
Ok(!statuses.is_empty())
}
/// Count commits between two refs (equivalent to `git rev-list --count from..to`).
#[napi]
pub fn git_commit_count_between(
repo_path: String,
from_ref: String,
to_ref: String,
) -> Result<u32> {
let repo = open_repo(&repo_path)?;
let from_oid = repo
.revparse_single(&from_ref)
.map_err(|e| {
Error::new(
Status::GenericFailure,
format!("Failed to resolve ref '{from_ref}': {e}"),
)
})?
.id();
let to_oid = repo
.revparse_single(&to_ref)
.map_err(|e| {
Error::new(
Status::GenericFailure,
format!("Failed to resolve ref '{to_ref}': {e}"),
)
})?
.id();
let mut revwalk = repo.revwalk().map_err(|e| {
Error::new(
Status::GenericFailure,
format!("Failed to create revwalk: {e}"),
)
})?;
revwalk.push(to_oid).map_err(|e| {
Error::new(
Status::GenericFailure,
format!("Failed to push to_ref: {e}"),
)
})?;
revwalk.hide(from_oid).map_err(|e| {
Error::new(
Status::GenericFailure,
format!("Failed to hide from_ref: {e}"),
)
})?;
let count = revwalk.count() as u32;
Ok(count)
}

View file

@ -27,3 +27,4 @@ mod truncate;
mod json_parse;
mod stream_process;
mod xxhash;
mod git;

View file

@ -71,6 +71,7 @@ import {
mergeSliceToMain,
} from "./worktree.js";
import { GitServiceImpl, runGit } from "./git-service.js";
import { nativeCommitCountBetween } from "./native-git-bridge.js";
import { getPriorSliceCompletionBlocker } from "./dispatch-guard.js";
import type { GitPreferences } from "./git-service.js";
import { truncateToWidth, visibleWidth } from "@gsd/pi-tui";
@ -473,12 +474,8 @@ async function mergeOrphanedSliceBranches(
// Skip if already merged (no commits ahead of main)
const mainBranch = getMainBranch(base);
const aheadCount = runGit(
base,
["rev-list", "--count", `${mainBranch}..${branch}`],
{ allowFailure: true },
);
if (!aheadCount || aheadCount === "0") continue;
const aheadCount = nativeCommitCountBetween(base, mainBranch, branch);
if (aheadCount === 0) continue;
// Read the roadmap from the slice branch to check if the slice is done.
// relMilestoneFile resolves the actual directory name on disk (handles

View file

@ -17,6 +17,14 @@ import {
getSliceBranchName,
SLICE_BRANCH_RE,
} from "./worktree.js";
import {
nativeGetCurrentBranch,
nativeDetectMainBranch,
nativeBranchExists,
nativeHasMergeConflicts,
nativeHasChanges,
nativeCommitCountBetween,
} from "./native-git-bridge.js";
// ─── Types ─────────────────────────────────────────────────────────────────
@ -356,8 +364,8 @@ export class GitServiceImpl {
*/
autoCommit(unitType: string, unitId: string, extraExclusions: readonly string[] = []): string | null {
// Quick check: is there anything dirty at all?
const status = this.git(["status", "--short"], { allowFailure: true });
if (!status) return null;
// Native path uses libgit2 (single syscall), fallback spawns git.
if (!nativeHasChanges(this.basePath)) return null;
this.smartStage(extraExclusions);
@ -400,37 +408,25 @@ export class GitServiceImpl {
const integrationBranch = readIntegrationBranch(this.basePath, this._milestoneId);
if (integrationBranch) {
// Verify the branch still exists locally (could have been deleted)
const exists = this.git(["show-ref", "--verify", `refs/heads/${integrationBranch}`], { allowFailure: true });
if (exists) return integrationBranch;
if (nativeBranchExists(this.basePath, integrationBranch)) return integrationBranch;
}
}
const wtName = detectWorktreeName(this.basePath);
if (wtName) {
const wtBranch = `worktree/${wtName}`;
const exists = this.git(["show-ref", "--verify", `refs/heads/${wtBranch}`], { allowFailure: true });
if (exists) return wtBranch;
return this.git(["branch", "--show-current"]);
if (nativeBranchExists(this.basePath, wtBranch)) return wtBranch;
return nativeGetCurrentBranch(this.basePath);
}
const symbolic = this.git(["symbolic-ref", "refs/remotes/origin/HEAD"], { allowFailure: true });
if (symbolic) {
const match = symbolic.match(/refs\/remotes\/origin\/(.+)$/);
if (match) return match[1]!;
}
const mainExists = this.git(["show-ref", "--verify", "refs/heads/main"], { allowFailure: true });
if (mainExists) return "main";
const masterExists = this.git(["show-ref", "--verify", "refs/heads/master"], { allowFailure: true });
if (masterExists) return "master";
return this.git(["branch", "--show-current"]);
// Repo-level default detection: origin/HEAD → main → master → current branch.
// Native path uses libgit2 (single call), fallback spawns multiple git processes.
return nativeDetectMainBranch(this.basePath);
}
/** Get the current branch name. */
/** Get the current branch name. Native libgit2 when available, execSync fallback. */
getCurrentBranch(): string {
return this.git(["branch", "--show-current"]);
return nativeGetCurrentBranch(this.basePath);
}
/** True if currently on a GSD slice branch. */
@ -452,15 +448,10 @@ export class GitServiceImpl {
// ─── Branch Lifecycle ──────────────────────────────────────────────────
/**
* Check if a local branch exists.
* Check if a local branch exists. Native libgit2 when available, execSync fallback.
*/
private branchExists(branch: string): boolean {
try {
this.git(["show-ref", "--verify", "--quiet", `refs/heads/${branch}`]);
return true;
} catch {
return false;
}
return nativeBranchExists(this.basePath, branch);
}
/**
@ -715,9 +706,9 @@ export class GitServiceImpl {
);
}
// Check commits ahead
const aheadCount = this.git(["rev-list", "--count", `${mainBranch}..${branch}`]);
if (aheadCount === "0") {
// Check commits ahead — native libgit2 revwalk when available
const aheadCount = nativeCommitCountBetween(this.basePath, mainBranch, branch);
if (aheadCount === 0) {
throw new Error(
`Slice branch "${branch}" has no commits ahead of "${mainBranch}". Nothing to merge.`,
);

View file

@ -0,0 +1,180 @@
// 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.
//
// Only READ operations are native — WRITE operations (commit, merge, checkout, push)
// remain as execSync calls in git-service.ts.
import { execSync } from "node:child_process";
/** Env overlay that suppresses all interactive git credential prompts. */
const GIT_NO_PROMPT_ENV = {
...process.env,
GIT_TERMINAL_PROMPT: "0",
GIT_ASKPASS: "",
};
let nativeModule: {
gitCurrentBranch: (repoPath: string) => string | null;
gitMainBranch: (repoPath: string) => string;
gitBranchExists: (repoPath: string, branch: string) => boolean;
gitHasMergeConflicts: (repoPath: string) => boolean;
gitWorkingTreeStatus: (repoPath: string) => string;
gitHasChanges: (repoPath: string) => boolean;
gitCommitCountBetween: (repoPath: string, fromRef: string, toRef: string) => number;
} | null = null;
let loadAttempted = false;
function loadNative(): typeof nativeModule {
if (loadAttempted) return nativeModule;
loadAttempted = true;
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const mod = require("@gsd/native");
if (mod.gitCurrentBranch && mod.gitHasChanges) {
nativeModule = mod;
}
} catch {
// Native module not available — all functions fall back to git CLI
}
return nativeModule;
}
/** Run a git command via execSync. Returns trimmed stdout. */
function gitExec(basePath: string, args: string[], allowFailure = false): string {
try {
return execSync(`git ${args.join(" ")}`, {
cwd: basePath,
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf-8",
env: GIT_NO_PROMPT_ENV,
}).trim();
} catch {
if (allowFailure) return "";
throw new Error(`git ${args.join(" ")} failed in ${basePath}`);
}
}
/**
* Get the current branch name.
* Native: reads HEAD symbolic ref via libgit2.
* Fallback: `git branch --show-current`.
*/
export function nativeGetCurrentBranch(basePath: string): string {
const native = loadNative();
if (native) {
const branch = native.gitCurrentBranch(basePath);
return branch ?? "";
}
return gitExec(basePath, ["branch", "--show-current"]);
}
/**
* 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();
if (native) {
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\/(.+)$/);
if (match) return match[1]!;
}
const mainExists = gitExec(basePath, ["show-ref", "--verify", "refs/heads/main"], true);
if (mainExists) return "main";
const masterExists = gitExec(basePath, ["show-ref", "--verify", "refs/heads/master"], true);
if (masterExists) return "master";
return gitExec(basePath, ["branch", "--show-current"]);
}
/**
* Check if a local branch exists.
* Native: checks refs/heads/<name> via libgit2.
* Fallback: `git show-ref --verify`.
*/
export function nativeBranchExists(basePath: string, branch: string): boolean {
const native = loadNative();
if (native) {
return native.gitBranchExists(basePath, branch);
}
const result = gitExec(basePath, ["show-ref", "--verify", `refs/heads/${branch}`], true);
return result !== "";
}
/**
* Check if the index has unmerged entries (merge conflicts).
* Native: reads index conflict state via libgit2.
* Fallback: `git diff --name-only --diff-filter=U`.
*/
export function nativeHasMergeConflicts(basePath: string): boolean {
const native = loadNative();
if (native) {
return native.gitHasMergeConflicts(basePath);
}
const result = gitExec(basePath, ["diff", "--name-only", "--diff-filter=U"], true);
return result !== "";
}
/**
* Get working tree status (porcelain format).
* Native: reads status via libgit2.
* Fallback: `git status --porcelain`.
*/
export function nativeWorkingTreeStatus(basePath: string): string {
const native = loadNative();
if (native) {
return native.gitWorkingTreeStatus(basePath);
}
return gitExec(basePath, ["status", "--porcelain"], true);
}
/**
* Quick check: any staged or unstaged changes?
* Native: libgit2 status check (single syscall).
* Fallback: `git status --short`.
*/
export function nativeHasChanges(basePath: string): boolean {
const native = loadNative();
if (native) {
return native.gitHasChanges(basePath);
}
const result = gitExec(basePath, ["status", "--short"], true);
return result !== "";
}
/**
* Count commits between two refs (from..to).
* Native: libgit2 revwalk.
* Fallback: `git rev-list --count from..to`.
*/
export function nativeCommitCountBetween(basePath: string, fromRef: string, toRef: string): number {
const native = loadNative();
if (native) {
return native.gitCommitCountBetween(basePath, fromRef, toRef);
}
const result = gitExec(basePath, ["rev-list", "--count", `${fromRef}..${toRef}`], true);
return parseInt(result, 10) || 0;
}
/**
* Check if the native git module is available.
*/
export function isNativeGitAvailable(): boolean {
return loadNative() !== null;
}