Files
Kigi-CLI/crates/codegen/kigi-fast-worktree/src/sync.rs
T
ZacharyZhang-NY d6c20fc13f 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).
2026-07-17 05:31:01 -04:00

1913 lines
73 KiB
Rust

//! Sync a pre-created worktree to match a source repo's current state.
//!
//! This module provides the [`WorktreeSync`] API used by the worktree pool
//! to bring a pre-allocated worktree up to date with the source repo's HEAD
//! and dirty working tree state. It is designed for **linked** worktrees
//! (shared object store), so `git reset --hard <commit>` always succeeds
//! regardless of how far HEAD has diverged.
//!
//! All git operations use `gix` for HEAD resolution and `git` CLI for
//! mutations (`reset`, `clean`, `status`). File copies use
//! `reflink_copy::reflink_or_copy()` for CoW efficiency.
//!
//! ## Why CLI `git status` instead of `gix::status()`?
//!
//! The existing `git::status::get_modified_files()` uses `gix` natively via
//! `repo.status()` / `index_worktree_iter`. However, it only reports
//! *worktree-vs-index* differences — it does not distinguish the **staged
//! (index) column** from the **worktree column** (the `XY` pair in porcelain
//! output). For `sync_dirty_state` we need the full `XY` semantics to
//! correctly handle cases like `XY = "D "` (staged deletion, file still on
//! disk) vs `XY = " D"` (worktree deletion). The porcelain v2 format gives
//! us both columns, rename detection with `origPath`, and unmerged entries
//! — all NUL-delimited for safe path handling.
use std::path::Path;
use anyhow::{Context, Result};
use bytes::Bytes;
use crate::copy::cow::{clone_file, replace_symlink};
use crate::git::checkout::{git_clean_fd, git_command, git_reset_hard_command};
use crate::git::discovery::get_head_commit;
/// Pre-collected dirty state from a source repository.
///
/// Holds the raw output of `git status --porcelain=v2 -z --untracked-files=all`.
/// Collect once via [`collect_source_dirty_state`], then pass to multiple
/// [`WorktreeSync::sync_from_precomputed`] calls to avoid redundant `git status`
/// invocations (each takes ~1.4s on large repos).
///
/// Uses [`Bytes`] internally so cloning is a cheap ref-count bump rather
/// than a full buffer copy — important when sharing across multiple syncs.
#[derive(Clone, Debug)]
pub struct SourceDirtyState {
/// Raw NUL-delimited porcelain v2 output. Empty means the source is clean.
raw: Bytes,
}
impl SourceDirtyState {
/// Returns `true` if the source has no dirty files (nothing to sync).
pub fn is_empty(&self) -> bool {
self.raw.is_empty()
}
}
/// Collect dirty state from a source repository.
///
/// Runs `git status --porcelain=v2 -z --untracked-files=all` on the source
/// and captures the output. The result can be shared across multiple
/// [`WorktreeSync::sync_from_precomputed`] calls.
///
/// This is a **blocking** function — call from `spawn_blocking` in async contexts.
pub fn collect_source_dirty_state(source: &Path) -> Result<SourceDirtyState> {
let output = git_command()
.args(["status", "--porcelain=v2", "-z", "--untracked-files=all"])
.current_dir(source)
.output()
.context("failed to run git status")?;
if !output.status.success() {
anyhow::bail!(
"git status failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
Ok(SourceDirtyState {
raw: Bytes::from(output.stdout),
})
}
/// Report from a sync operation.
#[derive(Clone, Debug, Default)]
pub struct SyncReport {
/// Whether HEAD was moved (git reset --hard was needed).
pub head_moved: bool,
/// Number of dirty files replicated from source.
pub dirty_files_copied: u64,
/// Number of files deleted in destination to match source deletions.
pub files_deleted: u64,
/// Number of staged entries replicated via `git add`/`git rm --cached`.
pub staged_entries: u64,
/// Whether `git clean` was skipped (worktree known to be clean).
pub clean_skipped: bool,
/// Whether dirty sync was skipped because pre-computed state was empty.
pub dirty_skipped: bool,
// ── Per-phase timing (milliseconds) ─────────────────────────────────
/// Time to resolve HEAD commits on source + worktree (gix).
pub head_resolve_ms: u64,
/// Time for `git reset --hard` (0 if HEAD didn't move).
pub reset_hard_ms: u64,
/// Time for `git clean -fd` (0 if skipped).
pub clean_ms: u64,
/// Time for dirty-state sync: `git status` + file copy/delete + staged replay.
pub dirty_sync_ms: u64,
/// Time for just the `git status` call inside dirty sync (subset of dirty_sync_ms).
pub git_status_ms: u64,
/// Time for file copy/delete operations (subset of dirty_sync_ms).
pub file_ops_ms: u64,
/// Time for staged change replay (subset of dirty_sync_ms).
pub staged_replay_ms: u64,
}
/// Sync a pre-created worktree to match a source repo's current state.
///
/// Holds the source and worktree paths, allowing repeated sync operations
/// on the same pair (useful for pool replenishment).
///
/// All operations are **synchronous/blocking**. Callers should use
/// `spawn_blocking` when calling from async contexts.
pub struct WorktreeSync<'a> {
/// Path to the source repository (the "truth").
pub source: &'a Path,
/// Path to the linked worktree to sync.
pub worktree: &'a Path,
}
impl<'a> WorktreeSync<'a> {
/// Create a new sync handle for the given source/worktree pair.
pub fn new(source: &'a Path, worktree: &'a Path) -> Self {
Self { source, worktree }
}
/// Sync the worktree to match the source's current HEAD + dirty state.
///
/// Strategy:
/// 1. Resolve source HEAD and worktree HEAD via gix.
/// 2. If HEAD moved → `git reset --hard <source_HEAD>` (linked worktrees
/// share the object store, so new commits are always available).
/// 3. `git clean -fd` to remove any leftover untracked files (skippable).
/// 4. If `copy_dirty` is true → replicate dirty state from source.
///
/// # Errors
///
/// Returns an error if any git command fails (non-zero exit), or if
/// the gix repository cannot be opened.
pub fn sync_worktree(&self, copy_dirty: bool) -> Result<SyncReport> {
self.sync_worktree_opts(copy_dirty, false)
}
/// Sync with full control over the clean step.
///
/// When `skip_clean` is `true`, the `git clean` step is omitted entirely.
/// This is safe when the worktree is **known to be clean** — e.g. a freshly
/// created pool worktree, or one that was just released (reset+clean'd).
/// Skipping the clean step saves ~800ms on large repos (106K files) because
/// `git clean` walks the entire directory tree even when there's nothing to
/// remove.
pub fn sync_worktree_opts(&self, copy_dirty: bool, skip_clean: bool) -> Result<SyncReport> {
use std::time::Instant;
let mut report = SyncReport::default();
// Phase 1: Resolve HEAD commits.
let t = Instant::now();
let source_head = get_head_commit(self.source).context("failed to get source HEAD")?;
let worktree_head =
get_head_commit(self.worktree).context("failed to get worktree HEAD")?;
report.head_resolve_ms = t.elapsed().as_millis() as u64;
// Phase 2: Sync committed state.
// `git reset --hard` rebuilds the index with correct stat caches,
// fsmonitor data, and untracked-cache extensions. We must NOT
// overwrite this index afterwards.
if source_head != worktree_head {
report.head_moved = true;
let t = Instant::now();
git_reset_hard_command(self.worktree, Some(&source_head))?;
report.reset_hard_ms = t.elapsed().as_millis() as u64;
}
// Phase 3: Clean untracked files from previous use.
// Skipped when the worktree is known to be clean (freshly created or
// just released). Uses `-fd` (not `-fdx`): pool worktrees never have
// gitignored files, and skipping `-x` avoids parsing .gitignore which
// is faster.
if skip_clean {
report.clean_skipped = true;
tracing::debug!(
worktree = %self.worktree.display(),
"skipping git clean (worktree known to be clean)"
);
} else {
let t = Instant::now();
git_clean_fd(self.worktree, false)?;
report.clean_ms = t.elapsed().as_millis() as u64;
}
// Phase 4: Replicate dirty state if requested.
// This copies dirty files and replays staged changes via
// `git add`/`git rm --cached` — it does NOT copy the index
// wholesale, preserving the stat caches built by reset.
if copy_dirty {
let t = Instant::now();
let dirty_report = self.sync_dirty_state_timed()?;
report.dirty_sync_ms = t.elapsed().as_millis() as u64;
report.dirty_files_copied = dirty_report.dirty_files_copied;
report.files_deleted = dirty_report.files_deleted;
report.staged_entries = dirty_report.staged_entries;
report.git_status_ms = dirty_report.git_status_ms;
report.file_ops_ms = dirty_report.file_ops_ms;
report.staged_replay_ms = dirty_report.staged_replay_ms;
}
Ok(report)
}
/// Sync using a pre-collected [`SourceDirtyState`].
///
/// Same as [`sync_worktree_opts`] but uses a pre-computed dirty state
/// instead of running `git status` internally. This allows a single
/// `git status` call to be shared across multiple worktree syncs.
///
/// If `dirty_state` is `None`, dirty sync is skipped entirely
/// (equivalent to `copy_dirty=false`).
pub fn sync_from_precomputed(
&self,
dirty_state: Option<&SourceDirtyState>,
skip_clean: bool,
) -> Result<SyncReport> {
let mut report = SyncReport::default();
let source_head = get_head_commit(self.source).context("failed to get source HEAD")?;
let worktree_head =
get_head_commit(self.worktree).context("failed to get worktree HEAD")?;
if source_head != worktree_head {
report.head_moved = true;
git_reset_hard_command(self.worktree, Some(&source_head))?;
}
if skip_clean {
report.clean_skipped = true;
} else {
git_clean_fd(self.worktree, false)?;
}
match dirty_state {
Some(state) if !state.is_empty() => {
let result = apply_porcelain_v2_entries(&state.raw, self.source, self.worktree)?;
report.dirty_files_copied = result.copied;
report.files_deleted = result.deleted;
if !result.staged_adds.is_empty() || !result.staged_deletes.is_empty() {
report.staged_entries = replay_staged_changes(
self.worktree,
&result.staged_adds,
&result.staged_deletes,
)?;
}
}
Some(_) => {
// Pre-computed state is empty — source is clean, nothing to sync.
report.dirty_skipped = true;
}
None => {
// No dirty state provided — skip entirely.
// Currently unreachable from production callers (fallback path
// uses the old `sync_worktree_opts` instead), but kept as a
// defensive API contract: callers can pass None to opt out.
report.dirty_skipped = true;
}
}
Ok(report)
}
/// Copy dirty files from source to worktree and replicate staged changes.
///
/// Uses `git status --porcelain=v2 -z` for correct handling of all entry
/// types including renames (see module-level docs for rationale).
/// File copies use `reflink_or_copy()` for CoW.
///
/// Staged changes are replicated by running `git add` / `git rm --cached`
/// on the worktree for each entry with a non-`.` X column. This preserves
/// the worktree's index stat caches, fsmonitor data, and untracked-cache
/// extensions that were built by `git reset --hard`.
///
/// **Does NOT copy the source index wholesale** — that would destroy all
/// stat caches (every entry would have stale mtime/inode/dev from the
/// source filesystem) and force `git status` to re-hash every file.
pub fn sync_dirty_state(&self) -> Result<SyncReport> {
self.sync_dirty_state_timed()
}
/// Inner implementation with per-phase timing captured in the report.
fn sync_dirty_state_timed(&self) -> Result<SyncReport> {
use std::time::Instant;
let mut report = SyncReport::default();
// Sub-phase 1: git status on source.
let t = Instant::now();
let output = git_command()
.args(["status", "--porcelain=v2", "-z", "--untracked-files=all"])
.current_dir(self.source)
.output()
.context("failed to run git status")?;
report.git_status_ms = t.elapsed().as_millis() as u64;
if !output.status.success() {
anyhow::bail!(
"git status failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
// Sub-phase 2: Parse and apply status entries (file copies + deletions).
if !output.stdout.is_empty() {
let t = Instant::now();
let result = apply_porcelain_v2_entries(&output.stdout, self.source, self.worktree)?;
report.file_ops_ms = t.elapsed().as_millis() as u64;
report.dirty_files_copied = result.copied;
report.files_deleted = result.deleted;
// Sub-phase 3: Replay staged changes on the worktree's index.
if !result.staged_adds.is_empty() || !result.staged_deletes.is_empty() {
let t = Instant::now();
report.staged_entries = replay_staged_changes(
self.worktree,
&result.staged_adds,
&result.staged_deletes,
)?;
report.staged_replay_ms = t.elapsed().as_millis() as u64;
}
}
Ok(report)
}
}
/// Result of parsing and applying porcelain v2 status entries.
struct ApplyResult {
/// Number of files copied from source to worktree.
copied: u64,
/// Number of files deleted in worktree to match source.
deleted: u64,
/// Staged additions/modifications: `(mode_in_index, hash_in_index, path)`.
///
/// We capture the exact blob hash from porcelain v2 (`hI` field) so that
/// `replay_staged_changes` can use `git update-index --cacheinfo` to set
/// the index entry to the correct blob WITHOUT re-reading the working tree.
/// This is critical for `XY = "MM"` where the staged content differs from
/// the working tree content.
staged_adds: Vec<(String, String, String)>,
/// Paths that need `git rm --cached` in the worktree (staged deletions).
staged_deletes: Vec<String>,
}
/// Parse `git status --porcelain=v2 -z` output and apply changes to the destination.
///
/// Porcelain v2 format with `-z` uses NUL as the field/record separator.
/// Entry types:
/// - `1 <XY> ...` — ordinary changed entry (one path follows)
/// - `2 <XY> ...` — renamed/copied entry (two paths follow: path then origPath)
/// - `u <XY> ...` — unmerged entry (one path follows)
/// - `? <path>` — untracked file
/// - `! <path>` — ignored file (not shown by default)
///
/// For renames (`2` entries), we copy the destination path (the new name)
/// and delete the source path (the old name) if it was tracked.
///
/// Also collects staged entries (X != '.') so the caller can replay them
/// on the worktree's index without copying the index wholesale.
///
/// Submodule entries are skipped — see [`is_submodule_entry`].
fn apply_porcelain_v2_entries(
stdout: &[u8],
source: &Path,
worktree: &Path,
) -> Result<ApplyResult> {
let mut copied: u64 = 0;
let mut deleted: u64 = 0;
let mut staged_adds: Vec<(String, String, String)> = Vec::new();
let mut staged_deletes: Vec<String> = Vec::new();
// Split on NUL bytes
let mut chunks = stdout.split(|&b| b == 0).peekable();
while let Some(chunk) = chunks.next() {
if chunk.is_empty() {
continue;
}
let line = std::str::from_utf8(chunk).context("non-UTF-8 path in git status output")?;
if line.starts_with("1 ") || line.starts_with("u ") {
// Ordinary entry: `1 XY sub mH mI mW hH hI path`
// 0 1 2 3 4 5 6 7 8
// Unmerged entry: `u XY sub m1 m2 m3 h1 h2 h3 path`
// 0 1 2 3 4 5 6 7 8 9
if is_submodule_entry(line) {
tracing::debug!(line = %line, "skipping submodule entry in dirty sync");
continue;
}
let path = extract_ordinary_path(line);
let xy = &line[2..4];
apply_file_change(xy, path, source, worktree, &mut copied, &mut deleted)?;
// Track staged changes (X column != '.')
let x = xy.as_bytes()[0];
if x != b'.' {
if x == b'D' {
staged_deletes.push(path.to_string());
} else {
// Extract mode-in-index (field 4) and hash-in-index (field 7)
// from the porcelain v2 line for `git update-index --cacheinfo`.
if let Some((mode, hash)) = extract_index_mode_hash(line) {
staged_adds.push((mode, hash, path.to_string()));
}
}
}
} else if line.starts_with("2 ") {
// Rename/copy entry: `2 XY sub mH mI mW hH hI X<score> path`
// 0 1 2 3 4 5 6 7 8 9
// Followed by the origPath as the next NUL-delimited chunk
if is_submodule_entry(line) {
tracing::debug!(line = %line, "skipping submodule rename entry in dirty sync");
// Consume the origPath chunk so parsing stays aligned.
let _ = chunks.next();
continue;
}
let path = extract_ordinary_path(line);
let xy = &line[2..4];
// Copy the new file
apply_file_change(xy, path, source, worktree, &mut copied, &mut deleted)?;
// Extract mode/hash for the renamed file and stage it
if let Some((mode, hash)) = extract_index_mode_hash(line) {
staged_adds.push((mode, hash, path.to_string()));
}
// Consume the origPath (old name) — it's the next NUL chunk
if let Some(orig_chunk) = chunks.next() {
let orig_path = std::str::from_utf8(orig_chunk)
.context("non-UTF-8 origPath in rename entry")?;
// Delete the old name from destination if it exists
let old_dest = worktree.join(orig_path);
if old_dest.exists() {
let _ = std::fs::remove_file(&old_dest);
deleted += 1;
}
// Stage the deletion of the old path
staged_deletes.push(orig_path.to_string());
}
} else if let Some(path) = line.strip_prefix("? ") {
// Untracked file: `? path`
copy_file_to_worktree(source, worktree, path)?;
copied += 1;
}
// Ignore `!` (ignored files) — we don't replicate those
}
Ok(ApplyResult {
copied,
deleted,
staged_adds,
staged_deletes,
})
}
/// Check whether a porcelain v2 entry is a submodule.
///
/// The `sub` field (space-delimited field 2) is `S...` for submodules and
/// `N...` for regular entries. Submodules are directories on disk, so
/// `reflink_or_copy` cannot handle them. Their committed state is already
/// synced by `git reset --hard`.
fn is_submodule_entry(line: &str) -> bool {
line.split(' ')
.nth(2)
.is_some_and(|sub| sub.starts_with('S'))
}
/// Extract `(mode_in_index, hash_in_index)` from a porcelain v2 line.
///
/// For ordinary entries (`1 XY sub mH mI mW hH hI path`):
/// - `mI` is field 4 (0-indexed), the mode in the index
/// - `hI` is field 7, the object hash in the index
///
/// For rename entries (`2 XY sub mH mI mW hH hI X<score> path`):
/// - Same field positions for mI and hI
///
/// Returns `None` if the line doesn't have enough fields.
fn extract_index_mode_hash(line: &str) -> Option<(String, String)> {
let fields: Vec<&str> = line.splitn(10, ' ').collect();
if fields.len() >= 8 {
// fields[4] = mI (mode in index), fields[7] = hI (hash in index)
Some((fields[4].to_string(), fields[7].to_string()))
} else {
None
}
}
/// Extract the path from a porcelain v2 ordinary/rename/unmerged entry.
///
/// See `git status --help`, section "Changed Tracked Entries" for the format:
/// <https://git-scm.com/docs/git-status#_changed_tracked_entries>
///
/// Format: `1 XY sub mH mI mW hH hI path` (8 space-separated header fields)
/// Rename: `2 XY sub mH mI mW hH hI X<score> path` (9 fields)
/// Unmerged: `u XY sub m1 m2 m3 h1 h2 h3 path` (10 fields)
fn extract_ordinary_path(line: &str) -> &str {
let prefix = if line.starts_with("2 ") {
// Rename: 9 space-separated header fields before path
9
} else if line.starts_with("u ") {
// Unmerged: 10 space-separated header fields before path
10
} else {
// Ordinary: 8 space-separated header fields before path
8
};
let mut spaces_seen = 0;
for (i, c) in line.char_indices() {
if c == ' ' {
spaces_seen += 1;
if spaces_seen == prefix {
return &line[i + 1..];
}
}
}
// Fallback — shouldn't happen with valid porcelain v2 output
line
}
/// Apply a single file change (copy or delete) based on the XY status.
///
/// In porcelain v2, `X` is the **staged** (index) status and `Y` is the
/// **worktree** status. We care about the worktree column (`Y`) for deciding
/// whether the file exists on disk in the source:
///
/// - `Y == 'D'` → file is deleted in the worktree → delete in target
/// - `X == 'D'` with `Y != 'D'` → staged for deletion (`git rm --cached`)
/// but the file **still exists** on disk → copy it
/// - anything else → file exists on disk → copy it
fn apply_file_change(
xy: &str,
path: &str,
source: &Path,
worktree: &Path,
copied: &mut u64,
deleted: &mut u64,
) -> Result<()> {
let y = xy.as_bytes()[1];
if y == b'D' {
// Worktree deletion — file does not exist on disk in source
let dest_file = worktree.join(path);
if dest_file.exists() {
std::fs::remove_file(&dest_file)
.with_context(|| format!("failed to delete {}", dest_file.display()))?;
*deleted += 1;
}
} else {
// File exists on disk in source (modified, added, type-changed,
// staged-deletion-but-still-on-disk, etc.) — copy it
copy_file_to_worktree(source, worktree, path)?;
*copied += 1;
}
Ok(())
}
/// Replay staged changes on the worktree's index.
///
/// For staged additions/modifications, uses `git update-index --cacheinfo`
/// with the exact `(mode, blob_hash, path)` from the source's porcelain v2
/// output. This sets the index entry to the correct blob **without reading
/// the working tree**, which is critical for `XY = "MM"` where the staged
/// content differs from the on-disk content.
///
/// For staged deletions, uses `git rm --cached` to remove the entry from
/// the index while leaving the file on disk (if present).
///
/// Returns the total number of staged entries applied.
fn replay_staged_changes(
worktree: &Path,
staged_adds: &[(String, String, String)],
staged_deletes: &[String],
) -> Result<u64> {
let mut count = 0u64;
// Batch `git update-index --cacheinfo` for all staged adds.
// We use `--stdin` with `-z` for NUL-delimited input to handle
// paths with spaces/special characters safely.
//
// Input format for --cacheinfo via --stdin -z:
// <mode> SP <hex-hash> TAB <path> NUL
if !staged_adds.is_empty() {
use std::io::Write;
let mut child = git_command()
.current_dir(worktree)
.args(["update-index", "-z", "--index-info"])
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.context("failed to spawn git update-index --index-info")?;
{
let stdin = child
.stdin
.as_mut()
.context("no stdin for git update-index")?;
for (mode, hash, path) in staged_adds {
// Format: "<mode> <hash>\t<path>\0"
write!(stdin, "{mode} {hash}\t{path}\0")
.context("failed to write to git update-index stdin")?;
}
}
let output = child
.wait_with_output()
.context("git update-index --index-info failed")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
tracing::warn!(
worktree = %worktree.display(),
stderr = %stderr.trim(),
"git update-index --index-info for staged changes failed (non-fatal)"
);
} else {
count += staged_adds.len() as u64;
}
}
// Batch `git rm --cached` for staged deletions.
// This removes the entry from the index while leaving the file on disk.
if !staged_deletes.is_empty() {
let mut cmd = git_command();
cmd.current_dir(worktree)
.arg("rm")
.arg("--cached")
.arg("--ignore-unmatch")
.arg("--");
for path in staged_deletes {
cmd.arg(path);
}
let output = cmd
.output()
.context("failed to run git rm --cached for staged deletions")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
tracing::warn!(
worktree = %worktree.display(),
stderr = %stderr.trim(),
"git rm --cached for staged deletions failed (non-fatal)"
);
} else {
count += staged_deletes.len() as u64;
}
}
Ok(count)
}
/// Copy a single dirty entry from source to worktree, creating parent dirs as
/// needed.
///
/// Uses `symlink_metadata` (not `exists`) to read the entry type without
/// following the link, so a dangling symlink is recreated, not skipped.
fn copy_file_to_worktree(source: &Path, worktree: &Path, rel_path: &str) -> Result<()> {
let src_file = source.join(rel_path);
let dst_file = worktree.join(rel_path);
let src_meta = match std::fs::symlink_metadata(&src_file) {
Ok(meta) => meta,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
// Source entry disappeared between `git status` and copy — TOCTOU
// race. Expected when files are being actively edited.
tracing::debug!(path = %rel_path, "source file vanished before copy, skipping");
return Ok(());
}
Err(e) => {
return Err(e).with_context(|| format!("failed to stat {}", src_file.display()));
}
};
if let Some(parent) = dst_file.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create parent dirs for {}", dst_file.display()))?;
}
if src_meta.file_type().is_symlink() {
let target = std::fs::read_link(&src_file)
.with_context(|| format!("failed to read symlink {}", src_file.display()))?;
return replace_symlink(&target, &dst_file)
.with_context(|| format!("failed to recreate symlink {}", dst_file.display()));
}
// Regular file: drop any existing dest first (reflink_or_copy can't
// overwrite). symlink_metadata detects a dangling-symlink dest, too.
if std::fs::symlink_metadata(&dst_file).is_ok() {
let _ = std::fs::remove_file(&dst_file);
}
clone_file(&src_file, &dst_file).with_context(|| {
format!(
"failed to copy {} -> {}",
src_file.display(),
dst_file.display()
)
})?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::git::index::copy_git_index;
use kigi_test_utils::git::{git_commit_all, init_git_repo};
use std::path::PathBuf;
use std::process::Command;
use tempfile::TempDir;
/// Helper: create a git worktree from a source repo
fn create_linked_worktree(source: &Path, name: &str) -> PathBuf {
let worktree_path = source.parent().unwrap().join(name);
let output = Command::new("git")
.current_dir(source)
.args([
"worktree",
"add",
"--detach",
worktree_path.to_str().unwrap(),
])
.output()
.unwrap();
assert!(
output.status.success(),
"git worktree add failed: {}",
String::from_utf8_lossy(&output.stderr)
);
worktree_path
}
#[test]
fn test_sync_same_head_no_dirty() {
kigi_test_utils::require_git!();
let temp = TempDir::new().unwrap();
let source = temp.path().join("source");
std::fs::create_dir_all(&source).unwrap();
init_git_repo(&source);
std::fs::write(source.join("file.txt"), "content").unwrap();
git_commit_all(&source, "initial");
let worktree = create_linked_worktree(&source, "wt1");
let sync = WorktreeSync::new(&source, &worktree);
let report = sync.sync_worktree(false).unwrap();
assert!(!report.head_moved);
assert_eq!(report.dirty_files_copied, 0);
}
#[test]
fn test_sync_head_moved() {
kigi_test_utils::require_git!();
let temp = TempDir::new().unwrap();
let source = temp.path().join("source");
std::fs::create_dir_all(&source).unwrap();
init_git_repo(&source);
std::fs::write(source.join("file.txt"), "v1").unwrap();
git_commit_all(&source, "initial");
let worktree = create_linked_worktree(&source, "wt1");
// Make a new commit in source
std::fs::write(source.join("file.txt"), "v2").unwrap();
git_commit_all(&source, "second");
let sync = WorktreeSync::new(&source, &worktree);
let report = sync.sync_worktree(false).unwrap();
assert!(report.head_moved);
// Worktree should have the new content
assert_eq!(
std::fs::read_to_string(worktree.join("file.txt")).unwrap(),
"v2"
);
}
#[test]
fn test_sync_dirty_files() {
kigi_test_utils::require_git!();
let temp = TempDir::new().unwrap();
let source = temp.path().join("source");
std::fs::create_dir_all(&source).unwrap();
init_git_repo(&source);
std::fs::write(source.join("file.txt"), "committed").unwrap();
git_commit_all(&source, "initial");
let worktree = create_linked_worktree(&source, "wt1");
// Make dirty changes in source
std::fs::write(source.join("file.txt"), "modified").unwrap();
std::fs::write(source.join("new_untracked.txt"), "untracked").unwrap();
let sync = WorktreeSync::new(&source, &worktree);
let report = sync.sync_worktree(true).unwrap();
assert!(report.dirty_files_copied >= 2);
// Worktree should have the dirty files
assert_eq!(
std::fs::read_to_string(worktree.join("file.txt")).unwrap(),
"modified"
);
assert_eq!(
std::fs::read_to_string(worktree.join("new_untracked.txt")).unwrap(),
"untracked"
);
}
#[test]
fn test_sync_deleted_file() {
kigi_test_utils::require_git!();
let temp = TempDir::new().unwrap();
let source = temp.path().join("source");
std::fs::create_dir_all(&source).unwrap();
init_git_repo(&source);
std::fs::write(source.join("keep.txt"), "keep").unwrap();
std::fs::write(source.join("delete_me.txt"), "bye").unwrap();
git_commit_all(&source, "initial");
let worktree = create_linked_worktree(&source, "wt1");
// Delete a tracked file in source
std::fs::remove_file(source.join("delete_me.txt")).unwrap();
let sync = WorktreeSync::new(&source, &worktree);
let report = sync.sync_worktree(true).unwrap();
assert!(report.files_deleted >= 1);
// File should be gone in worktree too
assert!(!worktree.join("delete_me.txt").exists());
}
#[test]
fn test_sync_head_moved_with_dirty() {
kigi_test_utils::require_git!();
let temp = TempDir::new().unwrap();
let source = temp.path().join("source");
std::fs::create_dir_all(&source).unwrap();
init_git_repo(&source);
std::fs::write(source.join("file.txt"), "v1").unwrap();
git_commit_all(&source, "initial");
let worktree = create_linked_worktree(&source, "wt1");
// New commit + dirty changes in source
std::fs::write(source.join("file.txt"), "v2").unwrap();
git_commit_all(&source, "second");
std::fs::write(source.join("file.txt"), "v2-dirty").unwrap();
let sync = WorktreeSync::new(&source, &worktree);
let report = sync.sync_worktree(true).unwrap();
assert!(report.head_moved);
assert!(report.dirty_files_copied >= 1);
assert_eq!(
std::fs::read_to_string(worktree.join("file.txt")).unwrap(),
"v2-dirty"
);
}
#[test]
fn test_sync_rename() {
kigi_test_utils::require_git!();
let temp = TempDir::new().unwrap();
let source = temp.path().join("source");
std::fs::create_dir_all(&source).unwrap();
init_git_repo(&source);
std::fs::write(source.join("old_name.txt"), "content").unwrap();
git_commit_all(&source, "initial");
let worktree = create_linked_worktree(&source, "wt1");
// Rename file in source (git mv)
Command::new("git")
.current_dir(&source)
.args(["mv", "old_name.txt", "new_name.txt"])
.output()
.unwrap();
let sync = WorktreeSync::new(&source, &worktree);
let report = sync.sync_worktree(true).unwrap();
// Should have copied new_name.txt and (potentially) deleted old_name.txt
assert!(report.dirty_files_copied >= 1 || report.files_deleted >= 1);
assert!(worktree.join("new_name.txt").exists());
assert_eq!(
std::fs::read_to_string(worktree.join("new_name.txt")).unwrap(),
"content"
);
}
#[test]
fn test_copy_git_index() {
kigi_test_utils::require_git!();
let temp = TempDir::new().unwrap();
let source = temp.path().join("source");
std::fs::create_dir_all(&source).unwrap();
init_git_repo(&source);
std::fs::write(source.join("file.txt"), "content").unwrap();
git_commit_all(&source, "initial");
// Stage a change (modify the index)
std::fs::write(source.join("file.txt"), "staged").unwrap();
Command::new("git")
.current_dir(&source)
.args(["add", "file.txt"])
.output()
.unwrap();
let worktree = create_linked_worktree(&source, "wt1");
// Copy index (via the shared git::index function)
let copied = copy_git_index(&source, &worktree).unwrap();
assert!(copied, "index should have been copied");
// The worktree's index should now reflect the staged change
let output = Command::new("git")
.current_dir(&worktree)
.args(["diff", "--cached", "--name-only"])
.output()
.unwrap();
let staged = String::from_utf8_lossy(&output.stdout);
assert!(
staged.contains("file.txt"),
"index copy should replicate staging state"
);
}
#[test]
fn test_sync_subdirectory_creation() {
kigi_test_utils::require_git!();
let temp = TempDir::new().unwrap();
let source = temp.path().join("source");
std::fs::create_dir_all(&source).unwrap();
init_git_repo(&source);
std::fs::write(source.join("file.txt"), "root").unwrap();
git_commit_all(&source, "initial");
let worktree = create_linked_worktree(&source, "wt1");
// Create nested untracked file in source
std::fs::create_dir_all(source.join("deeply/nested/dir")).unwrap();
std::fs::write(source.join("deeply/nested/dir/file.txt"), "deep").unwrap();
let sync = WorktreeSync::new(&source, &worktree);
let report = sync.sync_worktree(true).unwrap();
assert!(report.dirty_files_copied >= 1);
assert_eq!(
std::fs::read_to_string(worktree.join("deeply/nested/dir/file.txt")).unwrap(),
"deep"
);
}
#[test]
fn test_sync_copy_dirty_false_does_not_copy_index() {
kigi_test_utils::require_git!();
let temp = TempDir::new().unwrap();
let source = temp.path().join("source");
std::fs::create_dir_all(&source).unwrap();
init_git_repo(&source);
std::fs::write(source.join("file.txt"), "content").unwrap();
git_commit_all(&source, "initial");
// Stage a change in source
std::fs::write(source.join("file.txt"), "staged-change").unwrap();
Command::new("git")
.current_dir(&source)
.args(["add", "file.txt"])
.output()
.unwrap();
let worktree = create_linked_worktree(&source, "wt1");
// Sync without dirty state
let sync = WorktreeSync::new(&source, &worktree);
let report = sync.sync_worktree(false).unwrap();
assert_eq!(report.staged_entries, 0);
assert_eq!(report.dirty_files_copied, 0);
// Worktree should NOT have the staged change
let output = Command::new("git")
.current_dir(&worktree)
.args(["diff", "--cached", "--name-only"])
.output()
.unwrap();
let staged = String::from_utf8_lossy(&output.stdout);
assert!(
!staged.contains("file.txt"),
"staged changes should not be replicated when copy_dirty=false"
);
}
#[test]
fn test_sync_file_with_spaces_and_unicode() {
kigi_test_utils::require_git!();
let temp = TempDir::new().unwrap();
let source = temp.path().join("source");
std::fs::create_dir_all(&source).unwrap();
init_git_repo(&source);
std::fs::write(source.join("file.txt"), "anchor").unwrap();
git_commit_all(&source, "initial");
let worktree = create_linked_worktree(&source, "wt1");
// Create files with spaces and unicode in source
std::fs::write(source.join("file with spaces.txt"), "spaces").unwrap();
std::fs::create_dir_all(source.join("dir with spaces")).unwrap();
std::fs::write(
source.join("dir with spaces/nested file.txt"),
"nested spaces",
)
.unwrap();
std::fs::write(source.join("日本語ファイル.txt"), "unicode").unwrap();
let sync = WorktreeSync::new(&source, &worktree);
let report = sync.sync_worktree(true).unwrap();
assert!(report.dirty_files_copied >= 3);
assert_eq!(
std::fs::read_to_string(worktree.join("file with spaces.txt")).unwrap(),
"spaces"
);
assert_eq!(
std::fs::read_to_string(worktree.join("dir with spaces/nested file.txt")).unwrap(),
"nested spaces"
);
assert_eq!(
std::fs::read_to_string(worktree.join("日本語ファイル.txt")).unwrap(),
"unicode"
);
}
#[test]
fn test_sync_staged_deletion_file_still_on_disk() {
kigi_test_utils::require_git!();
// Regression test: `git rm --cached` stages a deletion but
// leaves the file on disk. We should copy the file, not delete it.
let temp = TempDir::new().unwrap();
let source = temp.path().join("source");
std::fs::create_dir_all(&source).unwrap();
init_git_repo(&source);
std::fs::write(source.join("cached.txt"), "still here").unwrap();
git_commit_all(&source, "initial");
let worktree = create_linked_worktree(&source, "wt1");
// Stage deletion but keep on disk
Command::new("git")
.current_dir(&source)
.args(["rm", "--cached", "cached.txt"])
.output()
.unwrap();
// File should still exist on disk in source
assert!(source.join("cached.txt").exists());
let sync = WorktreeSync::new(&source, &worktree);
let report = sync.sync_worktree(true).unwrap();
// The file should be COPIED (it exists on disk), not deleted
assert!(
worktree.join("cached.txt").exists(),
"file should be copied since it's still on disk in source"
);
assert!(report.dirty_files_copied >= 1);
}
#[test]
fn test_is_submodule_entry() {
// Ordinary submodule
assert!(is_submodule_entry(
"1 .M SC.. 160000 160000 160000 aeed4e8a def456 submodules/example-submodule"
));
// Submodule with all flags set
assert!(is_submodule_entry(
"1 .M SCMU 160000 160000 160000 aeed4e8a def456 submodules/foo"
));
// Regular file
assert!(!is_submodule_entry(
"1 .M N... 100644 100644 100644 abc123 def456 src/main.rs"
));
// Rename entry for submodule
assert!(is_submodule_entry(
"2 R. SC.. 160000 160000 160000 abc123 def456 R100 submodules/new-name"
));
// Unmerged submodule
assert!(is_submodule_entry(
"u UU SC.. 160000 160000 160000 160000 abc123 def456 ghi789 submodules/foo"
));
// Untracked entry (no sub field) — should not match
assert!(!is_submodule_entry("? some/path"));
}
#[test]
fn test_apply_skips_submodule_entries() {
let temp = TempDir::new().unwrap();
let source = temp.path().join("src");
let worktree = temp.path().join("wt");
std::fs::create_dir_all(&source).unwrap();
std::fs::create_dir_all(&worktree).unwrap();
std::fs::write(source.join("file.txt"), "modified").unwrap();
// Submodule is a directory on disk — must not be passed to reflink_or_copy
std::fs::create_dir_all(source.join("submodules/example-submodule")).unwrap();
// Porcelain v2: regular file + submodule entry
let status_output = b"1 .M N... 100644 100644 100644 abc123 def456 file.txt\x001 .M SC.. 160000 160000 160000 aeed4e def456 submodules/example-submodule\x00";
let result = apply_porcelain_v2_entries(status_output, &source, &worktree).unwrap();
assert_eq!(result.copied, 1, "only the regular file should be copied");
assert_eq!(result.deleted, 0);
assert!(worktree.join("file.txt").exists());
}
#[test]
fn test_extract_ordinary_path_type1() {
// Ordinary changed entry
let line = "1 .M N... 100644 100644 100644 abc123 def456 src/main.rs";
assert_eq!(extract_ordinary_path(line), "src/main.rs");
}
#[test]
fn test_extract_ordinary_path_type2() {
// Rename entry
let line = "2 R. N... 100644 100644 100644 abc123 def456 R100 new_name.rs";
assert_eq!(extract_ordinary_path(line), "new_name.rs");
}
#[test]
fn test_extract_ordinary_path_unmerged() {
// Unmerged entry
let line = "u UU N... 100644 100644 100644 100644 abc123 def456 ghi789 conflict.rs";
assert_eq!(extract_ordinary_path(line), "conflict.rs");
}
#[test]
fn test_sync_branch_changed() {
kigi_test_utils::require_git!();
// Source checks out a different branch after worktree was created.
// The worktree (detached) should sync to the new branch's HEAD.
let temp = TempDir::new().unwrap();
let source = temp.path().join("source");
std::fs::create_dir_all(&source).unwrap();
init_git_repo(&source);
std::fs::write(source.join("file.txt"), "main-v1").unwrap();
git_commit_all(&source, "initial on main");
let worktree = create_linked_worktree(&source, "wt1");
// Create a new branch in source and switch to it
Command::new("git")
.current_dir(&source)
.args(["checkout", "-b", "feature-branch"])
.output()
.unwrap();
std::fs::write(source.join("file.txt"), "feature-v1").unwrap();
std::fs::write(source.join("feature_only.txt"), "feature file").unwrap();
git_commit_all(&source, "commit on feature-branch");
// Sync — worktree should jump to the feature branch's HEAD
let sync = WorktreeSync::new(&source, &worktree);
let report = sync.sync_worktree(false).unwrap();
assert!(
report.head_moved,
"HEAD should have moved to feature branch"
);
assert_eq!(
std::fs::read_to_string(worktree.join("file.txt")).unwrap(),
"feature-v1"
);
assert!(
worktree.join("feature_only.txt").exists(),
"feature-only file should exist in worktree"
);
}
#[test]
fn test_sync_multiple_commits_ahead() {
kigi_test_utils::require_git!();
// Source is many commits ahead of the worktree.
// git reset --hard should jump directly regardless of distance.
let temp = TempDir::new().unwrap();
let source = temp.path().join("source");
std::fs::create_dir_all(&source).unwrap();
init_git_repo(&source);
std::fs::write(source.join("file.txt"), "v1").unwrap();
git_commit_all(&source, "commit 1");
let worktree = create_linked_worktree(&source, "wt1");
// Make 5 more commits in source
for i in 2..=6 {
std::fs::write(source.join("file.txt"), format!("v{i}")).unwrap();
std::fs::write(
source.join(format!("added_in_commit_{i}.txt")),
format!("new in {i}"),
)
.unwrap();
git_commit_all(&source, &format!("commit {i}"));
}
let sync = WorktreeSync::new(&source, &worktree);
let report = sync.sync_worktree(false).unwrap();
assert!(report.head_moved, "HEAD should have moved 5 commits ahead");
// Verify the latest content
assert_eq!(
std::fs::read_to_string(worktree.join("file.txt")).unwrap(),
"v6"
);
// Verify all new files exist
for i in 2..=6 {
assert!(
worktree.join(format!("added_in_commit_{i}.txt")).exists(),
"file from commit {i} should exist"
);
}
}
#[test]
fn test_sync_staged_and_worktree_modifications_same_file() {
kigi_test_utils::require_git!();
// A file has BOTH staged changes (in the index) AND further worktree
// modifications on top. The sync should replicate the on-disk state
// (worktree version) and the index (staged version).
let temp = TempDir::new().unwrap();
let source = temp.path().join("source");
std::fs::create_dir_all(&source).unwrap();
init_git_repo(&source);
std::fs::write(source.join("file.txt"), "original").unwrap();
git_commit_all(&source, "initial");
let worktree = create_linked_worktree(&source, "wt1");
// Stage a change
std::fs::write(source.join("file.txt"), "staged-version").unwrap();
Command::new("git")
.current_dir(&source)
.args(["add", "file.txt"])
.output()
.unwrap();
// Make further worktree modifications on top of the staged change
std::fs::write(source.join("file.txt"), "worktree-version").unwrap();
let sync = WorktreeSync::new(&source, &worktree);
let report = sync.sync_worktree(true).unwrap();
assert!(report.dirty_files_copied >= 1);
assert!(
report.staged_entries >= 1,
"staged change should be replicated"
);
// On-disk content should be the worktree version (latest on-disk state)
assert_eq!(
std::fs::read_to_string(worktree.join("file.txt")).unwrap(),
"worktree-version"
);
// Index should have the staged version (not the worktree version)
let output = Command::new("git")
.current_dir(&worktree)
.args(["diff", "--cached", "--name-only"])
.output()
.unwrap();
let staged = String::from_utf8_lossy(&output.stdout);
assert!(
staged.contains("file.txt"),
"file should appear as staged in worktree's index"
);
// Verify the staged content is the "staged-version" (not "worktree-version")
let output = Command::new("git")
.current_dir(&worktree)
.args(["show", ":file.txt"])
.output()
.unwrap();
assert!(
output.status.success(),
"git show :file.txt failed: {}",
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(
String::from_utf8_lossy(&output.stdout),
"staged-version",
"index should contain the staged version, not the worktree version"
);
}
// ========================================================================
// skip_clean=true tests (pool path)
//
// The worktree pool calls sync_worktree_opts(copy_dirty, skip_clean=true)
// because pool worktrees are known-clean (freshly created or just
// released). These tests verify that commits, dirty files, and untracked
// files are correctly replicated through that code path.
// ========================================================================
#[test]
fn test_skip_clean_commit_replication() {
kigi_test_utils::require_git!();
// Pool scenario: worktree created, source gets new commits, sync
// with skip_clean=true should still replicate them via reset --hard.
let temp = TempDir::new().unwrap();
let source = temp.path().join("source");
std::fs::create_dir_all(&source).unwrap();
init_git_repo(&source);
std::fs::write(source.join("file.txt"), "v1").unwrap();
git_commit_all(&source, "initial");
let worktree = create_linked_worktree(&source, "wt1");
// Source advances by 3 commits after worktree creation
std::fs::write(source.join("file.txt"), "v2").unwrap();
git_commit_all(&source, "commit 2");
std::fs::write(source.join("new_file.txt"), "added in commit 3").unwrap();
git_commit_all(&source, "commit 3");
std::fs::write(source.join("file.txt"), "v4").unwrap();
git_commit_all(&source, "commit 4");
let sync = WorktreeSync::new(&source, &worktree);
let report = sync.sync_worktree_opts(false, true).unwrap();
assert!(
report.head_moved,
"HEAD should move to source's latest commit"
);
assert!(report.clean_skipped, "clean_skipped should be reported");
assert_eq!(
std::fs::read_to_string(worktree.join("file.txt")).unwrap(),
"v4",
"worktree should have latest committed content"
);
assert_eq!(
std::fs::read_to_string(worktree.join("new_file.txt")).unwrap(),
"added in commit 3",
"file from intermediate commit should exist"
);
}
#[test]
fn test_skip_clean_dirty_and_untracked_replication() {
kigi_test_utils::require_git!();
// Pool scenario: worktree is clean, source has dirty tracked files
// and new untracked files. skip_clean=true + copy_dirty=true should
// replicate both.
let temp = TempDir::new().unwrap();
let source = temp.path().join("source");
std::fs::create_dir_all(&source).unwrap();
init_git_repo(&source);
std::fs::write(source.join("tracked.txt"), "committed").unwrap();
git_commit_all(&source, "initial");
let worktree = create_linked_worktree(&source, "wt1");
// Dirty modifications in source (no commit)
std::fs::write(source.join("tracked.txt"), "dirty-modification").unwrap();
std::fs::write(source.join("brand_new.txt"), "untracked content").unwrap();
std::fs::create_dir_all(source.join("new_dir")).unwrap();
std::fs::write(source.join("new_dir/nested.txt"), "nested untracked").unwrap();
let sync = WorktreeSync::new(&source, &worktree);
let report = sync.sync_worktree_opts(true, true).unwrap();
assert!(!report.head_moved, "HEAD unchanged — no new commits");
assert!(
report.dirty_files_copied >= 3,
"should copy tracked mod + 2 untracked"
);
assert_eq!(
std::fs::read_to_string(worktree.join("tracked.txt")).unwrap(),
"dirty-modification"
);
assert_eq!(
std::fs::read_to_string(worktree.join("brand_new.txt")).unwrap(),
"untracked content"
);
assert_eq!(
std::fs::read_to_string(worktree.join("new_dir/nested.txt")).unwrap(),
"nested untracked"
);
}
#[test]
fn test_skip_clean_commit_plus_dirty() {
kigi_test_utils::require_git!();
// Full pool scenario: source advanced by commits AND has dirty state
// on top. skip_clean=true + copy_dirty=true.
let temp = TempDir::new().unwrap();
let source = temp.path().join("source");
std::fs::create_dir_all(&source).unwrap();
init_git_repo(&source);
std::fs::write(source.join("file.txt"), "v1").unwrap();
git_commit_all(&source, "initial");
let worktree = create_linked_worktree(&source, "wt1");
// New commit
std::fs::write(source.join("file.txt"), "v2-committed").unwrap();
std::fs::write(source.join("committed_new.txt"), "from commit").unwrap();
git_commit_all(&source, "second");
// Dirty state on top of the new commit
std::fs::write(source.join("file.txt"), "v2-dirty-overlay").unwrap();
std::fs::write(source.join("untracked.txt"), "untracked").unwrap();
let sync = WorktreeSync::new(&source, &worktree);
let report = sync.sync_worktree_opts(true, true).unwrap();
assert!(report.head_moved);
assert!(report.dirty_files_copied >= 2);
// Committed file from second commit should exist
assert_eq!(
std::fs::read_to_string(worktree.join("committed_new.txt")).unwrap(),
"from commit"
);
// Dirty overlay should win over committed content
assert_eq!(
std::fs::read_to_string(worktree.join("file.txt")).unwrap(),
"v2-dirty-overlay"
);
// Untracked file should be copied
assert_eq!(
std::fs::read_to_string(worktree.join("untracked.txt")).unwrap(),
"untracked"
);
}
#[test]
fn test_skip_clean_staged_changes_replicated() {
kigi_test_utils::require_git!();
// Pool scenario: source has staged (indexed) changes.
// skip_clean=true + copy_dirty=true should replay staged entries
// via git update-index.
let temp = TempDir::new().unwrap();
let source = temp.path().join("source");
std::fs::create_dir_all(&source).unwrap();
init_git_repo(&source);
std::fs::write(source.join("file.txt"), "original").unwrap();
git_commit_all(&source, "initial");
let worktree = create_linked_worktree(&source, "wt1");
// Stage a modification in source
std::fs::write(source.join("file.txt"), "staged-content").unwrap();
Command::new("git")
.current_dir(&source)
.args(["add", "file.txt"])
.output()
.unwrap();
let sync = WorktreeSync::new(&source, &worktree);
let report = sync.sync_worktree_opts(true, true).unwrap();
assert!(report.staged_entries >= 1, "should replicate staged entry");
assert!(
report.dirty_files_copied >= 1,
"should copy the staged file"
);
// On-disk content should match source
assert_eq!(
std::fs::read_to_string(worktree.join("file.txt")).unwrap(),
"staged-content"
);
// Index should show file as staged
let output = Command::new("git")
.current_dir(&worktree)
.args(["diff", "--cached", "--name-only"])
.output()
.unwrap();
let staged = String::from_utf8_lossy(&output.stdout);
assert!(
staged.contains("file.txt"),
"file should be staged in worktree index"
);
}
#[test]
fn test_skip_clean_sequential_reuse() {
kigi_test_utils::require_git!();
// Simulates pool worktree reuse: create → sync → (simulate use) →
// source changes → sync again. Both syncs use skip_clean=true.
let temp = TempDir::new().unwrap();
let source = temp.path().join("source");
std::fs::create_dir_all(&source).unwrap();
init_git_repo(&source);
std::fs::write(source.join("file.txt"), "v1").unwrap();
git_commit_all(&source, "initial");
let worktree = create_linked_worktree(&source, "wt1");
// --- First sync: source advanced ---
std::fs::write(source.join("file.txt"), "v2").unwrap();
git_commit_all(&source, "second");
let sync = WorktreeSync::new(&source, &worktree);
let report1 = sync.sync_worktree_opts(false, true).unwrap();
assert!(report1.head_moved);
assert_eq!(
std::fs::read_to_string(worktree.join("file.txt")).unwrap(),
"v2"
);
// --- Simulate release: reset --hard + clean (what the pool does) ---
Command::new("git")
.current_dir(&worktree)
.args(["reset", "--hard"])
.output()
.unwrap();
Command::new("git")
.current_dir(&worktree)
.args(["clean", "-fd"])
.output()
.unwrap();
// --- Second sync: source advanced again with dirty state ---
std::fs::write(source.join("file.txt"), "v3").unwrap();
git_commit_all(&source, "third");
std::fs::write(source.join("file.txt"), "v3-dirty").unwrap();
std::fs::write(source.join("second_use.txt"), "new untracked").unwrap();
let report2 = sync.sync_worktree_opts(true, true).unwrap();
assert!(report2.head_moved);
assert!(report2.dirty_files_copied >= 2);
assert_eq!(
std::fs::read_to_string(worktree.join("file.txt")).unwrap(),
"v3-dirty"
);
assert_eq!(
std::fs::read_to_string(worktree.join("second_use.txt")).unwrap(),
"new untracked"
);
}
#[test]
fn test_skip_clean_does_not_remove_leftover_untracked() {
kigi_test_utils::require_git!();
// Documents the skip_clean contract: if the worktree has leftover
// untracked files from a previous use and skip_clean=true is passed,
// those files PERSIST. This is correct for the pool because pool
// worktrees are always cleaned before being returned to the ready
// state.
let temp = TempDir::new().unwrap();
let source = temp.path().join("source");
std::fs::create_dir_all(&source).unwrap();
init_git_repo(&source);
std::fs::write(source.join("file.txt"), "v1").unwrap();
git_commit_all(&source, "initial");
let worktree = create_linked_worktree(&source, "wt1");
// Simulate leftover untracked file in worktree (from previous use)
std::fs::write(worktree.join("leftover.txt"), "stale").unwrap();
// Sync with skip_clean=true — leftover should survive
let sync = WorktreeSync::new(&source, &worktree);
let _report = sync.sync_worktree_opts(false, true).unwrap();
assert!(
worktree.join("leftover.txt").exists(),
"skip_clean=true should NOT remove leftover untracked files"
);
// Contrast: sync with skip_clean=false removes the leftover
let _report = sync.sync_worktree_opts(false, false).unwrap();
assert!(
!worktree.join("leftover.txt").exists(),
"skip_clean=false should remove leftover untracked files"
);
}
#[test]
fn test_skip_clean_deleted_tracked_file_replicated() {
kigi_test_utils::require_git!();
// Source deletes a tracked file. skip_clean=true + copy_dirty=true
// should replicate the deletion in the worktree.
let temp = TempDir::new().unwrap();
let source = temp.path().join("source");
std::fs::create_dir_all(&source).unwrap();
init_git_repo(&source);
std::fs::write(source.join("keep.txt"), "keep").unwrap();
std::fs::write(source.join("delete_me.txt"), "bye").unwrap();
git_commit_all(&source, "initial");
let worktree = create_linked_worktree(&source, "wt1");
// Delete tracked file in source (dirty deletion, not committed)
std::fs::remove_file(source.join("delete_me.txt")).unwrap();
let sync = WorktreeSync::new(&source, &worktree);
let report = sync.sync_worktree_opts(true, true).unwrap();
assert!(report.files_deleted >= 1);
assert!(
!worktree.join("delete_me.txt").exists(),
"deleted tracked file should be removed from worktree"
);
assert!(
worktree.join("keep.txt").exists(),
"non-deleted file should still exist"
);
}
#[test]
fn test_skip_clean_branch_switch_with_dirty() {
kigi_test_utils::require_git!();
// Source switches branches and has dirty state. skip_clean=true
// should handle the branch jump + dirty overlay.
let temp = TempDir::new().unwrap();
let source = temp.path().join("source");
std::fs::create_dir_all(&source).unwrap();
init_git_repo(&source);
std::fs::write(source.join("file.txt"), "main-v1").unwrap();
git_commit_all(&source, "initial on main");
let worktree = create_linked_worktree(&source, "wt1");
// Switch to feature branch, commit, then add dirty state
Command::new("git")
.current_dir(&source)
.args(["checkout", "-b", "feature"])
.output()
.unwrap();
std::fs::write(source.join("feature.txt"), "feature file").unwrap();
git_commit_all(&source, "feature commit");
std::fs::write(source.join("feature.txt"), "feature-dirty").unwrap();
std::fs::write(source.join("untracked_feature.txt"), "untracked on feature").unwrap();
let sync = WorktreeSync::new(&source, &worktree);
let report = sync.sync_worktree_opts(true, true).unwrap();
assert!(report.head_moved, "HEAD should jump to feature branch");
assert!(report.dirty_files_copied >= 2);
// Committed feature file should exist with dirty overlay
assert_eq!(
std::fs::read_to_string(worktree.join("feature.txt")).unwrap(),
"feature-dirty"
);
assert_eq!(
std::fs::read_to_string(worktree.join("untracked_feature.txt")).unwrap(),
"untracked on feature"
);
}
// ── sync_from_precomputed tests ──
#[test]
fn test_sync_from_precomputed_none_skips_dirty() {
kigi_test_utils::require_git!();
let temp = TempDir::new().unwrap();
let source = temp.path().join("source");
std::fs::create_dir_all(&source).unwrap();
init_git_repo(&source);
std::fs::write(source.join("file.txt"), "committed").unwrap();
git_commit_all(&source, "initial");
let worktree = create_linked_worktree(&source, "wt1");
// Make dirty changes in source
std::fs::write(source.join("file.txt"), "modified").unwrap();
// sync_from_precomputed with None should skip dirty sync
let sync = WorktreeSync::new(&source, &worktree);
let report = sync.sync_from_precomputed(None, true).unwrap();
assert!(report.dirty_skipped);
assert_eq!(report.dirty_files_copied, 0);
// Worktree should NOT have the dirty change
assert_eq!(
std::fs::read_to_string(worktree.join("file.txt")).unwrap(),
"committed"
);
}
#[test]
fn test_sync_from_precomputed_empty_skips_dirty() {
kigi_test_utils::require_git!();
let temp = TempDir::new().unwrap();
let source = temp.path().join("source");
std::fs::create_dir_all(&source).unwrap();
init_git_repo(&source);
std::fs::write(source.join("file.txt"), "committed").unwrap();
git_commit_all(&source, "initial");
let worktree = create_linked_worktree(&source, "wt1");
// Collect dirty state from a clean source
let dirty = super::collect_source_dirty_state(&source).unwrap();
assert!(dirty.is_empty());
let sync = WorktreeSync::new(&source, &worktree);
let report = sync.sync_from_precomputed(Some(&dirty), true).unwrap();
assert!(report.dirty_skipped);
assert_eq!(report.dirty_files_copied, 0);
}
#[test]
fn test_sync_from_precomputed_with_dirty_files() {
kigi_test_utils::require_git!();
let temp = TempDir::new().unwrap();
let source = temp.path().join("source");
std::fs::create_dir_all(&source).unwrap();
init_git_repo(&source);
std::fs::write(source.join("file.txt"), "committed").unwrap();
git_commit_all(&source, "initial");
let worktree = create_linked_worktree(&source, "wt1");
// Make dirty changes, then collect
std::fs::write(source.join("file.txt"), "modified").unwrap();
std::fs::write(source.join("untracked.txt"), "new file").unwrap();
let dirty = super::collect_source_dirty_state(&source).unwrap();
assert!(!dirty.is_empty());
let sync = WorktreeSync::new(&source, &worktree);
let report = sync.sync_from_precomputed(Some(&dirty), true).unwrap();
assert!(!report.dirty_skipped);
assert!(report.dirty_files_copied >= 2);
// Worktree should have the dirty changes
assert_eq!(
std::fs::read_to_string(worktree.join("file.txt")).unwrap(),
"modified"
);
assert_eq!(
std::fs::read_to_string(worktree.join("untracked.txt")).unwrap(),
"new file"
);
}
#[test]
fn test_sync_from_precomputed_shared_across_two_worktrees() {
kigi_test_utils::require_git!();
// The core use case: collect once, apply to two worktrees.
let temp = TempDir::new().unwrap();
let source = temp.path().join("source");
std::fs::create_dir_all(&source).unwrap();
init_git_repo(&source);
std::fs::write(source.join("file.txt"), "committed").unwrap();
git_commit_all(&source, "initial");
let wt_a = create_linked_worktree(&source, "wt_a");
let wt_b = create_linked_worktree(&source, "wt_b");
// Make dirty changes, collect once
std::fs::write(source.join("file.txt"), "dirty").unwrap();
let dirty = super::collect_source_dirty_state(&source).unwrap();
// Apply to both worktrees (clone is cheap — Bytes ref-count bump)
let sync_a = WorktreeSync::new(&source, &wt_a);
let report_a = sync_a.sync_from_precomputed(Some(&dirty), true).unwrap();
let sync_b = WorktreeSync::new(&source, &wt_b);
let report_b = sync_b.sync_from_precomputed(Some(&dirty), true).unwrap();
assert!(report_a.dirty_files_copied >= 1);
assert!(report_b.dirty_files_copied >= 1);
assert_eq!(
std::fs::read_to_string(wt_a.join("file.txt")).unwrap(),
"dirty"
);
assert_eq!(
std::fs::read_to_string(wt_b.join("file.txt")).unwrap(),
"dirty"
);
}
#[cfg(unix)]
#[test]
fn test_sync_replicates_symlinks_not_regular_files() {
kigi_test_utils::require_git!();
// Dirty symlinks (valid and dangling) must land in the worktree as
// symlinks, never dereferenced to regular files and never skipped.
let temp = TempDir::new().unwrap();
let source = temp.path().join("source");
std::fs::create_dir_all(&source).unwrap();
init_git_repo(&source);
std::fs::write(source.join("target.txt"), "real content").unwrap();
git_commit_all(&source, "initial");
let worktree = create_linked_worktree(&source, "wt1");
// Dirty source fixtures: valid symlink, dangling symlink, regular file.
std::os::unix::fs::symlink("target.txt", source.join("link.txt")).unwrap();
std::os::unix::fs::symlink("does-not-exist", source.join("dangling.txt")).unwrap();
std::fs::write(source.join("regular.txt"), "regular").unwrap();
let sync = WorktreeSync::new(&source, &worktree);
sync.sync_worktree(true).unwrap();
let link_meta = std::fs::symlink_metadata(worktree.join("link.txt")).unwrap();
assert!(
link_meta.file_type().is_symlink(),
"link.txt must remain a symlink, not become a regular file"
);
assert_eq!(
std::fs::read_link(worktree.join("link.txt")).unwrap(),
PathBuf::from("target.txt")
);
let dangling_meta = std::fs::symlink_metadata(worktree.join("dangling.txt")).unwrap();
assert!(
dangling_meta.file_type().is_symlink(),
"dangling.txt must be recreated as a symlink"
);
assert_eq!(
std::fs::read_link(worktree.join("dangling.txt")).unwrap(),
PathBuf::from("does-not-exist")
);
let reg_meta = std::fs::symlink_metadata(worktree.join("regular.txt")).unwrap();
assert!(reg_meta.file_type().is_file());
assert_eq!(
std::fs::read_to_string(worktree.join("regular.txt")).unwrap(),
"regular"
);
}
#[cfg(unix)]
#[test]
fn test_copy_file_replaces_dangling_symlink_dest_with_regular_file() {
// Dest holds a dangling symlink; copying a regular file over it must
// replace it — reflink can't overwrite and `exists()` misses a broken link.
let temp = TempDir::new().unwrap();
let source = temp.path().join("src");
let worktree = temp.path().join("wt");
std::fs::create_dir_all(&source).unwrap();
std::fs::create_dir_all(&worktree).unwrap();
std::fs::write(source.join("file.txt"), "real content").unwrap();
std::os::unix::fs::symlink("does-not-exist", worktree.join("file.txt")).unwrap();
copy_file_to_worktree(&source, &worktree, "file.txt").unwrap();
let meta = std::fs::symlink_metadata(worktree.join("file.txt")).unwrap();
assert!(
meta.file_type().is_file(),
"dest must become a regular file, not stay a symlink"
);
assert_eq!(
std::fs::read_to_string(worktree.join("file.txt")).unwrap(),
"real content"
);
}
#[cfg(unix)]
#[test]
fn test_sync_updates_modified_tracked_symlink_target() {
kigi_test_utils::require_git!();
// Re-pointing a tracked symlink in source must update the worktree dest
// to the new target while keeping it a symlink.
let temp = TempDir::new().unwrap();
let source = temp.path().join("source");
std::fs::create_dir_all(&source).unwrap();
init_git_repo(&source);
std::fs::write(source.join("target_a.txt"), "a").unwrap();
std::fs::write(source.join("target_b.txt"), "b").unwrap();
std::os::unix::fs::symlink("target_a.txt", source.join("link.txt")).unwrap();
git_commit_all(&source, "initial");
let worktree = create_linked_worktree(&source, "wt1");
assert_eq!(
std::fs::read_link(worktree.join("link.txt")).unwrap(),
PathBuf::from("target_a.txt"),
"worktree starts with the committed symlink target"
);
// Re-point the tracked symlink in source (modified, uncommitted).
std::fs::remove_file(source.join("link.txt")).unwrap();
std::os::unix::fs::symlink("target_b.txt", source.join("link.txt")).unwrap();
let sync = WorktreeSync::new(&source, &worktree);
sync.sync_worktree(true).unwrap();
let meta = std::fs::symlink_metadata(worktree.join("link.txt")).unwrap();
assert!(
meta.file_type().is_symlink(),
"dest must remain a symlink after the update"
);
assert_eq!(
std::fs::read_link(worktree.join("link.txt")).unwrap(),
PathBuf::from("target_b.txt"),
"dest symlink must be updated to the new target"
);
}
}