M0: compilable skeleton — Kigi 0.1.0 fork surgery
Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.
Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
ptyctl, ptyctl-cli, third_party/ unchanged; proto package
xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
(templates re-encrypted)
Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
module & dc_log, heap-profile uploader, auth-diagnostics uploader,
session-analytics halves of feedback; local zero-egress observability
preserved in new kigi-log crate (unified log, --debug firehose,
subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
shell util
Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted
Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean
Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
(new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
fast-worktree); RSS measurement tests serialized via serial_test
Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
notices sustained; kigi-tools ported-code notices extended; README,
CONTRIBUTING, SECURITY, AGENTS.md rewritten
Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,151 @@
|
||||
//! Repository/worktree discovery helpers.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
/// Find the git directory for a path using gix (handles both repos and worktrees).
|
||||
///
|
||||
/// For a regular repo, returns the `.git` directory. For a linked worktree,
|
||||
/// returns the worktree's git dir under `.git/worktrees/<name>`.
|
||||
///
|
||||
/// Note: currently unused in production code — retained for future use and
|
||||
/// tested below. See `find_worktree_git_dir` for the version used by
|
||||
/// `copy_git_index`.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn find_git_dir(path: &Path) -> Result<PathBuf> {
|
||||
let repo = gix::discover(path)
|
||||
.with_context(|| format!("failed to discover git repo at {}", path.display()))?;
|
||||
|
||||
Ok(repo.git_dir().to_path_buf())
|
||||
}
|
||||
|
||||
/// Find the worktree's git directory from its `.git` file.
|
||||
///
|
||||
/// Worktrees have a `.git` file (not directory) that points to the actual git dir.
|
||||
/// For regular repos, returns the `.git` directory.
|
||||
pub(crate) fn find_worktree_git_dir(worktree_path: &Path) -> Result<PathBuf> {
|
||||
let git_path = worktree_path.join(".git");
|
||||
|
||||
if git_path.is_file() {
|
||||
// Worktree: .git is a file containing "gitdir: <path>"
|
||||
let content = std::fs::read_to_string(&git_path)
|
||||
.with_context(|| format!("failed to read .git file at {}", git_path.display()))?;
|
||||
|
||||
let raw = content
|
||||
.strip_prefix("gitdir: ")
|
||||
.ok_or_else(|| anyhow::anyhow!("invalid .git file format: {}", content.trim()))?
|
||||
.trim();
|
||||
|
||||
// git may write a RELATIVE pointer (worktrees added with a relative
|
||||
// path). Resolve it against the worktree dir — otherwise downstream
|
||||
// index lookups join it against the CWD and break (mirrors
|
||||
// `read_worktree_gitdir` in api.rs).
|
||||
let raw_path = Path::new(raw);
|
||||
let resolved = if raw_path.is_relative() {
|
||||
worktree_path.join(raw_path)
|
||||
} else {
|
||||
raw_path.to_path_buf()
|
||||
};
|
||||
Ok(dunce::canonicalize(&resolved).unwrap_or(resolved))
|
||||
} else if git_path.is_dir() {
|
||||
// Regular repository
|
||||
Ok(git_path)
|
||||
} else {
|
||||
anyhow::bail!(
|
||||
"no .git file or directory found at {}",
|
||||
worktree_path.display()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the worktree root (working directory root) for a path.
|
||||
///
|
||||
/// This handles both regular repositories and worktrees correctly.
|
||||
/// For a regular repo at `/repo`, returns `/repo`.
|
||||
/// For a worktree at `/worktrees/wt1`, returns `/worktrees/wt1`.
|
||||
/// For a subdirectory `/repo/subdir`, returns `/repo`.
|
||||
pub(crate) fn find_worktree_root(path: &Path) -> Result<PathBuf> {
|
||||
let repo = gix::discover(path)
|
||||
.with_context(|| format!("failed to discover git repo at {}", path.display()))?;
|
||||
|
||||
// workdir() returns the working directory root for both repos and worktrees
|
||||
let work_dir = repo
|
||||
.workdir()
|
||||
.ok_or_else(|| anyhow::anyhow!("bare repository has no working directory"))?;
|
||||
|
||||
Ok(work_dir.to_path_buf())
|
||||
}
|
||||
|
||||
/// Get the HEAD commit hash using gix.
|
||||
pub(crate) fn get_head_commit(path: &Path) -> Result<String> {
|
||||
let repo = gix::discover(path)
|
||||
.with_context(|| format!("failed to discover git repo at {}", path.display()))?;
|
||||
|
||||
let head = repo
|
||||
.head()
|
||||
.context("failed to get HEAD")?
|
||||
.peel_to_commit()
|
||||
.context("failed to peel HEAD to commit")?;
|
||||
|
||||
Ok(head.id().to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use kigi_test_utils::git::{git_commit_all, init_git_repo};
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn test_find_git_dir() {
|
||||
kigi_test_utils::require_git!();
|
||||
let temp = TempDir::new().unwrap();
|
||||
init_git_repo(temp.path());
|
||||
|
||||
let git_dir = find_git_dir(temp.path()).unwrap();
|
||||
assert!(git_dir.ends_with(".git"));
|
||||
assert!(git_dir.is_dir());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_worktree_git_dir_resolves_relative_gitdir() {
|
||||
// A worktree `.git` file can hold a RELATIVE `gitdir:` pointer; it must
|
||||
// resolve against the worktree dir, not be returned as-is (which would
|
||||
// break index copy for relative-gitdir worktrees).
|
||||
let temp = TempDir::new().unwrap();
|
||||
let worktree = temp.path().join("wt");
|
||||
std::fs::create_dir_all(&worktree).unwrap();
|
||||
let real_git = temp.path().join("repo/.git/worktrees/wt");
|
||||
std::fs::create_dir_all(&real_git).unwrap();
|
||||
std::fs::write(worktree.join(".git"), "gitdir: ../repo/.git/worktrees/wt\n").unwrap();
|
||||
|
||||
let resolved = find_worktree_git_dir(&worktree).unwrap();
|
||||
assert_eq!(resolved, dunce::canonicalize(&real_git).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_worktree_git_dir_regular_repo() {
|
||||
kigi_test_utils::require_git!();
|
||||
let temp = TempDir::new().unwrap();
|
||||
init_git_repo(temp.path());
|
||||
|
||||
let git_dir = find_worktree_git_dir(temp.path()).unwrap();
|
||||
assert!(git_dir.ends_with(".git"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_head_commit() {
|
||||
kigi_test_utils::require_git!();
|
||||
let temp = TempDir::new().unwrap();
|
||||
init_git_repo(temp.path());
|
||||
|
||||
// Create a commit
|
||||
std::fs::write(temp.path().join("file.txt"), "content").unwrap();
|
||||
git_commit_all(temp.path(), "initial");
|
||||
|
||||
let commit = get_head_commit(temp.path()).unwrap();
|
||||
assert_eq!(commit.len(), 40); // SHA-1 hex string
|
||||
assert!(commit.chars().all(|c| c.is_ascii_hexdigit()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
//! Git index operations used during worktree creation.
|
||||
|
||||
use std::fs::Metadata;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
use crate::copy::cow::clone_file;
|
||||
use crate::git::discovery::find_worktree_git_dir;
|
||||
|
||||
/// Copy the git index from source to destination worktree.
|
||||
///
|
||||
/// Resolves the actual git directory for both sides, handling linked worktrees
|
||||
/// where `.git` is a file pointing to the real git dir. Both sides use
|
||||
/// `find_worktree_git_dir` for consistency: for a regular repo it returns
|
||||
/// `.git/`, for a linked worktree it follows the `gitdir:` pointer.
|
||||
///
|
||||
/// Uses CoW (reflink) copy for efficiency on APFS/Btrfs.
|
||||
///
|
||||
/// Returns `true` if the index was actually copied, `false` if the source
|
||||
/// has no index file.
|
||||
pub(crate) fn copy_git_index(source: &Path, dest_worktree: &Path) -> Result<bool> {
|
||||
let source_git_dir = find_worktree_git_dir(source)?;
|
||||
let dest_git_dir = find_worktree_git_dir(dest_worktree)?;
|
||||
|
||||
let source_index = source_git_dir.join("index");
|
||||
let dest_index = dest_git_dir.join("index");
|
||||
|
||||
if source_index.exists() {
|
||||
// reflink_or_copy cannot overwrite — remove destination first
|
||||
if dest_index.exists() {
|
||||
let _ = std::fs::remove_file(&dest_index);
|
||||
}
|
||||
|
||||
clone_file(&source_index, &dest_index).with_context(|| {
|
||||
format!(
|
||||
"failed to copy index from {} to {}",
|
||||
source_index.display(),
|
||||
dest_index.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
// Handle split index: when core.splitIndex is enabled, the index
|
||||
// file references a `sharedindex.<hash>` file that must be
|
||||
// reachable from the same directory as the index. For linked
|
||||
// worktrees the shared index lives in the common git dir (the
|
||||
// main repo's `.git/`), not in `.git/worktrees/<name>/`.
|
||||
// Symlink any sharedindex.* files from the source's common dir
|
||||
// into the dest git dir so gix can resolve them.
|
||||
link_shared_indexes(&source_git_dir, &dest_git_dir)?;
|
||||
|
||||
tracing::debug!(
|
||||
source = %source_index.display(),
|
||||
dest = %dest_index.display(),
|
||||
"copied git index (reflink)"
|
||||
);
|
||||
Ok(true)
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// Symlink `sharedindex.*` files from the source into the destination
|
||||
/// git directory.
|
||||
///
|
||||
/// When `core.splitIndex` is enabled, the main index file contains a
|
||||
/// `link` extension referencing a content-addressed `sharedindex.<hash>`
|
||||
/// file. `gix::index::File::at()` looks for this file in the **same
|
||||
/// directory** as the index file. For linked worktrees the index lives
|
||||
/// in `.git/worktrees/<name>/` but the shared index lives in the common
|
||||
/// `.git/` directory. We bridge this by symlinking.
|
||||
///
|
||||
/// We scan **two** directories for shared index files:
|
||||
/// 1. The source's **common dir** (main repo `.git/`) — where git
|
||||
/// typically stores shared index files.
|
||||
/// 2. The source's **own git dir** (`.git/worktrees/<name>/`) — git may
|
||||
/// create new shared index files directly here when running inside a
|
||||
/// linked worktree with `core.splitIndex` enabled.
|
||||
///
|
||||
/// No-op if there are no `sharedindex.*` files (i.e. split index is not
|
||||
/// in use).
|
||||
fn link_shared_indexes(source_git_dir: &Path, dest_git_dir: &Path) -> Result<()> {
|
||||
// Resolve the common dir: for a linked worktree the `commondir` file
|
||||
// points to the shared `.git/`. For a regular repo the git dir IS the
|
||||
// common dir.
|
||||
let source_common_dir = resolve_common_dir(source_git_dir);
|
||||
|
||||
// Collect directories to scan. Always include the common dir. If the
|
||||
// source git dir is different (i.e. source is a linked worktree), also
|
||||
// scan the source git dir itself — git may have created shared index
|
||||
// files directly there.
|
||||
let mut dirs_to_scan: Vec<&Path> = vec![&source_common_dir];
|
||||
if source_git_dir != source_common_dir {
|
||||
dirs_to_scan.push(source_git_dir);
|
||||
}
|
||||
|
||||
let mut linked = 0u32;
|
||||
for scan_dir in &dirs_to_scan {
|
||||
let entries = match std::fs::read_dir(scan_dir) {
|
||||
Ok(e) => e,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name();
|
||||
let name_str = name.to_string_lossy();
|
||||
if !name_str.starts_with("sharedindex.") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let src = entry.path();
|
||||
let dst = dest_git_dir.join(&name);
|
||||
|
||||
// Skip if already present (e.g. dest IS the common dir, or
|
||||
// already linked from a previous scan_dir iteration).
|
||||
if dst.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Symlink is ideal: instant, zero-copy, shared index is
|
||||
// read-only content-addressed data.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
std::os::unix::fs::symlink(&src, &dst).with_context(|| {
|
||||
format!(
|
||||
"failed to symlink sharedindex {} -> {}",
|
||||
dst.display(),
|
||||
src.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
// Fallback: reflink/copy on Windows.
|
||||
clone_file(&src, &dst).with_context(|| {
|
||||
format!(
|
||||
"failed to copy sharedindex {} -> {}",
|
||||
src.display(),
|
||||
dst.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
linked += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if linked > 0 {
|
||||
tracing::debug!(
|
||||
source_common_dir = %source_common_dir.display(),
|
||||
dest_git_dir = %dest_git_dir.display(),
|
||||
linked,
|
||||
"linked sharedindex files for split-index support"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve the common git directory from a worktree git dir.
|
||||
///
|
||||
/// For a linked worktree, `.git/worktrees/<name>/commondir` contains a
|
||||
/// relative path (typically `../..`) pointing to the shared `.git/`.
|
||||
/// For a regular repo, the git dir itself is the common dir.
|
||||
fn resolve_common_dir(git_dir: &Path) -> PathBuf {
|
||||
let commondir_file = git_dir.join("commondir");
|
||||
if let Ok(content) = std::fs::read_to_string(&commondir_file) {
|
||||
let relative = content.trim();
|
||||
let resolved = git_dir.join(relative);
|
||||
// Canonicalize to clean up `../..` etc.
|
||||
dunce::canonicalize(&resolved).unwrap_or(resolved)
|
||||
} else {
|
||||
git_dir.to_path_buf()
|
||||
}
|
||||
}
|
||||
|
||||
/// Update index entries with new stat information from file metadata.
|
||||
///
|
||||
/// This updates the stat cache (mtime, size, etc.) for files that were copied,
|
||||
/// avoiding the need for a full `git update-index --refresh`.
|
||||
pub(crate) fn update_index_stats(
|
||||
worktree_path: &Path,
|
||||
file_metadata: &[(PathBuf, Metadata)],
|
||||
) -> Result<()> {
|
||||
if file_metadata.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
let git_dir = find_worktree_git_dir(worktree_path)?;
|
||||
let index_path = git_dir.join("index");
|
||||
|
||||
// If index doesn't exist yet, there's nothing to update
|
||||
if !index_path.exists() {
|
||||
tracing::debug!(
|
||||
path = %worktree_path.display(),
|
||||
"index file doesn't exist yet, skipping update"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Guard against empty index files — gix-index panics when the file
|
||||
// is 0 bytes because it tries to slice the trailing hash from an
|
||||
// empty mmap (integer underflow in the slice range).
|
||||
if index_path.metadata().map_or(true, |m| m.len() == 0) {
|
||||
tracing::debug!(
|
||||
path = %worktree_path.display(),
|
||||
"index file is empty, skipping update"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Open the index file directly for modification
|
||||
let mut index = gix::index::File::at(
|
||||
&index_path,
|
||||
gix::hash::Kind::Sha1,
|
||||
false,
|
||||
Default::default(),
|
||||
)
|
||||
.context("failed to open git index")?;
|
||||
|
||||
// Update stat info for each file that was copied
|
||||
let mut updated_count = 0;
|
||||
for entry in file_metadata.iter() {
|
||||
let (path, metadata) = (&entry.0, &entry.1);
|
||||
|
||||
// Convert path to BStr for gix
|
||||
let path_str = path.to_string_lossy();
|
||||
let path_bytes: &gix::bstr::BStr = path_str.as_bytes().into();
|
||||
|
||||
// Find the entry in the index
|
||||
if let Ok(entry_index) = index.entry_index_by_path(path_bytes) {
|
||||
let entry = &mut index.entries_mut()[entry_index];
|
||||
updated_count += 1;
|
||||
|
||||
// Update stat fields from metadata
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
|
||||
// mtime (modification time)
|
||||
entry.stat.mtime.secs = metadata
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_secs() as u32)
|
||||
.unwrap_or(0);
|
||||
|
||||
entry.stat.mtime.nsecs = metadata
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|d| d.subsec_nanos())
|
||||
.unwrap_or(0);
|
||||
|
||||
// ctime (change time) - MUST be the actual ctime, not mtime
|
||||
entry.stat.ctime.secs = metadata.ctime() as u32;
|
||||
entry.stat.ctime.nsecs = metadata.ctime_nsec() as u32;
|
||||
|
||||
// Other stat fields
|
||||
entry.stat.size = metadata.len() as u32;
|
||||
entry.stat.dev = metadata.dev() as u32;
|
||||
entry.stat.ino = metadata.ino() as u32;
|
||||
entry.stat.uid = metadata.uid();
|
||||
entry.stat.gid = metadata.gid();
|
||||
// Note: mode is on the entry itself, not stat
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
// On non-Unix systems, use mtime for both
|
||||
entry.stat.mtime.secs = metadata
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_secs() as u32)
|
||||
.unwrap_or(0);
|
||||
|
||||
entry.stat.mtime.nsecs = metadata
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|d| d.subsec_nanos())
|
||||
.unwrap_or(0);
|
||||
|
||||
entry.stat.ctime.secs = entry.stat.mtime.secs;
|
||||
entry.stat.ctime.nsecs = entry.stat.mtime.nsecs;
|
||||
entry.stat.size = metadata.len() as u32;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Count how many entries were actually updated
|
||||
let num_updated = updated_count;
|
||||
|
||||
// Write the updated index
|
||||
index.write(Default::default())?;
|
||||
|
||||
tracing::debug!(
|
||||
path = %worktree_path.display(),
|
||||
files_updated = num_updated,
|
||||
elapsed = ?start.elapsed(),
|
||||
"updated index stat cache"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
//! Git operations used by fast worktree creation.
|
||||
//!
|
||||
//! This module isolates git-specific functionality (worktree creation, status, index refresh)
|
||||
//! from filesystem copy logic and orchestration.
|
||||
|
||||
pub(crate) mod checkout;
|
||||
pub(crate) mod discovery;
|
||||
pub(crate) mod index;
|
||||
pub(crate) mod status;
|
||||
pub(crate) mod worktree;
|
||||
|
||||
pub(crate) use checkout::checkout_ref;
|
||||
pub(crate) use checkout::{git_clean_fd, git_reset_hard_command};
|
||||
// Only consumed by the Linux-only snapshot finalize path.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) use checkout::{has_staged_changes, worktree_at_ref, worktree_has_tracked_changes};
|
||||
pub(crate) use discovery::{find_worktree_root, get_head_commit};
|
||||
pub(crate) use index::{copy_git_index, update_index_stats};
|
||||
pub(crate) use status::get_modified_files;
|
||||
pub(crate) use worktree::worktree_add_no_checkout;
|
||||
@@ -0,0 +1,106 @@
|
||||
//! Git status helpers (compute dirty paths).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use dashmap::DashSet;
|
||||
use gix::bstr::BString;
|
||||
use gix::status::index_worktree::Item;
|
||||
use gix_status::index_as_worktree::{Change, EntryStatus};
|
||||
|
||||
use crate::copy::DirtyFilesReport;
|
||||
|
||||
/// Result of scanning for modified files, including both paths and counts by category.
|
||||
pub(crate) struct ModifiedFilesResult {
|
||||
/// Set of all relative paths that are modified/untracked/deleted.
|
||||
pub paths: DashSet<PathBuf>,
|
||||
/// Categorized counts of dirty files.
|
||||
pub report: DirtyFilesReport,
|
||||
}
|
||||
|
||||
/// Get modified files from the source repository.
|
||||
///
|
||||
/// This uses `gix`'s `index_worktree_iter` which compares the **index** to the
|
||||
/// **worktree**. It reports which files have been modified/added/deleted relative
|
||||
/// to what's staged, but does **not** expose the two-column staged-vs-worktree
|
||||
/// status (`XY` in porcelain output). For full `XY` semantics (needed by
|
||||
/// `sync::WorktreeSync`), see the CLI-based parser in `sync.rs`.
|
||||
///
|
||||
/// This is a blocking operation.
|
||||
pub(crate) fn get_modified_files(source: &Path) -> Result<ModifiedFilesResult> {
|
||||
let repo = gix::discover(source).context("failed to discover git repository")?;
|
||||
let modified: DashSet<PathBuf> = DashSet::new();
|
||||
|
||||
// Guard against empty index files — gix-index panics when the file
|
||||
// is 0 bytes because it tries to slice the trailing hash from an
|
||||
// empty mmap (integer underflow in the slice range).
|
||||
let index_path = repo.git_dir().join("index");
|
||||
if index_path.metadata().map_or(true, |m| m.len() == 0) {
|
||||
tracing::debug!(
|
||||
path = %source.display(),
|
||||
"index file is empty or missing, returning empty modified set"
|
||||
);
|
||||
return Ok(ModifiedFilesResult {
|
||||
paths: modified,
|
||||
report: DirtyFilesReport {
|
||||
modified_files: 0,
|
||||
untracked_files: 0,
|
||||
deleted_files: 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let mut modified_count = 0u64;
|
||||
let mut untracked_count = 0u64;
|
||||
let mut deleted_count = 0u64;
|
||||
|
||||
// Cap produce workers: gix-features spawn-EAGAIN aborts under panic=abort.
|
||||
let status = kigi_gix_status::with_budgeted_thread_limit(repo.status(gix::progress::Discard)?);
|
||||
let iter = status.into_index_worktree_iter(Vec::<BString>::new())?;
|
||||
|
||||
for item_result in iter {
|
||||
let item = item_result?;
|
||||
|
||||
let path = match &item {
|
||||
Item::Modification {
|
||||
rela_path, status, ..
|
||||
} => {
|
||||
// Check if it's a deletion (file exists in index but not in worktree)
|
||||
match status {
|
||||
EntryStatus::Change(Change::Removed) => deleted_count += 1,
|
||||
_ => modified_count += 1,
|
||||
}
|
||||
rela_path.to_string()
|
||||
}
|
||||
Item::DirectoryContents { entry, .. } => {
|
||||
// DirectoryContents = untracked files from directory walk
|
||||
untracked_count += 1;
|
||||
entry.rela_path.to_string()
|
||||
}
|
||||
Item::Rewrite { dirwalk_entry, .. } => {
|
||||
// Rewrite = file was renamed (tracked as modified)
|
||||
modified_count += 1;
|
||||
dirwalk_entry.rela_path.to_string()
|
||||
}
|
||||
};
|
||||
|
||||
modified.insert(PathBuf::from(path));
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
count = modified.len(),
|
||||
modified = modified_count,
|
||||
untracked = untracked_count,
|
||||
deleted = deleted_count,
|
||||
"found modified files"
|
||||
);
|
||||
|
||||
Ok(ModifiedFilesResult {
|
||||
paths: modified,
|
||||
report: DirtyFilesReport {
|
||||
modified_files: modified_count,
|
||||
untracked_files: untracked_count,
|
||||
deleted_files: deleted_count,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
//! Git worktree operations.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
use crate::git::checkout::git_command;
|
||||
|
||||
/// Create a git worktree with `--no-checkout`. Blocking.
|
||||
pub(crate) fn worktree_add_no_checkout(source: &Path, dest: &str, git_ref: &str) -> Result<()> {
|
||||
let output = git_command()
|
||||
.current_dir(source)
|
||||
.args([
|
||||
"worktree",
|
||||
"add",
|
||||
"--detach",
|
||||
"--no-checkout",
|
||||
dest,
|
||||
git_ref,
|
||||
])
|
||||
.output()
|
||||
.context("failed to run git worktree add")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
anyhow::bail!("git worktree add failed: {}", stderr);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user