diff --git a/native/Cargo.lock b/native/Cargo.lock index 588c75d51..f3038064b 100644 --- a/native/Cargo.lock +++ b/native/Cargo.lock @@ -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" diff --git a/native/crates/engine/Cargo.toml b/native/crates/engine/Cargo.toml index d4dda258c..b6a0e3af7 100644 --- a/native/crates/engine/Cargo.toml +++ b/native/crates/engine/Cargo.toml @@ -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" diff --git a/native/crates/engine/src/git.rs b/native/crates/engine/src/git.rs new file mode 100644 index 000000000..6012a53ae --- /dev/null +++ b/native/crates/engine/src/git.rs @@ -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::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> { + 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 { + 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/). +#[napi] +pub fn git_branch_exists(repo_path: String, branch: String) -> Result { + 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 { + 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 { + 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 { + 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 { + 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) +} diff --git a/native/crates/engine/src/lib.rs b/native/crates/engine/src/lib.rs index b18c3d16e..ed314b5f7 100644 --- a/native/crates/engine/src/lib.rs +++ b/native/crates/engine/src/lib.rs @@ -27,3 +27,4 @@ mod truncate; mod json_parse; mod stream_process; mod xxhash; +mod git; diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index 7179e942d..c00a203f0 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -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 diff --git a/src/resources/extensions/gsd/git-service.ts b/src/resources/extensions/gsd/git-service.ts index 1e335e604..c984e6606 100644 --- a/src/resources/extensions/gsd/git-service.ts +++ b/src/resources/extensions/gsd/git-service.ts @@ -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.`, ); diff --git a/src/resources/extensions/gsd/native-git-bridge.ts b/src/resources/extensions/gsd/native-git-bridge.ts new file mode 100644 index 000000000..851547b41 --- /dev/null +++ b/src/resources/extensions/gsd/native-git-bridge.ts @@ -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/ 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; +}