Sweep every first-party crate source (1956 .rs files) to the project comment guidelines: delete redundant restatements, decorative banners, change narration, and end-of-line comments; keep and tighten the crucial ones (invariants, bug rationale, SAFETY blocks, ported-source attribution). No functional code changed. Every edit is proven comment-only against the prior tree by a comment-stripping lexer (string/char/raw-string aware) plus a separate doctest-fence check. Where removing a comment made rustfmt or clippy want to re-lay-out adjacent code, the minimal triggering comment is restored so code tokens stay byte-identical. Gates green: cargo fmt --all --check (0 diffs), cargo check and cargo clippy --workspace --all-targets (0 warnings). Adds scripts/check_codegen_comment_guidelines.py — the enforcement gate for these guidelines (flags banners, end-of-line comments, change narration, and commented-out code).
1911 lines
73 KiB
Rust
1911 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"
|
|
);
|
|
}
|
|
}
|