Publish harness and TUI open-source

initial sync from the monorepo
This commit is contained in:
grokkybara[bot] 2026-07-16 06:46:02 +01:00
commit c68e39f604
2734 changed files with 1437016 additions and 0 deletions

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,214 @@
//! CLI for fast git worktree creation.
//!
//! Usage:
//! fast-worktree create <source> <dest> [options]
//!
//! Example:
//! fast-worktree create /path/to/repo /path/to/worktree --dirty --parallelism 8
use std::path::PathBuf;
use std::time::Instant;
use anyhow::Result;
use clap::{Parser, Subcommand, ValueEnum};
use tracing::{Level, info};
use xai_fast_worktree::{BtrfsMode, IgnoredFilesMode, WorkingTreeMode, WorktreeBuilder};
/// CLI enum for BTRFS mode selection
#[derive(Clone, Debug, Default, ValueEnum)]
enum CliBtrfsMode {
/// Auto-detect: use BTRFS snapshot if source is on a BTRFS subvolume
#[default]
Auto,
/// Force BTRFS snapshot (error if not available)
Force,
/// Disable BTRFS snapshot, always use file-by-file copy
Disabled,
}
impl From<CliBtrfsMode> for BtrfsMode {
fn from(mode: CliBtrfsMode) -> Self {
match mode {
CliBtrfsMode::Auto => BtrfsMode::Auto,
CliBtrfsMode::Force => BtrfsMode::Force,
CliBtrfsMode::Disabled => BtrfsMode::Disabled,
}
}
}
#[derive(Parser)]
#[command(name = "fast-worktree")]
#[command(about = "High-performance git worktree creation using CoW cloning")]
#[command(version)]
struct Cli {
#[command(subcommand)]
command: Commands,
/// Enable verbose logging
#[arg(short, long, global = true)]
verbose: bool,
}
#[derive(Subcommand)]
enum Commands {
/// Create a new worktree from source
Create {
/// Source repository or worktree path
source: PathBuf,
/// Destination path for the new worktree
dest: PathBuf,
/// Git ref to checkout (default: HEAD)
#[arg(long, default_value = "HEAD")]
git_ref: String,
/// Copy dirty/modified files from source
#[arg(long, short = 'd')]
dirty: bool,
/// Copy ignored files (node_modules, target, etc.)
#[arg(long, short = 'i')]
ignored: bool,
/// Number of parallel workers (0 = auto)
#[arg(long, short = 'j', default_value = "0")]
parallelism: usize,
/// Parallelism for ignored files copy
#[arg(long, default_value = "0")]
ignored_parallelism: usize,
/// Patterns to skip when copying ignored files
#[arg(long)]
skip: Vec<String>,
/// BTRFS snapshot mode (Linux only): auto, force, or disabled
#[arg(long, value_enum, default_value = "auto")]
btrfs: CliBtrfsMode,
/// Create a standalone repo copy instead of a linked worktree.
/// The copy has its own .git/ (CoW'd) and can be promoted via rename.
#[arg(long, short = 's')]
standalone: bool,
},
}
fn main() -> Result<()> {
let cli = Cli::parse();
// Initialize tracing
let level = if cli.verbose {
Level::DEBUG
} else {
Level::INFO
};
tracing_subscriber::fmt()
.with_max_level(level)
.with_target(false)
.init();
match cli.command {
Commands::Create {
source,
dest,
git_ref,
dirty,
ignored,
parallelism,
ignored_parallelism,
skip,
btrfs,
standalone,
} => {
let start = Instant::now();
info!(
source = %source.display(),
dest = %dest.display(),
git_ref = %git_ref,
dirty = dirty,
ignored = ignored,
parallelism = parallelism,
btrfs = ?btrfs,
standalone = standalone,
"Creating worktree"
);
let working_tree = if dirty {
WorkingTreeMode::PreserveWorkingTree
} else {
WorkingTreeMode::CleanAll
};
let ignored_files = if ignored {
IgnoredFilesMode::Copy {
skip_patterns: skip,
}
} else {
IgnoredFilesMode::Skip
};
let result = WorktreeBuilder::new(source, dest)
.git_ref(git_ref)
.parallelism(parallelism)
.ignored_parallelism(ignored_parallelism)
.channel_buffer(1024)
.working_tree_mode(working_tree)
.ignored_files_mode(ignored_files)
.btrfs_mode(btrfs.into())
.standalone(standalone)
.create()?;
let elapsed = start.elapsed();
println!("\n✓ Worktree created successfully!");
println!(" Path: {}", result.worktree_path.display());
println!(" Commit: {}", &result.commit[..12]);
// For snapshot methods (btrfs/overlay), files_copied will be 0
if result.unignored_copy.files_copied > 0 {
println!(
" Files: {} copied, {} dirs",
result.unignored_copy.files_copied, result.unignored_copy.dirs_created
);
} else {
println!(" Method: snapshot (instant, BTRFS or overlay)");
}
if standalone {
println!(" Mode: standalone (independent .git/, promotable via rename)");
} else {
println!(" Mode: linked worktree");
}
if let Some(ref ignored_stats) = result.ignored_copy {
println!(" Ignored: {} copied", ignored_stats.files_copied);
}
let mut printed_warnings = false;
if !result.unignored_copy.issues.is_empty() {
println!("\n⚠ Warnings:");
printed_warnings = true;
for error in &result.unignored_copy.issues {
println!(" - {}", error);
}
}
if let Some(ref ignored_stats) = result.ignored_copy
&& !ignored_stats.issues.is_empty()
{
if !printed_warnings {
println!("\n⚠ Warnings:");
}
for error in &ignored_stats.issues {
println!(" - {}", error);
}
}
println!("\n Time: {:.2?}", elapsed);
}
}
Ok(())
}

View file

@ -0,0 +1,808 @@
//! Pool performance benchmark — emulates the A/B worktree pool lifecycle.
//!
//! Exercises the exact same primitives the production pool uses:
//! 1. Create worktree (GitCheckout mode, like the pool fill task)
//! 2. Warm git caches (git status, like the pool does before marking ready)
//! 3. Sync (git reset --hard + git clean + dirty state copy, like acquire())
//! 4. Simulate use (git status in the synced worktree)
//! 5. Release (git reset --hard + git clean, like release())
//! 6. Cleanup (git worktree remove, like shutdown/schedule_cleanup)
//!
//! Runs against a REAL repo (defaults to the current directory).
//! Designed to be run from a large repo root to get realistic timings.
//!
//! Usage:
//! cargo run --release --bin pool-perf-bench -- [--source /path/to/repo] [--iterations 3]
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Instant;
use anyhow::{Context, Result};
use clap::Parser;
use xai_fast_worktree::{CreationMode, WorktreeBuilder, WorktreeSync, remove_worktree};
// ============================================================================
// CLI
// ============================================================================
#[derive(Parser)]
#[command(name = "pool-perf-bench")]
#[command(about = "Benchmark worktree pool lifecycle (create/warm/sync/release/cleanup)")]
struct Cli {
/// Source repository path (default: current directory)
#[arg(long, default_value = ".")]
source: PathBuf,
/// Number of full lifecycle iterations
#[arg(long, default_value = "3")]
iterations: usize,
/// Number of parallel checkout workers (0 = auto)
#[arg(long, default_value = "0")]
parallelism: usize,
/// Whether to copy dirty state during sync
#[arg(long)]
copy_dirty: bool,
/// Enable verbose tracing output
#[arg(short, long)]
verbose: bool,
/// Run in A/B mode: create 2 worktrees concurrently, sync both, release both
#[arg(long)]
ab: bool,
/// Output results as JSON (for programmatic consumption)
#[arg(long)]
json: bool,
}
// ============================================================================
// Timing structs
// ============================================================================
#[derive(Debug, Clone)]
struct PhaseTiming {
name: String,
duration_ms: f64,
detail: String,
}
#[derive(Debug, Clone)]
struct IterationResult {
iteration: usize,
phases: Vec<PhaseTiming>,
total_ms: f64,
}
#[derive(Debug, Clone)]
struct BenchmarkResult {
source: String,
tracked_files: usize,
iterations: Vec<IterationResult>,
ab_mode: bool,
summary: BenchmarkSummary,
}
#[derive(Debug, Clone)]
struct BenchmarkSummary {
/// Per-phase averages across all iterations
phase_averages: Vec<(String, f64)>,
/// Total average
total_avg_ms: f64,
/// Slowest phase name and average
bottleneck: (String, f64),
}
// ============================================================================
// Phase runners
// ============================================================================
/// Phase 1: Create a linked worktree via GitCheckout mode (what the pool fill task does)
fn phase_create(source: &Path, dest: &Path, parallelism: usize) -> Result<PhaseTiming> {
let start = Instant::now();
WorktreeBuilder::new(source, dest)
.creation_mode(CreationMode::GitCheckout)
.parallelism(parallelism)
.create()
.context("WorktreeBuilder::create failed")?;
let ms = start.elapsed().as_secs_f64() * 1000.0;
Ok(PhaseTiming {
name: "create (GitCheckout)".into(),
duration_ms: ms,
detail: format!("git worktree add --detach with checkout.workers={parallelism}"),
})
}
/// Phase 2: Warm git caches (git status --porcelain) — populates fsmonitor, untracked cache
fn phase_warm_caches(worktree: &Path) -> Result<PhaseTiming> {
let start = Instant::now();
let output = Command::new("git")
.args(["status", "--porcelain"])
.current_dir(worktree)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.output()
.context("git status for cache warming")?;
let ms = start.elapsed().as_secs_f64() * 1000.0;
let detail = if output.status.success() {
"git status --porcelain (populates fsmonitor + untracked cache)".into()
} else {
format!(
"git status FAILED: {}",
String::from_utf8_lossy(&output.stderr).trim()
)
};
Ok(PhaseTiming {
name: "warm caches".into(),
duration_ms: ms,
detail,
})
}
/// Phase 2b: Second git status — measures the warm-cache speed (should be much faster)
fn phase_warm_caches_2nd(worktree: &Path) -> Result<PhaseTiming> {
let start = Instant::now();
Command::new("git")
.args(["status", "--porcelain"])
.current_dir(worktree)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.output()
.context("git status (2nd run)")?;
let ms = start.elapsed().as_secs_f64() * 1000.0;
Ok(PhaseTiming {
name: "warm caches (2nd, hot)".into(),
duration_ms: ms,
detail: "git status --porcelain (should be fast with warm caches)".into(),
})
}
/// Phase 3: Sync — reset to source HEAD + clean + copy dirty state
fn phase_sync(
source: &Path,
worktree: &Path,
copy_dirty: bool,
skip_clean: bool,
) -> Result<PhaseTiming> {
let start = Instant::now();
let sync = WorktreeSync::new(source, worktree);
let report = sync
.sync_worktree_opts(copy_dirty, skip_clean)
.context("sync_worktree failed")?;
let ms = start.elapsed().as_secs_f64() * 1000.0;
Ok(PhaseTiming {
name: if skip_clean {
"sync (skip_clean)".into()
} else {
"sync".into()
},
duration_ms: ms,
detail: format!(
"head_moved={} dirty_copied={} deleted={} staged={} clean_skipped={}",
report.head_moved,
report.dirty_files_copied,
report.files_deleted,
report.staged_entries,
report.clean_skipped
),
})
}
/// Phase 3b: Measure git status speed AFTER sync (validates stat caches are intact)
fn phase_post_sync_status(worktree: &Path) -> Result<PhaseTiming> {
let start = Instant::now();
let output = Command::new("git")
.args(["status", "--porcelain"])
.current_dir(worktree)
.output()
.context("post-sync git status")?;
let ms = start.elapsed().as_secs_f64() * 1000.0;
let line_count = output
.stdout
.split(|&b| b == b'\n')
.filter(|l| !l.is_empty())
.count();
Ok(PhaseTiming {
name: "post-sync git status".into(),
duration_ms: ms,
detail: format!("{line_count} dirty entries reported"),
})
}
/// Phase 4: Simulate use — run git diff --stat (what an agent would do)
fn phase_simulate_use(worktree: &Path) -> Result<PhaseTiming> {
let start = Instant::now();
Command::new("git")
.args(["diff", "--stat"])
.current_dir(worktree)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.output()
.context("git diff --stat")?;
let ms = start.elapsed().as_secs_f64() * 1000.0;
Ok(PhaseTiming {
name: "simulate use (git diff --stat)".into(),
duration_ms: ms,
detail: "simulates agent reading diff".into(),
})
}
/// Phase 5: Release — git reset --hard + git clean -fdx (what pool.release() does)
fn phase_release(worktree: &Path) -> Result<PhaseTiming> {
let start = Instant::now();
let r1 = Command::new("git")
.args(["reset", "--hard", "HEAD"])
.current_dir(worktree)
.output()
.context("git reset --hard")?;
let r2 = Command::new("git")
.args(["clean", "-fdx"])
.current_dir(worktree)
.output()
.context("git clean -fdx")?;
let ms = start.elapsed().as_secs_f64() * 1000.0;
Ok(PhaseTiming {
name: "release (reset+clean)".into(),
duration_ms: ms,
detail: format!(
"reset_ok={} clean_ok={}",
r1.status.success(),
r2.status.success()
),
})
}
/// Phase 5b: Re-warm caches after release (what the pool does before marking .ready)
fn phase_post_release_warm(worktree: &Path) -> Result<PhaseTiming> {
let start = Instant::now();
Command::new("git")
.args(["status", "--porcelain"])
.current_dir(worktree)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.output()
.context("post-release git status")?;
let ms = start.elapsed().as_secs_f64() * 1000.0;
Ok(PhaseTiming {
name: "post-release warm".into(),
duration_ms: ms,
detail: "git status after release to re-warm caches".into(),
})
}
/// Phase 6: Cleanup — rm -rf + deregister (fast) instead of git worktree remove (slow)
fn phase_cleanup(_source: &Path, worktree: &Path) -> Result<PhaseTiming> {
let start = Instant::now();
let report = remove_worktree(worktree).context("remove_worktree failed")?;
let ms = start.elapsed().as_secs_f64() * 1000.0;
Ok(PhaseTiming {
name: "cleanup (rm -rf + deregister)".into(),
duration_ms: ms,
detail: format!("btrfs={}", report.used_btrfs_delete),
})
}
// ============================================================================
// A/B mode: two worktrees concurrently
// ============================================================================
fn run_ab_iteration(
source: &Path,
base_dir: &Path,
iteration: usize,
parallelism: usize,
copy_dirty: bool,
) -> Result<IterationResult> {
let mut phases = Vec::new();
let iter_start = Instant::now();
let wt_a = base_dir.join(format!("bench_wt_a_{iteration}"));
let wt_b = base_dir.join(format!("bench_wt_b_{iteration}"));
// Phase 1: Create both worktrees (sequentially, like the fill task does)
eprintln!(" [A/B] Creating worktree A...");
{
let mut p = phase_create(source, &wt_a, parallelism)?;
p.name = "create A (GitCheckout)".into();
phases.push(p);
}
eprintln!(" [A/B] Creating worktree B...");
{
let mut p = phase_create(source, &wt_b, parallelism)?;
p.name = "create B (GitCheckout)".into();
phases.push(p);
}
// Phase 2: Warm caches on both
eprintln!(" [A/B] Warming caches A...");
{
let mut p = phase_warm_caches(&wt_a)?;
p.name = "warm caches A".into();
phases.push(p);
}
eprintln!(" [A/B] Warming caches B...");
{
let mut p = phase_warm_caches(&wt_b)?;
p.name = "warm caches B".into();
phases.push(p);
}
// Phase 2b: Second warm (hot cache measurement)
{
let mut p = phase_warm_caches_2nd(&wt_a)?;
p.name = "warm caches A (2nd, hot)".into();
phases.push(p);
}
{
let mut p = phase_warm_caches_2nd(&wt_b)?;
p.name = "warm caches B (2nd, hot)".into();
phases.push(p);
}
// Phase 3: Sync both (this is what acquire() does after claim)
eprintln!(" [A/B] Syncing A...");
{
let mut p = phase_sync(source, &wt_a, copy_dirty, /* skip_clean */ true)?;
p.name = "sync A (skip_clean)".into();
phases.push(p);
}
eprintln!(" [A/B] Syncing B...");
{
let mut p = phase_sync(source, &wt_b, copy_dirty, /* skip_clean */ true)?;
p.name = "sync B (skip_clean)".into();
phases.push(p);
}
// Phase 3b: Post-sync git status (validates stat caches)
{
let mut p = phase_post_sync_status(&wt_a)?;
p.name = "post-sync status A".into();
phases.push(p);
}
{
let mut p = phase_post_sync_status(&wt_b)?;
p.name = "post-sync status B".into();
phases.push(p);
}
// Phase 4: Simulate use
{
let mut p = phase_simulate_use(&wt_a)?;
p.name = "use A (git diff)".into();
phases.push(p);
}
{
let mut p = phase_simulate_use(&wt_b)?;
p.name = "use B (git diff)".into();
phases.push(p);
}
// Phase 5: Release both
eprintln!(" [A/B] Releasing A...");
{
let mut p = phase_release(&wt_a)?;
p.name = "release A".into();
phases.push(p);
}
eprintln!(" [A/B] Releasing B...");
{
let mut p = phase_release(&wt_b)?;
p.name = "release B".into();
phases.push(p);
}
// Phase 5b: Post-release warm
{
let mut p = phase_post_release_warm(&wt_a)?;
p.name = "post-release warm A".into();
phases.push(p);
}
{
let mut p = phase_post_release_warm(&wt_b)?;
p.name = "post-release warm B".into();
phases.push(p);
}
// Phase 6: Cleanup both
eprintln!(" [A/B] Cleaning up A...");
{
let mut p = phase_cleanup(source, &wt_a)?;
p.name = "cleanup A".into();
phases.push(p);
}
eprintln!(" [A/B] Cleaning up B...");
{
let mut p = phase_cleanup(source, &wt_b)?;
p.name = "cleanup B".into();
phases.push(p);
}
let total_ms = iter_start.elapsed().as_secs_f64() * 1000.0;
Ok(IterationResult {
iteration,
phases,
total_ms,
})
}
fn run_single_iteration(
source: &Path,
base_dir: &Path,
iteration: usize,
parallelism: usize,
copy_dirty: bool,
) -> Result<IterationResult> {
let mut phases = Vec::new();
let iter_start = Instant::now();
let wt = base_dir.join(format!("bench_wt_{iteration}"));
eprintln!(" Creating worktree...");
phases.push(phase_create(source, &wt, parallelism)?);
eprintln!(" Warming caches (1st)...");
phases.push(phase_warm_caches(&wt)?);
eprintln!(" Warming caches (2nd, hot)...");
phases.push(phase_warm_caches_2nd(&wt)?);
eprintln!(" Syncing (skip_clean=true, pool mode)...");
phases.push(phase_sync(
source, &wt, copy_dirty, /* skip_clean */ true,
)?);
eprintln!(" Post-sync git status...");
phases.push(phase_post_sync_status(&wt)?);
eprintln!(" Simulating use...");
phases.push(phase_simulate_use(&wt)?);
eprintln!(" Releasing...");
phases.push(phase_release(&wt)?);
eprintln!(" Post-release warm...");
phases.push(phase_post_release_warm(&wt)?);
eprintln!(" Cleaning up...");
phases.push(phase_cleanup(source, &wt)?);
let total_ms = iter_start.elapsed().as_secs_f64() * 1000.0;
Ok(IterationResult {
iteration,
phases,
total_ms,
})
}
// ============================================================================
// Helpers
// ============================================================================
fn count_tracked_files(source: &Path) -> Result<usize> {
xai_fast_worktree::count_tracked_files(source)
}
fn compute_summary(iterations: &[IterationResult]) -> BenchmarkSummary {
if iterations.is_empty() {
return BenchmarkSummary {
phase_averages: vec![],
total_avg_ms: 0.0,
bottleneck: ("(none)".into(), 0.0),
};
}
// Collect all unique phase names in order from the first iteration
let phase_names: Vec<String> = iterations[0]
.phases
.iter()
.map(|p| p.name.clone())
.collect();
let mut phase_averages = Vec::new();
let mut max_phase = ("(none)".to_string(), 0.0f64);
for name in &phase_names {
let mut sum = 0.0;
let mut count = 0;
for iter in iterations {
for phase in &iter.phases {
if &phase.name == name {
sum += phase.duration_ms;
count += 1;
}
}
}
let avg = if count > 0 { sum / count as f64 } else { 0.0 };
if avg > max_phase.1 {
max_phase = (name.clone(), avg);
}
phase_averages.push((name.clone(), avg));
}
let total_avg = iterations.iter().map(|i| i.total_ms).sum::<f64>() / iterations.len() as f64;
BenchmarkSummary {
phase_averages,
total_avg_ms: total_avg,
bottleneck: max_phase,
}
}
// ============================================================================
// Output
// ============================================================================
fn print_iteration(result: &IterationResult) {
println!();
println!(
" ┌─ Iteration {} ─────────────────────────────────────────────────",
result.iteration + 1
);
for phase in &result.phases {
let bar_len = (phase.duration_ms / 100.0).min(50.0) as usize;
let bar: String = "".repeat(bar_len);
println!(
" │ {:>8.1}ms {:<35} {}",
phase.duration_ms, phase.name, bar
);
if !phase.detail.is_empty() {
println!(" │ └─ {}", phase.detail);
}
}
println!(
" └─ Total: {:.1}ms ──────────────────────────────────────────────",
result.total_ms
);
}
fn print_summary(result: &BenchmarkResult) {
println!();
println!("╔══════════════════════════════════════════════════════════════════╗");
println!("║ BENCHMARK SUMMARY ║");
println!("╠══════════════════════════════════════════════════════════════════╣");
println!(
"║ Source: {:<55} ║",
&result.source[..result.source.len().min(55)]
);
println!("║ Tracked files: {:<48}", result.tracked_files);
println!(
"║ Mode: {:<57} ║",
if result.ab_mode {
"A/B (2 worktrees)"
} else {
"Single worktree"
}
);
println!("║ Iterations: {:<51}", result.iterations.len());
println!("╠══════════════════════════════════════════════════════════════════╣");
println!("║ Phase Averages: ║");
for (name, avg) in &result.summary.phase_averages {
let pct = if result.summary.total_avg_ms > 0.0 {
avg / result.summary.total_avg_ms * 100.0
} else {
0.0
};
let marker = if name == &result.summary.bottleneck.0 {
" ◄ BOTTLENECK"
} else {
""
};
println!("{:>8.1}ms ({:>4.1}%) {:<35}{}", avg, pct, name, marker);
}
println!("╠══════════════════════════════════════════════════════════════════╣");
println!(
"║ Total average: {:.1}ms{:>47} ║",
result.summary.total_avg_ms, ""
);
println!(
"║ Bottleneck: {} ({:.1}ms){} ║",
result.summary.bottleneck.0,
result.summary.bottleneck.1,
" ".repeat(
63usize
.saturating_sub(result.summary.bottleneck.0.len())
.saturating_sub(format!("{:.1}", result.summary.bottleneck.1).len())
.saturating_sub(15)
)
);
println!("╚══════════════════════════════════════════════════════════════════╝");
}
fn print_json(result: &BenchmarkResult) {
println!("{{");
println!(" \"source\": {:?},", result.source);
println!(" \"tracked_files\": {},", result.tracked_files);
println!(" \"ab_mode\": {},", result.ab_mode);
println!(" \"iterations\": [");
for (i, iter) in result.iterations.iter().enumerate() {
println!(" {{");
println!(" \"iteration\": {},", iter.iteration);
println!(" \"total_ms\": {:.2},", iter.total_ms);
println!(" \"phases\": [");
for (j, phase) in iter.phases.iter().enumerate() {
let comma = if j + 1 < iter.phases.len() { "," } else { "" };
println!(
" {{ \"name\": {:?}, \"duration_ms\": {:.2}, \"detail\": {:?} }}{}",
phase.name, phase.duration_ms, phase.detail, comma
);
}
println!(" ]");
let comma = if i + 1 < result.iterations.len() {
","
} else {
""
};
println!(" }}{comma}");
}
println!(" ],");
println!(" \"summary\": {{");
println!(" \"total_avg_ms\": {:.2},", result.summary.total_avg_ms);
println!(
" \"bottleneck\": {{ \"name\": {:?}, \"avg_ms\": {:.2} }},",
result.summary.bottleneck.0, result.summary.bottleneck.1
);
println!(" \"phase_averages\": [");
for (i, (name, avg)) in result.summary.phase_averages.iter().enumerate() {
let comma = if i + 1 < result.summary.phase_averages.len() {
","
} else {
""
};
println!(
" {{ \"name\": {:?}, \"avg_ms\": {:.2} }}{}",
name, avg, comma
);
}
println!(" ]");
println!(" }}");
println!("}}");
}
// ============================================================================
// Main
// ============================================================================
fn main() -> Result<()> {
let cli = Cli::parse();
// Init tracing
if cli.verbose {
tracing_subscriber::fmt()
.with_max_level(tracing::Level::DEBUG)
.with_target(false)
.init();
} else {
tracing_subscriber::fmt()
.with_max_level(tracing::Level::WARN)
.with_target(false)
.init();
}
let source = dunce::canonicalize(&cli.source).context("source path not found")?;
let tracked = count_tracked_files(&source).unwrap_or(0);
if !cli.json {
eprintln!("╔══════════════════════════════════════════════════════════════════╗");
eprintln!("║ Pool Performance Benchmark ║");
eprintln!("╠══════════════════════════════════════════════════════════════════╣");
eprintln!("║ Source: {}", source.display());
eprintln!("║ Tracked files: {tracked}");
eprintln!(
"║ Mode: {}",
if cli.ab {
"A/B (2 worktrees)"
} else {
"Single worktree"
}
);
eprintln!("║ Iterations: {}", cli.iterations);
eprintln!(
"║ Parallelism: {}",
if cli.parallelism == 0 {
"auto".to_string()
} else {
cli.parallelism.to_string()
}
);
eprintln!("║ Copy dirty: {}", cli.copy_dirty);
eprintln!("╚══════════════════════════════════════════════════════════════════╝");
eprintln!();
}
// Create a temporary base directory for worktrees
let bench_dir = tempfile::Builder::new()
.prefix("pool-perf-bench-")
.tempdir()
.context("failed to create temp dir")?;
if !cli.json {
eprintln!("Bench dir: {}", bench_dir.path().display());
}
// Enable git perf features on source (like the pool does)
if !cli.json {
eprintln!("Enabling git perf features on source...");
}
for (key, val) in [("core.fsmonitor", "true"), ("core.untrackedCache", "true")] {
Command::new("git")
.args(["config", key, val])
.current_dir(&source)
.output()
.ok();
}
let mut iterations = Vec::new();
for i in 0..cli.iterations {
if !cli.json {
eprintln!("\n━━━ Iteration {}/{} ━━━", i + 1, cli.iterations);
}
let result = if cli.ab {
run_ab_iteration(
&source,
bench_dir.path(),
i,
cli.parallelism,
cli.copy_dirty,
)?
} else {
run_single_iteration(
&source,
bench_dir.path(),
i,
cli.parallelism,
cli.copy_dirty,
)?
};
if !cli.json {
print_iteration(&result);
}
iterations.push(result);
}
let summary = compute_summary(&iterations);
let bench_result = BenchmarkResult {
source: source.to_string_lossy().to_string(),
tracked_files: tracked,
iterations,
ab_mode: cli.ab,
summary,
};
if cli.json {
print_json(&bench_result);
} else {
print_summary(&bench_result);
}
Ok(())
}

View file

@ -0,0 +1,728 @@
//! BTRFS filesystem and subvolume detection.
//!
//! This module handles detection of BTRFS filesystems and subvolumes, including
//! the case where a BTRFS subvolume is bind-mounted to another location (e.g.
//! when the working-tree path is not itself on BTRFS).
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use anyhow::{Context, Result};
use nix::sys::statfs::{BTRFS_SUPER_MAGIC, statfs};
/// Information about a BTRFS subvolume.
#[derive(Debug, Clone)]
pub struct BtrfsInfo {
/// Root path of the subvolume as seen by the user.
/// This may be a bind mount target (e.g., `/workspace/repo`).
pub subvolume_root: PathBuf,
/// If the subvolume is accessed via a bind mount, this contains the actual
/// source path on the btrfs filesystem (e.g., `/mnt/btrfs/repo`).
/// None if the path is directly on btrfs without a bind mount.
pub bind_mount_source: Option<PathBuf>,
/// The btrfs mount point where snapshots can be created.
/// For bind mounts, this is the parent of bind_mount_source.
/// For direct btrfs paths, this is determined from the mount table.
pub btrfs_mount_point: Option<PathBuf>,
}
/// Information about a bind mount.
#[derive(Debug, Clone)]
pub struct BindMountInfo {
/// The target path (where the bind mount is visible)
#[allow(dead_code)]
pub target: PathBuf,
/// The source path (actual location of the data)
pub source: PathBuf,
/// The filesystem type of the source
pub fs_type: String,
}
/// Check if a path is on a BTRFS filesystem.
pub fn is_btrfs(path: &Path) -> Result<bool> {
let stat = statfs(path).with_context(|| format!("statfs failed for {}", path.display()))?;
Ok(stat.filesystem_type() == BTRFS_SUPER_MAGIC)
}
/// Get bind mount information for a path.
///
/// Uses `findmnt` to check if a path is a bind mount and retrieve its source.
/// Returns `Ok(Some(BindMountInfo))` if the path is a bind mount.
/// Returns `Ok(None)` if not a bind mount or if detection fails.
pub fn get_bind_mount_info(path: &Path) -> Result<Option<BindMountInfo>> {
// Use findmnt to get mount information
// -n: no headers, -o: output fields, -T: target path
let mut cmd = Command::new("findmnt");
xai_tty_utils::detach_std_command(&mut cmd);
cmd.stdin(Stdio::null());
let output = cmd
.args(["-n", "-o", "SOURCE,TARGET,FSTYPE,OPTIONS", "-T"])
// OsStr arg: a non-UTF-8 path must not silently collapse to ".".
.arg(path)
.output();
let output = match output {
Ok(output) if output.status.success() => output,
Ok(_) => {
tracing::debug!(path = %path.display(), "findmnt failed or path not mounted");
return Ok(None);
}
Err(e) => {
tracing::debug!(path = %path.display(), error = %e, "failed to run findmnt");
return Ok(None);
}
};
let stdout = String::from_utf8_lossy(&output.stdout);
let line = stdout.trim();
if line.is_empty() {
return Ok(None);
}
// Parse findmnt output: SOURCE TARGET FSTYPE OPTIONS
// Fields are separated by whitespace
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() < 3 {
tracing::debug!(path = %path.display(), line = %line, "unexpected findmnt output format");
return Ok(None);
}
let source = parts[0];
let target = parts[1];
let fs_type = parts[2];
let options = parts.get(3).unwrap_or(&"");
// Check if this is a bind mount by looking for "bind" in options
// or by checking if source contains a subpath (e.g., /dev/loop0[/repo])
let is_bind = options.contains("bind")
|| source.contains('[')
|| (source.starts_with('/') && !source.starts_with("/dev/"));
if !is_bind {
tracing::debug!(
path = %path.display(),
source = %source,
"not a bind mount"
);
return Ok(None);
}
// For bind mounts, the source might be in format like "/dev/loop0[/repo]"
// We need to resolve the actual path
let actual_source = resolve_bind_mount_source(path)?;
if let Some(actual_source) = actual_source {
tracing::debug!(
path = %path.display(),
source = %actual_source.display(),
"detected bind mount"
);
Ok(Some(BindMountInfo {
target: PathBuf::from(target),
source: actual_source,
fs_type: fs_type.to_string(),
}))
} else {
Ok(None)
}
}
/// Resolve the actual source path for a bind mount by parsing /proc/self/mountinfo.
///
/// This is more reliable than parsing findmnt output for getting the actual path.
fn resolve_bind_mount_source(target: &Path) -> Result<Option<PathBuf>> {
let mountinfo = std::fs::read_to_string("/proc/self/mountinfo")
.context("failed to read /proc/self/mountinfo")?;
let target_str = target.to_string_lossy();
// Find the mount entry for our target
// mountinfo format: ID PARENT_ID MAJOR:MINOR ROOT MOUNTPOINT OPTIONS - FSTYPE SOURCE SUPER_OPTIONS
for line in mountinfo.lines() {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() < 10 {
continue;
}
let mount_point = parts[4];
if mount_point != target_str {
continue;
}
// Found our mount point
let root = parts[3]; // The root within the filesystem
let fstype_idx = parts.iter().position(|&p| p == "-").map(|i| i + 1);
if let Some(fstype_idx) = fstype_idx {
let fstype = parts.get(fstype_idx).unwrap_or(&"");
let source = parts.get(fstype_idx + 1).unwrap_or(&"");
// For btrfs bind mounts, we need to find where the btrfs is mounted
// and construct the full path
if *fstype == "btrfs" {
// Try 1: Find a btrfs root mount (root="/") for this device.
// Common when the btrfs volume root is mounted separately
// (e.g., `/mnt/btrfs/`).
if let Some(btrfs_mount) = find_btrfs_mount_for_source(source, &mountinfo)? {
// Construct the full path: btrfs_mount + root
let full_source = if root == "/" {
btrfs_mount
} else {
btrfs_mount.join(root.trim_start_matches('/'))
};
return Ok(Some(full_source));
}
// Try 2: Resolve using a subvolume mount (no root mount exists).
// This handles the case where only a btrfs subvolume is mounted
// (e.g., `mount -o subvol=/repo /dev/loop0 /workspace/repo`)
// without a separate mount for the btrfs volume root.
match resolve_via_subvol_mount(source, root, &mountinfo) {
Ok(Some(full_source)) => return Ok(Some(full_source)),
Ok(None) => {
tracing::debug!(
device = %source,
root = %root,
target = %target_str,
"subvol mount fallback found no matching mount for btrfs device"
);
}
Err(e) => {
tracing::debug!(
device = %source,
root = %root,
target = %target_str,
error = %e,
"subvol mount fallback failed"
);
}
}
}
}
break;
}
Ok(None)
}
/// Find the mount point for a btrfs device/source.
fn find_btrfs_mount_for_source(source: &str, mountinfo: &str) -> Result<Option<PathBuf>> {
// Look for a mount of this source that has root="/"
for line in mountinfo.lines() {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() < 10 {
continue;
}
let root = parts[3];
let mount_point = parts[4];
let fstype_idx = parts.iter().position(|&p| p == "-").map(|i| i + 1);
if let Some(fstype_idx) = fstype_idx {
let fstype = parts.get(fstype_idx).unwrap_or(&"");
let mount_source = parts.get(fstype_idx + 1).unwrap_or(&"");
if *fstype == "btrfs" && *mount_source == source && root == "/" {
return Ok(Some(PathBuf::from(mount_point)));
}
}
}
Ok(None)
}
/// Resolve a btrfs path using an existing subvolume mount when no root mount exists.
///
/// On some hosts the btrfs volume root is not mounted separately — only a
/// specific subvolume is mounted (e.g.,
/// `mount -o subvol=/repo /dev/loop0 /workspace/repo`). In that case,
/// `find_btrfs_mount_for_source` returns `None` because there's no `root="/"` mount.
///
/// This function finds any mount of the same btrfs device and computes the filesystem
/// path by adjusting for the mount's root offset.
///
/// For example, with mount entry `root=/repo mount_point=/workspace/repo` and target
/// root `/repo/.grok-snapshots/wt-123`, this returns
/// `/workspace/repo/.grok-snapshots/wt-123`.
fn resolve_via_subvol_mount(
device: &str,
target_root: &str,
mountinfo: &str,
) -> Result<Option<PathBuf>> {
for line in mountinfo.lines() {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() < 10 {
continue;
}
let mount_root = parts[3];
let mount_point = parts[4];
let fstype_idx = parts.iter().position(|&p| p == "-").map(|i| i + 1);
if let Some(fstype_idx) = fstype_idx {
let fstype = parts.get(fstype_idx).unwrap_or(&"");
let mount_source = parts.get(fstype_idx + 1).unwrap_or(&"");
if *fstype == "btrfs" && *mount_source == device {
// Found a mount of this btrfs device.
// Check if target_root starts with (or equals) this mount's root.
if let Some(relative) = target_root.strip_prefix(mount_root) {
// Ensure we matched at a path boundary, not a partial name
// (e.g., mount_root="/repo" matches "/repo/foo" but not "/repo-other")
if relative.is_empty() || relative.starts_with('/') {
let relative = relative.trim_start_matches('/');
let full_source = if relative.is_empty() {
PathBuf::from(mount_point)
} else {
PathBuf::from(mount_point).join(relative)
};
return Ok(Some(full_source));
}
}
}
}
}
Ok(None)
}
/// Get the btrfs mount point that contains a given path.
///
/// This walks up the path hierarchy to find the nearest btrfs mount point.
pub fn get_btrfs_mount_point(path: &Path) -> Result<Option<PathBuf>> {
let mountinfo = std::fs::read_to_string("/proc/self/mountinfo")
.context("failed to read /proc/self/mountinfo")?;
let canonical = dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
// Find the longest matching mount point that is btrfs
let mut best_match: Option<PathBuf> = None;
let mut best_len = 0;
for line in mountinfo.lines() {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() < 10 {
continue;
}
let mount_point = parts[4];
let fstype_idx = parts.iter().position(|&p| p == "-").map(|i| i + 1);
if let Some(fstype_idx) = fstype_idx {
let fstype = parts.get(fstype_idx).unwrap_or(&"");
if *fstype == "btrfs" && canonical.starts_with(mount_point) {
let len = mount_point.len();
if len > best_len {
best_len = len;
best_match = Some(PathBuf::from(mount_point));
}
}
}
}
Ok(best_match)
}
/// Check if a path is a BTRFS subvolume and return info.
///
/// Returns `Ok(Some(BtrfsInfo))` if the path is a BTRFS subvolume root.
/// Returns `Ok(None)` if not on BTRFS or not a subvolume.
///
/// This function also detects bind-mounted BTRFS subvolumes. For example, if
/// `/workspace/repo` is bind-mounted from `/mnt/btrfs/repo`, this function will
/// detect it as a BTRFS subvolume and populate the `bind_mount_source` field.
///
/// Note: This checks if the path itself is a subvolume root, not if it's
/// contained within a subvolume.
pub fn is_btrfs_subvolume(path: &Path) -> Result<Option<BtrfsInfo>> {
let on_btrfs = is_btrfs(path)?;
if !on_btrfs {
// Not on BTRFS at all (statfs says different fs type).
// Check if it's a bind mount from a BTRFS subvolume anyway
// (this handles the rare case where statfs doesn't report btrfs).
tracing::debug!(
path = %path.display(),
"path not on BTRFS, checking for bind mount from BTRFS"
);
if let Some(bind_info) = get_bind_mount_info(path)?
&& bind_info.fs_type == "btrfs"
&& check_is_subvolume_cmd(&bind_info.source)
{
let btrfs_mount = get_btrfs_mount_point(&bind_info.source).ok().flatten();
tracing::info!(
path = %path.display(),
source = %bind_info.source.display(),
btrfs_mount = ?btrfs_mount,
"path is a bind-mounted BTRFS subvolume"
);
return Ok(Some(BtrfsInfo {
subvolume_root: path.to_path_buf(),
bind_mount_source: Some(bind_info.source),
btrfs_mount_point: btrfs_mount,
}));
}
tracing::debug!(path = %path.display(), "not a BTRFS subvolume");
return Ok(None);
}
// Path is on BTRFS (statfs reports btrfs). Check if it's a subvolume.
if !check_is_subvolume_cmd(path) {
tracing::debug!(
path = %path.display(),
"path is on BTRFS but not a subvolume root"
);
return Ok(None);
}
// It's a BTRFS subvolume. Now check if it's accessed via a bind mount.
//
// A path like `/workspace/repo` can be a bind mount FROM a btrfs subvolume
// at `/mnt/btrfs/repo`. In that case, statfs reports btrfs (because the data
// IS on btrfs), but the snapshot destination (e.g. `~/.grok/worktrees/...`)
// is NOT on btrfs. We need to detect the bind mount so we can create
// snapshots inside the actual btrfs mount point and expose them at the
// destination via a symlink.
if let Some(bind_info) = get_bind_mount_info(path)?
&& bind_info.fs_type == "btrfs"
{
let btrfs_mount = get_btrfs_mount_point(&bind_info.source).ok().flatten();
tracing::info!(
path = %path.display(),
source = %bind_info.source.display(),
btrfs_mount = ?btrfs_mount,
"path is a bind-mounted BTRFS subvolume"
);
return Ok(Some(BtrfsInfo {
subvolume_root: path.to_path_buf(),
bind_mount_source: Some(bind_info.source),
btrfs_mount_point: btrfs_mount,
}));
}
// Direct BTRFS subvolume (not bind-mounted)
let btrfs_mount = get_btrfs_mount_point(path).ok().flatten();
tracing::debug!(
path = %path.display(),
btrfs_mount = ?btrfs_mount,
"path is a direct BTRFS subvolume"
);
Ok(Some(BtrfsInfo {
subvolume_root: path.to_path_buf(),
bind_mount_source: None,
btrfs_mount_point: btrfs_mount,
}))
}
/// Run `btrfs subvolume show` to check if a path is a subvolume.
fn check_is_subvolume_cmd(path: &Path) -> bool {
let mut cmd = Command::new("btrfs");
xai_tty_utils::detach_std_command(&mut cmd);
cmd.stdin(Stdio::null());
// OsStr arg: a non-UTF-8 path must not silently collapse to ".".
cmd.arg("subvolume")
.arg("show")
.arg(path)
.output()
.map(|output| output.status.success())
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
/// Helper to check if we're running on a BTRFS filesystem.
/// Returns the BTRFS path if available, None otherwise.
fn get_btrfs_test_path() -> Option<PathBuf> {
// Check environment variable first
if let Ok(path) = std::env::var("BTRFS_TEST_PATH") {
let path = PathBuf::from(path);
if path.exists() && is_btrfs(&path).unwrap_or(false) {
return Some(path);
}
}
// Check common BTRFS mount points
for candidate in &["/", "/home", "/btrfs", "/mnt/btrfs"] {
let path = Path::new(candidate);
if path.exists() && is_btrfs(path).unwrap_or(false) {
return Some(path.to_path_buf());
}
}
None
}
/// Helper to check if a path is a BTRFS subvolume for testing.
fn get_btrfs_subvolume_test_path() -> Option<PathBuf> {
if let Some(btrfs_path) = get_btrfs_test_path()
&& is_btrfs_subvolume(&btrfs_path).ok().flatten().is_some()
{
return Some(btrfs_path);
}
None
}
#[test]
fn test_btrfs_info_debug() {
let info = BtrfsInfo {
subvolume_root: PathBuf::from("/test/path"),
bind_mount_source: None,
btrfs_mount_point: None,
};
let debug_str = format!("{:?}", info);
assert!(debug_str.contains("BtrfsInfo"));
assert!(debug_str.contains("/test/path"));
}
#[test]
fn test_btrfs_info_clone() {
let info = BtrfsInfo {
subvolume_root: PathBuf::from("/original/path"),
bind_mount_source: Some(PathBuf::from("/btrfs/source")),
btrfs_mount_point: Some(PathBuf::from("/btrfs")),
};
let cloned = info.clone();
assert_eq!(info.subvolume_root, cloned.subvolume_root);
assert_eq!(info.bind_mount_source, cloned.bind_mount_source);
assert_eq!(info.btrfs_mount_point, cloned.btrfs_mount_point);
}
#[test]
fn test_btrfs_info_with_bind_mount() {
let info = BtrfsInfo {
subvolume_root: PathBuf::from("/workspace/repo"),
bind_mount_source: Some(PathBuf::from("/mnt/btrfs/repo")),
btrfs_mount_point: Some(PathBuf::from("/mnt/btrfs")),
};
assert!(info.bind_mount_source.is_some());
assert_eq!(
info.bind_mount_source.as_ref().unwrap(),
&PathBuf::from("/mnt/btrfs/repo")
);
assert_eq!(
info.btrfs_mount_point.as_ref().unwrap(),
&PathBuf::from("/mnt/btrfs")
);
}
#[test]
fn test_is_btrfs_on_root() {
// Test on root filesystem - should not panic regardless of fs type
let result = is_btrfs(Path::new("/"));
assert!(result.is_ok());
// We can't assert the value since it depends on the system
}
#[test]
fn test_is_btrfs_on_tmp() {
// /tmp is typically not on BTRFS (tmpfs or ext4)
// This test just verifies the function doesn't panic
let result = is_btrfs(Path::new("/tmp"));
assert!(result.is_ok());
}
#[test]
fn test_is_btrfs_nonexistent_path() {
let result = is_btrfs(Path::new("/nonexistent/path/that/does/not/exist"));
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("statfs failed"));
}
#[test]
fn test_is_btrfs_subvolume_on_tmp() {
// Should return None for non-BTRFS paths (or paths that aren't subvolumes)
let result = is_btrfs_subvolume(Path::new("/tmp"));
assert!(result.is_ok());
// On most systems /tmp is not a BTRFS subvolume
// But we can't assert None because some systems might have BTRFS /tmp
}
#[test]
fn test_is_btrfs_subvolume_nonexistent_path() {
// Nonexistent paths should error in is_btrfs before reaching btrfs command
let result = is_btrfs_subvolume(Path::new("/nonexistent/path/xyz"));
assert!(result.is_err());
}
#[test]
fn test_is_btrfs_subvolume_on_root() {
// Test on root - should not panic
let result = is_btrfs_subvolume(Path::new("/"));
assert!(result.is_ok());
// Result depends on whether / is a BTRFS subvolume
}
#[test]
fn test_btrfs_detection_on_real_btrfs() {
// This test automatically skips if no BTRFS is detected
let Some(btrfs_path) = get_btrfs_test_path() else {
eprintln!("Skipping test: no BTRFS filesystem detected");
return;
};
let is_btrfs_result = is_btrfs(&btrfs_path);
assert!(is_btrfs_result.is_ok());
assert!(is_btrfs_result.unwrap(), "Expected path to be on BTRFS");
eprintln!("BTRFS detected at: {}", btrfs_path.display());
}
#[test]
fn test_btrfs_subvolume_detection_on_real_btrfs() {
// This test automatically skips if no BTRFS subvolume is detected
let Some(subvol_path) = get_btrfs_subvolume_test_path() else {
eprintln!("Skipping test: no BTRFS subvolume detected");
return;
};
let result = is_btrfs_subvolume(&subvol_path);
assert!(result.is_ok());
let info = result.unwrap();
assert!(info.is_some(), "Expected path to be a BTRFS subvolume");
eprintln!("BTRFS subvolume detected at: {}", subvol_path.display());
}
/// Test that a bind-mounted btrfs path (which reports as btrfs via statfs)
/// correctly detects the bind mount and populates bind_mount_source.
///
/// Regression: a bind-mounted working tree can be mis-detected as a "direct"
/// btrfs subvolume (`bind_mount_source=None`), which then makes snapshot
/// creation fail because the destination path is not on btrfs.
///
/// Optional live check: set `BTRFS_BIND_TEST_PATH` to a bind-mounted btrfs
/// path on the host. Skips when the env var is unset or the path is not a
/// bind-mounted btrfs location.
#[test]
fn test_bind_mounted_btrfs_detects_bind_mount_source() {
let path = match std::env::var_os("BTRFS_BIND_TEST_PATH") {
Some(p) => PathBuf::from(p),
None => {
eprintln!("Skipping test: BTRFS_BIND_TEST_PATH not set");
return;
}
};
if !path.exists() {
eprintln!(
"Skipping test: BTRFS_BIND_TEST_PATH={} does not exist",
path.display()
);
return;
}
// Check if it's on btrfs
if !is_btrfs(&path).unwrap_or(false) {
eprintln!("Skipping test: {} is not on btrfs", path.display());
return;
}
// Check if it's a bind mount
let bind_info = get_bind_mount_info(&path);
let is_bind_mount = bind_info.as_ref().ok().and_then(|o| o.as_ref()).is_some();
if !is_bind_mount {
eprintln!("Skipping test: {} is not a bind mount", path.display());
return;
}
// The critical test: is_btrfs_subvolume should detect the bind mount
let result = is_btrfs_subvolume(&path);
assert!(result.is_ok());
let info = result.unwrap();
assert!(
info.is_some(),
"Expected {} to be a BTRFS subvolume",
path.display()
);
let info = info.unwrap();
assert!(
info.bind_mount_source.is_some(),
"Expected bind_mount_source to be Some for bind-mounted btrfs path {}, \
but got None. This would cause snapshot creation to fail because the destination \
is not on btrfs.",
path.display()
);
assert!(
info.btrfs_mount_point.is_some(),
"Expected btrfs_mount_point to be Some"
);
eprintln!(
"Bind-mounted btrfs correctly detected:\n path: {}\n bind_source: {:?}\n btrfs_mount: {:?}",
info.subvolume_root.display(),
info.bind_mount_source,
info.btrfs_mount_point
);
}
// ─── Unit tests for resolve_via_subvol_mount ─────────────────────────
#[test]
fn test_resolve_via_subvol_mount_exact_match() {
// Simulates: mount -o subvol=/repo /dev/loop0 /workspace/repo
// Target root is /repo (the subvolume itself)
let mountinfo =
"8267 8961 0:813 /repo /workspace/repo rw,relatime - btrfs /dev/loop0 rw,ssd";
let result = resolve_via_subvol_mount("/dev/loop0", "/repo", mountinfo).unwrap();
assert_eq!(result, Some(PathBuf::from("/workspace/repo")));
}
#[test]
fn test_resolve_via_subvol_mount_nested_path() {
// Simulates: target root /repo/.grok-snapshots/wt-123 resolved via
// mount with root=/repo at /workspace/repo
let mountinfo =
"8267 8961 0:813 /repo /workspace/repo rw,relatime - btrfs /dev/loop0 rw,ssd";
let result =
resolve_via_subvol_mount("/dev/loop0", "/repo/.grok-snapshots/wt-123", mountinfo)
.unwrap();
assert_eq!(
result,
Some(PathBuf::from("/workspace/repo/.grok-snapshots/wt-123"))
);
}
#[test]
fn test_resolve_via_subvol_mount_no_match() {
// Target root doesn't start with mount root
let mountinfo =
"8267 8961 0:813 /repo /workspace/repo rw,relatime - btrfs /dev/loop0 rw,ssd";
let result = resolve_via_subvol_mount("/dev/loop0", "/other", mountinfo).unwrap();
assert_eq!(result, None);
}
#[test]
fn test_resolve_via_subvol_mount_partial_name_no_match() {
// /repo-other should NOT match mount_root=/repo
let mountinfo =
"8267 8961 0:813 /repo /workspace/repo rw,relatime - btrfs /dev/loop0 rw,ssd";
let result = resolve_via_subvol_mount("/dev/loop0", "/repo-other", mountinfo).unwrap();
assert_eq!(result, None);
}
#[test]
fn test_resolve_via_subvol_mount_wrong_device() {
// Different device should not match
let mountinfo =
"8267 8961 0:813 /repo /workspace/repo rw,relatime - btrfs /dev/loop0 rw,ssd";
let result = resolve_via_subvol_mount("/dev/loop1", "/repo", mountinfo).unwrap();
assert_eq!(result, None);
}
#[test]
fn test_resolve_via_subvol_mount_non_btrfs_ignored() {
// ext4 mount should not match
let mountinfo = "8970 8961 259:7 /mnt/local /local rw,relatime - ext4 /dev/nvme0n1p2 rw";
let result = resolve_via_subvol_mount("/dev/nvme0n1p2", "/mnt/local", mountinfo).unwrap();
assert_eq!(result, None);
}
}

View file

@ -0,0 +1,24 @@
//! BTRFS snapshot support for fast worktree creation.
//!
//! On Linux systems where the source repo is on a BTRFS subvolume,
//! we can use BTRFS snapshots for O(1) worktree creation instead of
//! file-by-file CoW cloning.
//!
//! The snapshot creates a complete standalone git repository (not a
//! git worktree), which is immediately usable without any fixup.
//!
//! This module also handles the case where the BTRFS subvolume is accessed
//! via a bind mount (e.g. when the working-tree path is not itself on BTRFS).
//! In such cases, snapshots are created inside the BTRFS mount point and
//! exposed at the expected destination via a symlink.
pub mod detect;
pub mod snapshot;
pub use detect::{BtrfsInfo, is_btrfs, is_btrfs_subvolume};
pub use snapshot::{
BTRFS_META_SUFFIX, BTRFS_SNAPSHOT_SUBDIRS, BtrfsSnapshotMetadata, SnapshotMetaState,
btrfs_meta_path, create_snapshot, create_snapshot_with_symlink, create_worktree_symlink,
delete_snapshot, is_safe_snapshot_delete_target, remove_btrfs_metadata, snapshot_dest_path,
snapshot_meta_state, snapshot_meta_targets, write_btrfs_metadata,
};

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,117 @@
//! Copy-on-Write file cloning using the reflink-copy crate.
//!
//! Uses reflink (CoW) when supported by the filesystem, with automatic
//! fallback to regular copy.
use std::path::Path;
use anyhow::Result;
/// Clone a file using CoW if supported, falling back to regular copy.
///
/// On filesystems that support it (APFS on macOS, Btrfs/XFS on Linux),
/// this creates a reflink which shares data blocks until modified.
/// On other filesystems, it performs a regular copy.
pub(crate) fn clone_file(src: &Path, dest: &Path) -> Result<()> {
reflink_copy::reflink_or_copy(src, dest)?;
// reflink (FICLONE) only clones data blocks, creating the dest with
// default umask permissions. Explicitly propagate the source mode so the
// executable bit etc. survive on reflink-capable filesystems.
let perms = std::fs::metadata(src)?.permissions();
std::fs::set_permissions(dest, perms)?;
Ok(())
}
/// Recreate `dst` as a symlink pointing at `target`, replacing any existing
/// entry at `dst`.
///
/// `symlink()` refuses to overwrite an existing path, so we remove `dst` first
/// (a missing `dst` is not an error).
pub(crate) fn replace_symlink(target: &Path, dst: &Path) -> std::io::Result<()> {
let _ = std::fs::remove_file(dst);
symlink_to(target, dst)
}
#[cfg(unix)]
fn symlink_to(target: &Path, dst: &Path) -> std::io::Result<()> {
std::os::unix::fs::symlink(target, dst)
}
#[cfg(windows)]
fn symlink_to(target: &Path, dst: &Path) -> std::io::Result<()> {
std::os::windows::fs::symlink_file(target, dst)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_clone_file_with_fallback() {
let temp = TempDir::new().unwrap();
let src = temp.path().join("source.txt");
let dest = temp.path().join("dest.txt");
std::fs::write(&src, "hello world").unwrap();
// Should work either via CoW or fallback
clone_file(&src, &dest).unwrap();
assert!(dest.exists());
assert_eq!(std::fs::read_to_string(&dest).unwrap(), "hello world");
}
#[test]
fn test_clone_file_binary() {
let temp = TempDir::new().unwrap();
let src = temp.path().join("source.bin");
let dest = temp.path().join("dest.bin");
let data: Vec<u8> = (0..=255).collect();
std::fs::write(&src, &data).unwrap();
clone_file(&src, &dest).unwrap();
assert_eq!(std::fs::read(&dest).unwrap(), data);
}
#[test]
fn test_clone_preserves_permissions() {
use std::os::unix::fs::PermissionsExt;
let temp = TempDir::new().unwrap();
let src = temp.path().join("script.sh");
let dest = temp.path().join("script_copy.sh");
std::fs::write(&src, "#!/bin/bash\necho hello").unwrap();
// Make executable
let mut perms = std::fs::metadata(&src).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&src, perms).unwrap();
clone_file(&src, &dest).unwrap();
let dest_perms = std::fs::metadata(&dest).unwrap().permissions();
assert_eq!(dest_perms.mode() & 0o777, 0o755);
}
#[cfg(unix)]
#[test]
fn test_replace_symlink_overwrites_and_allows_dangling() {
let temp = TempDir::new().unwrap();
let dst = temp.path().join("link");
std::fs::write(&dst, "stale").unwrap();
// Target is intentionally dangling; it must still be created.
replace_symlink(Path::new("does-not-exist"), &dst).unwrap();
let meta = std::fs::symlink_metadata(&dst).unwrap();
assert!(meta.file_type().is_symlink(), "dst must be a symlink");
assert_eq!(
std::fs::read_link(&dst).unwrap(),
Path::new("does-not-exist")
);
}
}

View file

@ -0,0 +1,442 @@
//! Parallel copy engine.
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use anyhow::Result;
use crossbeam::channel::{Sender, bounded};
use dashmap::{DashMap, DashSet};
use ignore::{WalkBuilder, WalkState};
use tokio_util::sync::CancellationToken;
use crate::copy::shard::shard_for_path;
use crate::copy::skip::build_skip_matcher;
use crate::copy::types::{
CopyEntry, CopyEntryKind, CopyStats, ParallelCopyConfig, ParallelCopyResult,
};
use crate::copy::worker::{WorkerCtx, run_worker};
/// Copy files from source to dest using parallel workers with hash-based sharding.
///
/// Returns both stats and the set of paths that were copied (for deduplication).
/// Maximum worker threads to prevent FD exhaustion on macOS.
/// macOS default ulimit is 256. With 8 workers + 8 walker threads = 16 threads,
/// each can have ~10 FDs open (deeply nested dirs), leaving headroom for other uses.
#[cfg(target_os = "macos")]
const MAX_PARALLEL_WORKERS: usize = 8;
#[cfg(not(target_os = "macos"))]
const MAX_PARALLEL_WORKERS: usize = 32;
pub(crate) fn copy_parallel(
source: &Path,
dest: &Path,
config: ParallelCopyConfig,
cancellation_token: CancellationToken,
) -> Result<ParallelCopyResult> {
let num_workers = if config.num_workers == 0 {
num_cpus::get().min(MAX_PARALLEL_WORKERS)
} else {
config.num_workers.min(MAX_PARALLEL_WORKERS)
};
// Build skip patterns matcher.
let skip_matcher = if !config.skip_patterns.is_empty() {
Some(build_skip_matcher(&config.skip_patterns)?)
} else {
None
};
// Create bounded channels for each shard.
let channels: Vec<_> = (0..num_workers)
.map(|_| bounded::<CopyEntry>(config.channel_buffer))
.collect();
// Shared atomic counters for stats.
let files_copied = Arc::new(AtomicU64::new(0));
let dirs_created = Arc::new(AtomicU64::new(0));
let symlinks_copied = Arc::new(AtomicU64::new(0));
let files_skipped = Arc::new(AtomicU64::new(0));
let issues: Arc<std::sync::Mutex<Vec<String>>> = Arc::new(std::sync::Mutex::new(Vec::new()));
// Track successfully copied paths for deduplication.
let copied_paths: Arc<DashSet<std::path::PathBuf>> = Arc::new(DashSet::new());
// Collect file metadata for index updates.
let file_metadata: Arc<DashMap<std::path::PathBuf, std::fs::Metadata>> =
Arc::new(DashMap::new());
// Spawn worker threads.
let workers: Vec<_> = channels
.iter()
.map(|(_, rx)| {
let rx = rx.clone();
let ctx = WorkerCtx {
source: source.to_path_buf(),
dest: dest.to_path_buf(),
files_copied: Arc::clone(&files_copied),
dirs_created: Arc::clone(&dirs_created),
symlinks_copied: Arc::clone(&symlinks_copied),
issues: Arc::clone(&issues),
copied_paths: Arc::clone(&copied_paths),
file_metadata: Arc::clone(&file_metadata),
};
std::thread::spawn(move || run_worker(rx, ctx))
})
.collect();
// Collect senders for the walker.
let senders: Vec<Sender<CopyEntry>> = channels.iter().map(|(tx, _)| tx.clone()).collect();
// Build the walker.
// IMPORTANT: Limit walker threads to match num_workers to avoid FD exhaustion.
// On macOS, the default FD limit (256) can easily be exceeded when:
// - num_cpus walker threads (default) × directories open per thread
// - Plus num_workers copy workers × files being copied
// Deep directory trees (15+ levels) amplify this significantly.
let mut builder = WalkBuilder::new(source);
builder
.hidden(false) // Include hidden files.
.git_ignore(config.respect_gitignore)
.git_global(false) // Never use global gitignore (~/.config/git/ignore) —
// it contains personal preferences irrelevant to worktree creation.
.git_exclude(false) // Never use .git/info/exclude — external tooling
// can append broad patterns (*.min.js, *.zip) that
// incorrectly skip git-tracked files. The `ignore` crate doesn't
// check tracking status, so tracked files matching these patterns
// get silently dropped during the copy.
.threads(num_workers) // Limit walker parallelism to avoid FD exhaustion
.filter_entry(|entry| {
// Always skip .git directory.
entry.file_name() != ".git"
});
let walker = builder.build_parallel();
// Clone data for the walker closure.
let source_for_walker = source.to_path_buf();
let skip_files = config.skip_files.clone();
let files_skipped_walker = Arc::clone(&files_skipped);
let skip_matcher = skip_matcher.map(Arc::new);
// Run the parallel walker.
walker.run(|| {
let senders = senders.clone();
let source = source_for_walker.clone();
let n = num_workers;
let skip_files = skip_files.clone();
let files_skipped = Arc::clone(&files_skipped_walker);
let skip_matcher = skip_matcher.clone();
let cancellation_token = cancellation_token.clone();
Box::new(move |entry_result| {
// Check for cancellation
if cancellation_token.is_cancelled() {
return WalkState::Quit;
}
let entry = match entry_result {
Ok(e) => e,
Err(_) => return WalkState::Continue,
};
// Get relative path.
let rel_path = match entry.path().strip_prefix(&source) {
Ok(p) => p.to_path_buf(),
Err(_) => return WalkState::Continue,
};
// Skip root.
if rel_path.as_os_str().is_empty() {
return WalkState::Continue;
}
// Check if this file should be skipped (already copied or explicitly skipped).
if let Some(ref skip) = skip_files
&& skip.contains(&rel_path)
{
files_skipped.fetch_add(1, Ordering::Relaxed);
return WalkState::Continue;
}
// Check skip patterns.
if let Some(ref matcher) = skip_matcher
&& matcher.is_match(&rel_path)
{
files_skipped.fetch_add(1, Ordering::Relaxed);
return WalkState::Continue;
}
let file_type = entry.file_type();
let is_dir = file_type.as_ref().map(|ft| ft.is_dir()).unwrap_or(false);
let is_symlink = file_type
.as_ref()
.map(|ft| ft.is_symlink())
.unwrap_or(false);
let kind = if is_dir {
CopyEntryKind::Dir
} else if is_symlink {
CopyEntryKind::Symlink
} else {
CopyEntryKind::File
};
// Compute shard and send.
let shard = shard_for_path(&rel_path, n);
let _ = senders[shard].send(CopyEntry { rel_path, kind });
WalkState::Continue
})
});
// Close senders to signal workers to finish.
drop(senders);
for (tx, _) in channels {
drop(tx);
}
// Wait for all workers.
for worker in workers {
let _ = worker.join();
}
// Collect issues.
let issues = match Arc::try_unwrap(issues) {
Ok(mutex) => mutex.into_inner().unwrap_or_default(),
Err(arc) => arc.lock().unwrap().clone(),
};
let copied_paths = match Arc::try_unwrap(copied_paths) {
Ok(set) => set,
Err(arc) => {
let mut set = DashSet::new();
set.extend(arc.iter().map(|p| p.clone()));
set
}
};
let file_metadata = match Arc::try_unwrap(file_metadata) {
Ok(map) => map,
Err(arc) => {
let map = DashMap::new();
for entry in arc.iter() {
map.insert(entry.key().clone(), entry.value().clone());
}
map
}
};
Ok(ParallelCopyResult {
stats: CopyStats {
files_copied: files_copied.load(Ordering::Relaxed),
dirs_created: dirs_created.load(Ordering::Relaxed),
symlinks_copied: symlinks_copied.load(Ordering::Relaxed),
files_skipped: files_skipped.load(Ordering::Relaxed),
issues,
},
copied_paths,
file_metadata,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::copy::types::ParallelCopyConfig;
use std::path::PathBuf;
use tempfile::TempDir;
#[test]
fn test_copy_parallel_simple() {
let src = TempDir::new().unwrap();
let dest = TempDir::new().unwrap();
// Create some files
std::fs::write(src.path().join("file1.txt"), "content1").unwrap();
std::fs::write(src.path().join("file2.txt"), "content2").unwrap();
std::fs::create_dir(src.path().join("subdir")).unwrap();
std::fs::write(src.path().join("subdir/file3.txt"), "content3").unwrap();
let config = ParallelCopyConfig {
num_workers: 2,
channel_buffer: 64,
respect_gitignore: false,
..Default::default()
};
let result =
copy_parallel(src.path(), dest.path(), config, CancellationToken::new()).unwrap();
assert_eq!(result.stats.files_copied, 3);
assert!(dest.path().join("file1.txt").exists());
assert!(dest.path().join("file2.txt").exists());
assert!(dest.path().join("subdir/file3.txt").exists());
assert_eq!(result.copied_paths.len(), 4); // 3 files + 1 dir
}
#[test]
fn test_copy_parallel_with_skip() {
let src = TempDir::new().unwrap();
let dest = TempDir::new().unwrap();
std::fs::write(src.path().join("keep.txt"), "keep").unwrap();
std::fs::write(src.path().join("skip.txt"), "skip").unwrap();
let skip = DashSet::new();
skip.insert(PathBuf::from("skip.txt"));
let config = ParallelCopyConfig {
num_workers: 2,
channel_buffer: 64,
skip_files: Some(Arc::new(skip)),
respect_gitignore: false,
..Default::default()
};
let result =
copy_parallel(src.path(), dest.path(), config, CancellationToken::new()).unwrap();
assert_eq!(result.stats.files_copied, 1);
assert_eq!(result.stats.files_skipped, 1);
assert!(dest.path().join("keep.txt").exists());
assert!(!dest.path().join("skip.txt").exists());
}
#[test]
fn test_copy_parallel_only_ignored() {
xai_test_utils::require_git!();
let src = TempDir::new().unwrap();
let dest = TempDir::new().unwrap();
// Create a tracked file
std::fs::write(src.path().join("tracked.txt"), "tracked").unwrap();
// Create an "ignored" directory
std::fs::create_dir(src.path().join("node_modules")).unwrap();
std::fs::write(src.path().join("node_modules/pkg.txt"), "pkg").unwrap();
// Create .gitignore
std::fs::write(src.path().join(".gitignore"), "node_modules/").unwrap();
// Initialize git repo
std::process::Command::new("git")
.current_dir(src.path())
.args(["init"])
.output()
.unwrap();
// Copy only ignored files (skip unignored paths)
let config = ParallelCopyConfig {
num_workers: 2,
channel_buffer: 64,
skip_files: Some(Arc::new(
crate::copy::collect_unignored_paths(src.path(), 1).unwrap(),
)),
..Default::default()
};
let _result =
copy_parallel(src.path(), dest.path(), config, CancellationToken::new()).unwrap();
// Should have copied node_modules but not tracked.txt
assert!(dest.path().join("node_modules/pkg.txt").exists());
assert!(!dest.path().join("tracked.txt").exists());
}
#[test]
fn test_copy_parallel_with_cancellation_token_cancelled() {
use tokio_util::sync::CancellationToken;
let src = TempDir::new().unwrap();
let dest = TempDir::new().unwrap();
// Create some files
std::fs::write(src.path().join("file1.txt"), "content1").unwrap();
std::fs::write(src.path().join("file2.txt"), "content2").unwrap();
std::fs::write(src.path().join("file3.txt"), "content3").unwrap();
// Create cancellation token - cancel immediately (pre-cancelled)
let token = CancellationToken::new();
token.cancel();
let config = ParallelCopyConfig {
num_workers: 2,
channel_buffer: 64,
respect_gitignore: false,
..Default::default()
};
// Pass the PRE-CANCELLED token (not a fresh one): the walker checks
// cancellation first thing in every callback and quits, so nothing is
// ever queued to the workers.
let result = copy_parallel(src.path(), dest.path(), config, token).unwrap();
assert_eq!(
result.stats.files_copied, 0,
"a pre-cancelled token must short-circuit the copy before any file is written"
);
}
#[test]
fn test_copy_parallel_cancellation_token_not_cancelled() {
use tokio_util::sync::CancellationToken;
let src = TempDir::new().unwrap();
let dest = TempDir::new().unwrap();
// Create some files
std::fs::write(src.path().join("file1.txt"), "content1").unwrap();
std::fs::write(src.path().join("file2.txt"), "content2").unwrap();
let config = ParallelCopyConfig {
num_workers: 2,
channel_buffer: 64,
respect_gitignore: false,
..Default::default()
};
let result =
copy_parallel(src.path(), dest.path(), config, CancellationToken::new()).unwrap();
// All files should be copied
assert_eq!(result.stats.files_copied, 2);
assert!(dest.path().join("file1.txt").exists());
assert!(dest.path().join("file2.txt").exists());
}
#[cfg(unix)]
#[test]
fn test_copy_parallel_replicates_symlink() {
// Exercises the worker's CopyEntryKind::Symlink arm: a symlink in the
// source tree must be replicated AS a symlink (not dereferenced).
let src = TempDir::new().unwrap();
let dest = TempDir::new().unwrap();
std::fs::write(src.path().join("target.txt"), "content").unwrap();
std::os::unix::fs::symlink("target.txt", src.path().join("link.txt")).unwrap();
let config = ParallelCopyConfig {
num_workers: 2,
channel_buffer: 64,
respect_gitignore: false,
..Default::default()
};
let result =
copy_parallel(src.path(), dest.path(), config, CancellationToken::new()).unwrap();
assert_eq!(result.stats.symlinks_copied, 1);
let meta = std::fs::symlink_metadata(dest.path().join("link.txt")).unwrap();
assert!(
meta.file_type().is_symlink(),
"link must be replicated as a symlink"
);
assert_eq!(
std::fs::read_link(dest.path().join("link.txt")).unwrap(),
PathBuf::from("target.txt")
);
}
}

View file

@ -0,0 +1,569 @@
//! Selective CoW copy of `.git/` directory for standalone repository cloning.
//!
//! Copies essential git internal files using reflink (CoW) when supported,
//! skipping transient state, lock files, and stale worktree registrations.
//!
//! The `objects/` directory (often the largest subtree) is copied in parallel
//! using a thread pool for better throughput on SSDs.
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use anyhow::{Context, Result};
use crate::copy::cow::clone_file;
/// Statistics from copying the `.git/` directory.
#[derive(Clone, Debug, Default)]
pub(crate) struct GitDirCopyStats {
pub files_copied: u64,
pub dirs_created: u64,
pub symlinks_copied: u64,
pub entries_skipped: u64,
}
/// Top-level `.git/` entries to skip when creating a standalone copy.
///
/// These are either transient state (merge/rebase in-progress markers) or
/// linked-worktree metadata that would be stale in the copy.
const SKIP_TOP_LEVEL: &[&str] = &[
// Linked worktree registrations — stale in a standalone copy
"worktrees",
// Transient HEAD-like state files
"FETCH_HEAD",
"ORIG_HEAD",
"MERGE_HEAD",
"CHERRY_PICK_HEAD",
"REVERT_HEAD",
"REBASE_HEAD",
"AUTO_MERGE",
"BISECT_LOG",
// In-progress multi-step operation state
"sequencer",
"rebase-merge",
"rebase-apply",
// GC state
"gc.log",
// fsmonitor daemon state — a host-local Unix-domain IPC socket
// (`fsmonitor--daemon.ipc`, which cannot be reflinked/copied) plus its
// transient `cookies/` dir. Both are runtime state of the source repo's
// daemon and must never be inherited by a standalone copy.
"fsmonitor--daemon",
"fsmonitor--daemon.ipc",
];
/// A work item for the parallel copy pool.
struct CopyWork {
source: PathBuf,
dest: PathBuf,
}
/// Copy `.git/` directory contents using CoW, skipping unnecessary entries.
///
/// Creates a standalone git repository's `.git/` at `dest_git` by selectively
/// copying from `source_git`. Files are copied using reflink (CoW) when the
/// filesystem supports it, falling back to regular copy otherwise.
///
/// The `objects/` subtree is copied in parallel (it's typically the largest
/// part and has no ordering dependencies). Other top-level entries are copied
/// sequentially.
///
/// Skips:
/// - Lock files (`*.lock`) at any depth
/// - Stale worktree registrations (`worktrees/`)
/// - Transient state files (`MERGE_HEAD`, `CHERRY_PICK_HEAD`, etc.)
/// - In-progress rebase/cherry-pick state (`sequencer/`, `rebase-merge/`)
pub(crate) fn copy_git_dir(source_git: &Path, dest_git: &Path) -> Result<GitDirCopyStats> {
copy_git_dir_with_workers(source_git, dest_git, num_cpus::get())
}
/// `copy_git_dir` with an explicit worker cap, so tests can force the parallel
/// branch (`max_workers >= 2`) deterministically regardless of `num_cpus`.
fn copy_git_dir_with_workers(
source_git: &Path,
dest_git: &Path,
max_workers: usize,
) -> Result<GitDirCopyStats> {
anyhow::ensure!(
source_git.is_dir(),
"source .git must be a directory (not a linked worktree .git file): {}",
source_git.display()
);
let files_copied = AtomicU64::new(0);
let dirs_created = AtomicU64::new(0);
let symlinks_copied = AtomicU64::new(0);
let entries_skipped = AtomicU64::new(0);
// First pass: collect work items for parallel copy.
// We collect all (source, dest) pairs, then process them in parallel.
let mut work_items: Vec<CopyWork> = Vec::new();
collect_work_recursive(
source_git,
dest_git,
0,
&mut work_items,
&dirs_created,
&entries_skipped,
)?;
// Process file copies in parallel using scoped threads.
let num_workers = max_workers.min(work_items.len().max(1));
if num_workers <= 1 || work_items.len() < 64 {
// Not enough work to justify parallelism.
for item in &work_items {
copy_single_entry(&item.source, &item.dest, &files_copied, &symlinks_copied)?;
}
} else {
// Shard work items across threads (simple round-robin). Each thread
// returns its first copy error; the sequential branch propagates errors
// with `?`, so this branch must too — a failed `.git/index`/pack copy
// would otherwise yield a silently-corrupt standalone repo.
let chunk_size = work_items.len().div_ceil(num_workers);
let first_error = crossbeam::scope(|scope| {
let handles: Vec<_> = work_items
.chunks(chunk_size)
.map(|chunk| {
let files_copied = &files_copied;
let symlinks_copied = &symlinks_copied;
scope.spawn(move |_| -> Result<()> {
for item in chunk {
copy_single_entry(
&item.source,
&item.dest,
files_copied,
symlinks_copied,
)?;
}
Ok(())
})
})
.collect();
// Join in spawn order so "first error" is deterministic.
let mut first_error: Option<anyhow::Error> = None;
for handle in handles {
let chunk_result = match handle.join() {
Ok(r) => r,
Err(_) => Err(anyhow::anyhow!("parallel .git/ copy thread panicked")),
};
if let Err(e) = chunk_result
&& first_error.is_none()
{
first_error = Some(e);
}
}
first_error
})
.map_err(|_| anyhow::anyhow!("parallel .git/ copy panicked"))?;
if let Some(e) = first_error {
return Err(e);
}
}
let stats = GitDirCopyStats {
files_copied: files_copied.load(Ordering::Relaxed),
dirs_created: dirs_created.load(Ordering::Relaxed),
symlinks_copied: symlinks_copied.load(Ordering::Relaxed),
entries_skipped: entries_skipped.load(Ordering::Relaxed),
};
tracing::debug!(
files = stats.files_copied,
dirs = stats.dirs_created,
symlinks = stats.symlinks_copied,
skipped = stats.entries_skipped,
workers = num_workers,
"git dir copy complete"
);
Ok(stats)
}
/// Recursively collect work items (files/symlinks to copy), creating directories eagerly.
///
/// Directories are created immediately (they must exist before files are written),
/// but file copies are deferred to the work list for parallel processing.
fn collect_work_recursive(
source: &Path,
dest: &Path,
depth: usize,
work_items: &mut Vec<CopyWork>,
dirs_created: &AtomicU64,
entries_skipped: &AtomicU64,
) -> Result<()> {
std::fs::create_dir_all(dest)
.with_context(|| format!("failed to create directory {}", dest.display()))?;
dirs_created.fetch_add(1, Ordering::Relaxed);
let entries = std::fs::read_dir(source)
.with_context(|| format!("failed to read directory {}", source.display()))?;
for entry_result in entries {
let entry = entry_result
.with_context(|| format!("failed to read entry in {}", source.display()))?;
let name = entry.file_name();
let name_str = name.to_string_lossy();
if should_skip(&name_str, depth) {
entries_skipped.fetch_add(1, Ordering::Relaxed);
tracing::trace!(entry = %name_str, depth, "skipping .git/ entry");
continue;
}
let source_path = entry.path();
let dest_path = dest.join(&name);
let file_type = entry
.file_type()
.with_context(|| format!("failed to get file type for {}", source_path.display()))?;
if file_type.is_dir() {
collect_work_recursive(
&source_path,
&dest_path,
depth + 1,
work_items,
dirs_created,
entries_skipped,
)?;
} else if file_type.is_file() || file_type.is_symlink() {
// Regular file or symlink — add to work list.
work_items.push(CopyWork {
source: source_path,
dest: dest_path,
});
} else {
// Non-regular file (Unix socket, FIFO, device): it cannot be
// reflinked or copied as a file, and it is transient host-local
// state with no meaning in a copy (e.g. git's leftover
// `fsmonitor--daemon.ipc` socket). Skip it instead of failing the
// whole `.git/` copy.
entries_skipped.fetch_add(1, Ordering::Relaxed);
tracing::debug!(entry = %name_str, depth, "skipping non-regular .git/ entry");
}
}
Ok(())
}
/// Copy a single file or symlink entry.
fn copy_single_entry(
source_path: &Path,
dest_path: &Path,
files_copied: &AtomicU64,
symlinks_copied: &AtomicU64,
) -> Result<()> {
// Check if it's a symlink by querying symlink metadata.
let metadata = std::fs::symlink_metadata(source_path)
.with_context(|| format!("failed to stat {}", source_path.display()))?;
if metadata.is_symlink() {
// `target` is only used by the Unix symlink-recreate path. On
// Windows we copy the link as a regular file (no native symlink),
// so the target is never inspected.
#[cfg(unix)]
{
let target = std::fs::read_link(source_path)
.with_context(|| format!("failed to read symlink {}", source_path.display()))?;
std::os::unix::fs::symlink(&target, dest_path).with_context(|| {
format!(
"failed to create symlink {} -> {}",
dest_path.display(),
target.display()
)
})?;
}
#[cfg(not(unix))]
{
clone_file(source_path, dest_path).with_context(|| {
format!(
"failed to copy symlink as file {} -> {}",
source_path.display(),
dest_path.display()
)
})?;
}
symlinks_copied.fetch_add(1, Ordering::Relaxed);
} else {
clone_file(source_path, dest_path).with_context(|| {
format!(
"failed to copy {} -> {}",
source_path.display(),
dest_path.display()
)
})?;
files_copied.fetch_add(1, Ordering::Relaxed);
}
Ok(())
}
/// Decide whether to skip a `.git/` entry based on its name and depth.
fn should_skip(name: &str, depth: usize) -> bool {
// Skip lock files at any depth
if name.ends_with(".lock") {
return true;
}
// Skip known top-level entries
if depth == 0 && SKIP_TOP_LEVEL.contains(&name) {
return true;
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_copy_git_dir_basic() {
let temp = TempDir::new().unwrap();
let source_git = temp.path().join("source/.git");
let dest_git = temp.path().join("dest/.git");
// Create a minimal .git structure
std::fs::create_dir_all(source_git.join("objects/pack")).unwrap();
std::fs::create_dir_all(source_git.join("refs/heads")).unwrap();
std::fs::write(source_git.join("HEAD"), "ref: refs/heads/main\n").unwrap();
std::fs::write(source_git.join("config"), "[core]\n\tbare = false\n").unwrap();
std::fs::write(source_git.join("index"), "fake index data").unwrap();
std::fs::write(
source_git.join("objects/pack/pack-abc.pack"),
"fake pack data",
)
.unwrap();
std::fs::write(source_git.join("refs/heads/main"), "abc123\n").unwrap();
let stats = copy_git_dir(&source_git, &dest_git).unwrap();
assert!(dest_git.join("HEAD").exists());
assert!(dest_git.join("config").exists());
assert!(dest_git.join("index").exists());
assert!(dest_git.join("objects/pack/pack-abc.pack").exists());
assert!(dest_git.join("refs/heads/main").exists());
assert!(stats.files_copied >= 5);
}
#[test]
fn test_copy_git_dir_skips_worktrees() {
let temp = TempDir::new().unwrap();
let source_git = temp.path().join("source/.git");
let dest_git = temp.path().join("dest/.git");
std::fs::create_dir_all(source_git.join("worktrees/wt1")).unwrap();
std::fs::write(source_git.join("worktrees/wt1/gitdir"), "/some/path").unwrap();
std::fs::write(source_git.join("HEAD"), "ref: refs/heads/main\n").unwrap();
let stats = copy_git_dir(&source_git, &dest_git).unwrap();
assert!(dest_git.join("HEAD").exists());
assert!(!dest_git.join("worktrees").exists());
assert!(stats.entries_skipped >= 1);
}
#[test]
fn test_copy_git_dir_skips_lock_files() {
let temp = TempDir::new().unwrap();
let source_git = temp.path().join("source/.git");
let dest_git = temp.path().join("dest/.git");
std::fs::create_dir_all(&source_git).unwrap();
std::fs::write(source_git.join("HEAD"), "ref: refs/heads/main\n").unwrap();
std::fs::write(source_git.join("index.lock"), "locked").unwrap();
std::fs::write(source_git.join("config.lock"), "locked").unwrap();
let stats = copy_git_dir(&source_git, &dest_git).unwrap();
assert!(dest_git.join("HEAD").exists());
assert!(!dest_git.join("index.lock").exists());
assert!(!dest_git.join("config.lock").exists());
assert!(stats.entries_skipped >= 2);
}
#[test]
fn test_copy_git_dir_skips_lock_files_in_subdirs() {
let temp = TempDir::new().unwrap();
let source_git = temp.path().join("source/.git");
let dest_git = temp.path().join("dest/.git");
std::fs::create_dir_all(source_git.join("refs/heads")).unwrap();
std::fs::write(source_git.join("HEAD"), "ref: refs/heads/main\n").unwrap();
std::fs::write(source_git.join("refs/heads/main"), "abc123\n").unwrap();
std::fs::write(source_git.join("refs/heads/main.lock"), "locked").unwrap();
let stats = copy_git_dir(&source_git, &dest_git).unwrap();
assert!(dest_git.join("refs/heads/main").exists());
assert!(!dest_git.join("refs/heads/main.lock").exists());
assert!(stats.entries_skipped >= 1);
}
#[test]
fn test_copy_git_dir_skips_transient_state() {
let temp = TempDir::new().unwrap();
let source_git = temp.path().join("source/.git");
let dest_git = temp.path().join("dest/.git");
std::fs::create_dir_all(&source_git).unwrap();
std::fs::write(source_git.join("HEAD"), "ref: refs/heads/main\n").unwrap();
std::fs::write(source_git.join("MERGE_HEAD"), "abc123").unwrap();
std::fs::write(source_git.join("CHERRY_PICK_HEAD"), "def456").unwrap();
std::fs::write(source_git.join("ORIG_HEAD"), "ghi789").unwrap();
std::fs::write(source_git.join("FETCH_HEAD"), "jkl012").unwrap();
std::fs::create_dir_all(source_git.join("rebase-merge")).unwrap();
std::fs::write(source_git.join("rebase-merge/head-name"), "main").unwrap();
std::fs::create_dir_all(source_git.join("sequencer")).unwrap();
std::fs::write(source_git.join("sequencer/todo"), "pick abc123").unwrap();
let stats = copy_git_dir(&source_git, &dest_git).unwrap();
assert!(dest_git.join("HEAD").exists());
assert!(!dest_git.join("MERGE_HEAD").exists());
assert!(!dest_git.join("CHERRY_PICK_HEAD").exists());
assert!(!dest_git.join("ORIG_HEAD").exists());
assert!(!dest_git.join("FETCH_HEAD").exists());
assert!(!dest_git.join("rebase-merge").exists());
assert!(!dest_git.join("sequencer").exists());
assert!(stats.entries_skipped >= 6);
}
#[test]
fn test_copy_git_dir_skips_fsmonitor_daemon_state() {
// git's fsmonitor leaves a `fsmonitor--daemon/` dir (and an `.ipc`
// socket) of host-local runtime state. It must not be inherited by a
// standalone copy.
let temp = TempDir::new().unwrap();
let source_git = temp.path().join("source/.git");
let dest_git = temp.path().join("dest/.git");
std::fs::create_dir_all(source_git.join("fsmonitor--daemon/cookies")).unwrap();
std::fs::write(source_git.join("HEAD"), "ref: refs/heads/main\n").unwrap();
let stats = copy_git_dir(&source_git, &dest_git).unwrap();
assert!(dest_git.join("HEAD").exists());
assert!(!dest_git.join("fsmonitor--daemon").exists());
assert!(stats.entries_skipped >= 1);
}
#[cfg(unix)]
#[test]
fn test_copy_git_dir_skips_non_regular_files() {
// A leftover Unix-domain socket (e.g. git's `fsmonitor--daemon.ipc`)
// cannot be reflinked or copied as a file. It must be skipped, not fail
// the whole `.git/` copy. Uses a non-fsmonitor name so this exercises
// the type-based skip rather than the SKIP_TOP_LEVEL name match.
use std::os::unix::net::UnixListener;
let temp = TempDir::new().unwrap();
let source_git = temp.path().join("source/.git");
let dest_git = temp.path().join("dest/.git");
std::fs::create_dir_all(&source_git).unwrap();
std::fs::write(source_git.join("HEAD"), "ref: refs/heads/main\n").unwrap();
let _socket = UnixListener::bind(source_git.join("daemon.sock")).unwrap();
let stats = copy_git_dir(&source_git, &dest_git).unwrap();
assert!(dest_git.join("HEAD").exists());
assert!(!dest_git.join("daemon.sock").exists());
assert!(stats.entries_skipped >= 1);
}
#[test]
fn test_copy_git_dir_preserves_hooks() {
let temp = TempDir::new().unwrap();
let source_git = temp.path().join("source/.git");
let dest_git = temp.path().join("dest/.git");
std::fs::create_dir_all(source_git.join("hooks")).unwrap();
std::fs::write(source_git.join("HEAD"), "ref: refs/heads/main\n").unwrap();
std::fs::write(
source_git.join("hooks/pre-commit"),
"#!/bin/bash\necho check",
)
.unwrap();
let _stats = copy_git_dir(&source_git, &dest_git).unwrap();
assert!(dest_git.join("hooks/pre-commit").exists());
assert_eq!(
std::fs::read_to_string(dest_git.join("hooks/pre-commit")).unwrap(),
"#!/bin/bash\necho check"
);
}
#[test]
fn test_copy_git_dir_preserves_worktree_source_marker() {
// A worktree-from-worktree (standalone) must inherit the source's
// `grok-worktree-source` marker so it still points at the ultimate
// main repo rather than the intermediate worktree.
let temp = TempDir::new().unwrap();
let source_git = temp.path().join("source/.git");
let dest_git = temp.path().join("dest/.git");
std::fs::create_dir_all(&source_git).unwrap();
std::fs::write(source_git.join("HEAD"), "ref: refs/heads/main\n").unwrap();
std::fs::write(source_git.join("grok-worktree-source"), "/main/repo").unwrap();
copy_git_dir(&source_git, &dest_git).unwrap();
assert_eq!(
std::fs::read_to_string(dest_git.join("grok-worktree-source")).unwrap(),
"/main/repo"
);
}
#[test]
fn test_copy_git_dir_propagates_entry_copy_error() {
// A failed entry copy must surface as an error, not a silently-corrupt
// "success". `max_workers = 4` + >= 64 items forces the PARALLEL branch
// deterministically (independent of num_cpus).
let temp = TempDir::new().unwrap();
let source_git = temp.path().join("source/.git");
let dest_git = temp.path().join("dest/.git");
std::fs::create_dir_all(&source_git).unwrap();
std::fs::write(source_git.join("HEAD"), "ref: refs/heads/main\n").unwrap();
for i in 0..128 {
std::fs::write(source_git.join(format!("obj{i}")), "data").unwrap();
}
// Pre-create the dest entry for `obj0` as a DIRECTORY so the file copy
// onto it fails (EISDIR) deterministically, even as root.
std::fs::create_dir_all(dest_git.join("obj0")).unwrap();
let err = copy_git_dir_with_workers(&source_git, &dest_git, 4)
.expect_err("a failed .git/ entry copy must propagate as an error");
// The error names the failing entry, not some unrelated setup failure.
let chain = format!("{err:#}");
assert!(
chain.contains("obj0"),
"error should reference the failing entry, got: {chain}"
);
}
#[test]
fn test_copy_git_dir_rejects_git_file() {
let temp = TempDir::new().unwrap();
let source_git = temp.path().join("source/.git");
let dest_git = temp.path().join("dest/.git");
// Create .git as a file (linked worktree), not a directory
std::fs::create_dir_all(temp.path().join("source")).unwrap();
std::fs::write(&source_git, "gitdir: /some/other/path").unwrap();
let result = copy_git_dir(&source_git, &dest_git);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("must be a directory")
);
}
}

View file

@ -0,0 +1,15 @@
//! Filesystem replication engine used by fast worktree creation.
pub(crate) mod cow;
pub(crate) mod engine;
pub(crate) mod gitdir;
pub(crate) mod shard;
pub(crate) mod skip;
pub(crate) mod types;
pub(crate) mod worker;
pub(crate) use engine::copy_parallel;
pub(crate) use skip::collect_unignored_paths;
pub use types::CopyStats;
pub use types::DirtyFilesReport;
pub(crate) use types::ParallelCopyConfig;

View file

@ -0,0 +1,90 @@
//! Hash-based shard assignment for parallel file operations.
#[cfg(unix)]
use std::os::unix::ffi::OsStrExt;
use std::path::Path;
use rapidhash::v3::rapidhash_v3;
/// rapidhash of a path's raw bytes (lossy UTF-8 on non-unix).
fn rapidhash_path(path: &Path) -> u64 {
#[cfg(unix)]
let bytes = path.as_os_str().as_bytes();
#[cfg(not(unix))]
let lossy = path.as_os_str().to_string_lossy();
#[cfg(not(unix))]
let bytes = lossy.as_bytes();
rapidhash_v3(bytes)
}
/// Compute the shard index for a path based on its parent directory.
///
/// Files in the same directory will always be assigned to the same shard,
/// which avoids lock contention when creating parent directories.
pub(crate) fn shard_for_path(path: &Path, num_shards: usize) -> usize {
let parent = path.parent().unwrap_or(path);
(rapidhash_path(parent) as usize) % num_shards
}
/// Deterministic 16-hex-char (full 64-bit) hash of a path's full bytes.
///
/// Disambiguates same-basename worktrees that share a basename-derived key (btrfs
/// snapshot name, worktree DB id). Full 64 bits keep a collision astronomically
/// unlikely.
#[cfg(any(target_os = "linux", feature = "metadata"))]
pub(crate) fn short_path_hash(path: &Path) -> String {
format!("{:016x}", rapidhash_path(path))
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn test_same_directory_same_shard() {
let file1 = PathBuf::from("src/foo.rs");
let file2 = PathBuf::from("src/bar.rs");
let file3 = PathBuf::from("src/baz.rs");
let num_shards = 8;
let shard1 = shard_for_path(&file1, num_shards);
let shard2 = shard_for_path(&file2, num_shards);
let shard3 = shard_for_path(&file3, num_shards);
// All files in src/ should go to the same shard
assert_eq!(shard1, shard2);
assert_eq!(shard2, shard3);
}
#[test]
fn test_different_directories_may_differ() {
let file1 = PathBuf::from("src/foo.rs");
let file2 = PathBuf::from("tests/foo.rs");
let num_shards = 8;
// Different directories may (but don't have to) produce different shards
let _shard1 = shard_for_path(&file1, num_shards);
let _shard2 = shard_for_path(&file2, num_shards);
// Just verify it doesn't panic
}
#[test]
fn test_shard_in_range() {
let path = PathBuf::from("some/deep/nested/path/file.txt");
for num_shards in 1..=16 {
let shard = shard_for_path(&path, num_shards);
assert!(shard < num_shards);
}
}
#[test]
fn test_root_file() {
let path = PathBuf::from("file.txt");
let shard = shard_for_path(&path, 8);
assert!(shard < 8);
}
}

View file

@ -0,0 +1,112 @@
//! Skip logic for copy operations (gitignore + additional patterns).
use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::Result;
use dashmap::DashSet;
use ignore::{WalkBuilder, WalkState};
/// Build a globset matcher for skip patterns.
pub(crate) fn build_skip_matcher(patterns: &[String]) -> Result<globset::GlobSet> {
let mut builder = globset::GlobSetBuilder::new();
for pattern in patterns {
builder.add(globset::Glob::new(pattern)?);
}
Ok(builder.build()?)
}
/// Collect all *unignored* paths in `source` (relative).
///
/// This is used to implement an "ignored-only" copy: by collecting unignored paths
/// and then skipping them during a second pass with `respect_gitignore=false`.
pub(crate) fn collect_unignored_paths(
source: &Path,
parallelism: usize,
) -> Result<DashSet<PathBuf>> {
let unignored: Arc<DashSet<PathBuf>> = Arc::new(DashSet::new());
// git_exclude/git_global off: external tools append broad patterns (e.g.
// *.zip) to `.git/info/exclude`; the `ignore` crate would then drop matching
// TRACKED files from the unignored set, so the ignored-copy clobbers them.
let walker = WalkBuilder::new(source)
.hidden(false)
.git_ignore(true)
.git_global(false)
.git_exclude(false)
.filter_entry(|entry| entry.file_name() != ".git")
.threads(parallelism)
.build_parallel();
walker.run(|| {
let unignored = Arc::clone(&unignored);
Box::new(move |entry_result| {
let entry = match entry_result {
Ok(e) => e,
Err(_) => return WalkState::Continue,
};
let rel_path = match entry.path().strip_prefix(source) {
Ok(p) => p.to_path_buf(),
Err(_) => return WalkState::Continue,
};
unignored.insert(rel_path);
WalkState::Continue
})
});
Ok(match Arc::try_unwrap(unignored) {
Ok(set) => set,
Err(arc) => {
let mut set = DashSet::new();
set.extend(arc.iter().map(|p| p.clone()));
set
}
})
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
use xai_test_utils::git::{git_commit_all, init_git_repo};
#[test]
fn collect_unignored_includes_tracked_file_matching_git_exclude() {
xai_test_utils::require_git!();
// A tracked file matching `.git/info/exclude` must stay "unignored" so
// the ignored-copy doesn't re-copy and clobber it.
let temp = TempDir::new().unwrap();
let repo = temp.path();
init_git_repo(repo);
std::fs::write(repo.join("data.zip"), "tracked-archive").unwrap();
std::fs::write(repo.join("main.rs"), "fn main() {}").unwrap();
std::fs::write(repo.join(".gitignore"), "build/\n").unwrap();
git_commit_all(repo, "initial");
// External tooling appends broad patterns here (e.g., *.min.js, *.zip).
// `git init` does not always create `.git/info/` (the hermetic git on
// arm64 CI ships no init template), so create it before writing.
let info_dir = repo.join(".git").join("info");
std::fs::create_dir_all(&info_dir).unwrap();
std::fs::write(info_dir.join("exclude"), "*.zip\n").unwrap();
// A truly-ignored (gitignored, untracked) artifact.
std::fs::create_dir(repo.join("build")).unwrap();
std::fs::write(repo.join("build/out.o"), "obj").unwrap();
let unignored = collect_unignored_paths(repo, 1).unwrap();
assert!(
unignored.contains(&PathBuf::from("data.zip")),
"tracked file matching .git/info/exclude must be classed unignored"
);
assert!(unignored.contains(&PathBuf::from("main.rs")));
assert!(
!unignored.contains(&PathBuf::from("build/out.o")),
"a real .gitignore'd file must remain ignored (not unignored)"
);
}
}

View file

@ -0,0 +1,77 @@
//! Shared types for copy operations.
use std::fs::Metadata;
use std::path::PathBuf;
use std::sync::Arc;
use dashmap::{DashMap, DashSet};
/// A structured report about dirty (modified/untracked/deleted) files in the source worktree.
#[derive(Clone, Debug, Default)]
pub struct DirtyFilesReport {
pub modified_files: u64,
pub untracked_files: u64,
pub deleted_files: u64,
}
/// Statistics from a copy operation.
#[derive(Clone, Debug, Default)]
pub struct CopyStats {
pub files_copied: u64,
pub dirs_created: u64,
pub symlinks_copied: u64,
pub files_skipped: u64,
/// Non-fatal issues encountered while copying.
pub issues: Vec<String>,
}
impl CopyStats {
/// Merge another stats into this one.
pub fn merge(&mut self, other: CopyStats) {
self.files_copied += other.files_copied;
self.dirs_created += other.dirs_created;
self.symlinks_copied += other.symlinks_copied;
self.files_skipped += other.files_skipped;
self.issues.extend(other.issues);
}
}
/// Kind of filesystem entry to replicate.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum CopyEntryKind {
File,
Dir,
Symlink,
}
/// Entry to be processed by a worker.
#[derive(Debug)]
pub(crate) struct CopyEntry {
pub(crate) rel_path: PathBuf,
pub(crate) kind: CopyEntryKind,
}
/// Configuration for the parallel copy operation.
#[derive(Clone, Debug, Default)]
pub(crate) struct ParallelCopyConfig {
/// Number of parallel workers (0 = num_cpus)
pub num_workers: usize,
/// Channel buffer size per shard
pub channel_buffer: usize,
/// Files to skip (relative paths)
pub skip_files: Option<Arc<DashSet<PathBuf>>>,
/// Whether to respect `.gitignore` rules
pub respect_gitignore: bool,
/// Additional patterns to skip (glob patterns)
pub skip_patterns: Vec<String>,
}
/// Result of a parallel copy operation, including stats and the set of copied paths.
#[derive(Clone, Debug, Default)]
pub(crate) struct ParallelCopyResult {
pub stats: CopyStats,
/// All relative paths that were successfully copied (for deduplication in subsequent copies).
pub copied_paths: DashSet<PathBuf>,
/// Metadata for files that were copied (for index updates). Only regular files, not symlinks/dirs.
pub file_metadata: DashMap<PathBuf, Metadata>,
}

View file

@ -0,0 +1,135 @@
//! Worker logic for replicating a single filesystem entry.
use std::collections::HashSet;
use std::fs::Metadata;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use dashmap::{DashMap, DashSet};
use crate::copy::cow;
use crate::copy::types::{CopyEntry, CopyEntryKind};
pub(crate) struct WorkerCtx {
pub source: PathBuf,
pub dest: PathBuf,
pub files_copied: Arc<AtomicU64>,
pub dirs_created: Arc<AtomicU64>,
pub symlinks_copied: Arc<AtomicU64>,
pub issues: Arc<std::sync::Mutex<Vec<String>>>,
pub copied_paths: Arc<DashSet<PathBuf>>,
pub file_metadata: Arc<DashMap<PathBuf, Metadata>>,
}
pub(crate) fn run_worker(rx: crossbeam::channel::Receiver<CopyEntry>, ctx: WorkerCtx) {
// Track created directories to avoid redundant mkdir calls.
let mut created_dirs: HashSet<PathBuf> = HashSet::new();
for entry in rx {
let src = ctx.source.join(&entry.rel_path);
let dst = ctx.dest.join(&entry.rel_path);
let success = process_entry(
&entry,
&src,
&dst,
&mut created_dirs,
&ctx.files_copied,
&ctx.dirs_created,
&ctx.symlinks_copied,
&ctx.issues,
&ctx.file_metadata,
);
if success {
ctx.copied_paths.insert(entry.rel_path);
}
}
}
fn process_entry(
entry: &CopyEntry,
src: &Path,
dst: &Path,
created_dirs: &mut HashSet<PathBuf>,
files_copied: &AtomicU64,
dirs_created: &AtomicU64,
symlinks_copied: &AtomicU64,
issues: &std::sync::Mutex<Vec<String>>,
file_metadata: &DashMap<PathBuf, Metadata>,
) -> bool {
// Ensure parent directory exists.
if let Some(parent) = dst.parent()
&& !parent.as_os_str().is_empty()
&& created_dirs.insert(parent.to_path_buf())
&& let Err(e) = std::fs::create_dir_all(parent)
&& e.kind() != std::io::ErrorKind::AlreadyExists
{
issues
.lock()
.unwrap()
.push(format!("mkdir {}: {}", parent.display(), e));
return false;
}
match entry.kind {
CopyEntryKind::Dir => match std::fs::create_dir_all(dst) {
Ok(()) => {
dirs_created.fetch_add(1, Ordering::Relaxed);
true
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => true,
Err(e) => {
issues
.lock()
.unwrap()
.push(format!("mkdir {}: {}", entry.rel_path.display(), e));
false
}
},
CopyEntryKind::Symlink => match std::fs::read_link(src) {
Ok(target) => match cow::replace_symlink(&target, dst) {
Ok(()) => {
symlinks_copied.fetch_add(1, Ordering::Relaxed);
true
}
Err(e) => {
issues.lock().unwrap().push(format!(
"symlink {}: {}",
entry.rel_path.display(),
e
));
false
}
},
Err(e) => {
issues.lock().unwrap().push(format!(
"read_link {}: {}",
entry.rel_path.display(),
e
));
false
}
},
CopyEntryKind::File => match cow::clone_file(src, dst) {
Ok(()) => {
files_copied.fetch_add(1, Ordering::Relaxed);
// Collect file metadata for index updates
if let Ok(metadata) = std::fs::metadata(dst) {
file_metadata.insert(entry.rel_path.clone(), metadata);
}
true
}
Err(e) => {
issues
.lock()
.unwrap()
.push(format!("copy {}: {}", entry.rel_path.display(), e));
false
}
},
}
}

View file

@ -0,0 +1,413 @@
//! SQLite-backed metadata database for tracking worktrees.
//!
//! Gated behind the `metadata` cargo feature. When disabled, all DB operations
//! compile away to no-ops.
mod queries;
mod schema;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{Context, Result};
use rusqlite::Connection;
use serde::{Deserialize, Serialize};
use xai_sqlite_journal::JournalMode;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum WorktreeKind {
Session,
Ab,
Pool,
Fork,
Manual,
Subagent,
}
impl WorktreeKind {
pub fn as_str(self) -> &'static str {
match self {
Self::Session => "session",
Self::Ab => "ab",
Self::Pool => "pool",
Self::Fork => "fork",
Self::Manual => "manual",
Self::Subagent => "subagent",
}
}
pub fn from_str_lossy(s: &str) -> Self {
match s {
"session" => Self::Session,
"ab" => Self::Ab,
"pool" => Self::Pool,
"fork" => Self::Fork,
"manual" => Self::Manual,
"subagent" => Self::Subagent,
_ => Self::Manual,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum WorktreeStatus {
Alive,
Dead,
}
impl WorktreeStatus {
pub fn as_str(self) -> &'static str {
match self {
Self::Alive => "alive",
Self::Dead => "dead",
}
}
pub fn from_str_lossy(s: &str) -> Self {
match s {
"alive" => Self::Alive,
"dead" => Self::Dead,
_ => Self::Dead,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct WorktreeRecord {
pub id: String,
pub path: PathBuf,
pub source_repo: PathBuf,
pub repo_name: String,
pub kind: WorktreeKind,
pub creation_mode: String,
pub git_ref: Option<String>,
pub head_commit: Option<String>,
pub session_id: Option<String>,
pub creator_pid: Option<u32>,
pub created_at: i64,
pub last_accessed_at: Option<i64>,
pub status: WorktreeStatus,
pub metadata: Option<serde_json::Value>,
}
#[derive(Default)]
pub struct ListFilter {
pub repo_name: Option<String>,
pub source_repo: Option<PathBuf>,
pub kind: Option<WorktreeKind>,
pub status: Option<WorktreeStatus>,
pub include_dead: bool,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct DbStats {
pub total_records: u64,
pub alive_count: u64,
pub dead_count: u64,
pub db_file_bytes: u64,
}
pub struct WorktreeDb {
conn: Connection,
}
impl WorktreeDb {
/// Open (or create) the DB at `grok_home/worktrees.db`.
pub fn open(grok_home: &Path) -> Result<Self> {
Self::open_at(&grok_home.join("worktrees.db"))
}
/// Open with an explicit path.
pub fn open_at(path: &Path) -> Result<Self> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create dir for DB: {}", parent.display()))?;
}
// The mode decision statfs's the parent dir created above.
Self::open_at_with_journal_mode(path, JournalMode::for_db_path(path))
}
/// Open with an explicit journal mode — the seam tests use to exercise
/// the network-filesystem decision on a local disk.
fn open_at_with_journal_mode(path: &Path, journal_mode: JournalMode) -> Result<Self> {
// Per-host sibling on network mounts (see JournalMode::effective_db_path).
let path = journal_mode.effective_db_path(path);
let conn = Connection::open(&path)
.with_context(|| format!("failed to open worktree DB: {}", path.display()))?;
let db = Self { conn };
db.set_journal_mode(journal_mode)?;
// Normal statement timeout, now that the conversion budget is done.
db.conn
.busy_timeout(std::time::Duration::from_millis(5000))?;
db.init_schema()?;
Ok(db)
}
/// Put the database in `mode`'s journal mode, retrying on `SQLITE_BUSY`
/// under one absolute deadline (~10s total).
///
/// Conversion-lock acquisition only partially honors `busy_timeout` (see
/// `JournalMode::apply`, the single source of truth): a second process
/// opening the same file at the same instant can still get `SQLITE_BUSY`
/// immediately. Without a retry that opener's `open_at` fails, and callers
/// like `register_worktree`/`unregister_worktree` swallow the error
/// (best-effort) — silently dropping worktree tracking, exactly what this DB
/// exists to prevent. A bounded retry rides out the concurrent converter
/// (which finishes in microseconds), while the deadline plus a per-attempt
/// `busy_timeout` cap keeps a held legacy lock from stalling startup by
/// `attempts x busy_timeout`. Once converted the setting persists (WAL) or
/// re-applies as a no-op (TRUNCATE), so later opens are cheap.
fn set_journal_mode(&self, mode: JournalMode) -> Result<()> {
use rusqlite::ErrorCode;
use std::time::{Duration, Instant};
// Total conversion budget; each attempt waits at most 1s for locks.
const DEADLINE: Duration = Duration::from_secs(10);
let start = Instant::now();
let mut last_err = None;
loop {
let remaining = DEADLINE.saturating_sub(start.elapsed());
if remaining.is_zero() {
break;
}
self.conn
.busy_timeout(remaining.min(Duration::from_millis(1000)))?;
match mode.apply(&self.conn) {
Ok(()) => return Ok(()),
Err(e) => {
let busy = matches!(
&e,
rusqlite::Error::SqliteFailure(f, _)
if matches!(f.code, ErrorCode::DatabaseBusy | ErrorCode::DatabaseLocked)
);
if !busy {
return Err(e).with_context(|| {
format!("failed to set journal mode {}", mode.as_str())
});
}
last_err = Some(e);
// Brief pause so fail-fast busy errors don't spin hot.
std::thread::sleep(Duration::from_millis(20));
}
}
}
Err(last_err.expect("deadline allows at least one attempt")).with_context(|| {
format!(
"failed to set journal mode {} (database busy after {:?})",
mode.as_str(),
start.elapsed()
)
})
}
/// Open the default DB at `~/.grok/worktrees.db`.
///
/// Discovers grok home via `$GROK_HOME`, falling back to the canonicalized
/// `$HOME/.grok` (matching `xai_grok_config::grok_home`).
/// Path is resolved fresh each call (~1µs env var read) to support
/// test overrides. Each call opens its own connection — callers in hot
/// paths should cache the `WorktreeDb` instance.
pub fn open_default() -> Result<Self> {
Self::open(&resolve_grok_home()?)
}
/// Open an in-memory DB (for tests).
pub fn open_in_memory() -> Result<Self> {
let conn = Connection::open_in_memory().context("failed to open in-memory DB")?;
let db = Self { conn };
db.init_schema()?;
Ok(db)
}
fn init_schema(&self) -> Result<()> {
self.conn
.execute_batch(schema::INIT_SQL)
.context("failed to init worktree DB schema")?;
let stored: Option<String> = self
.conn
.query_row(schema::GET_META, ["schema_version"], |row| row.get(0))
.ok();
let needs_update = match stored {
None => true,
Some(v) => v.parse::<u32>().unwrap_or(0) < schema::SCHEMA_VERSION,
};
if needs_update {
self.conn.execute(
schema::UPSERT_META,
rusqlite::params!["schema_version", schema::SCHEMA_VERSION.to_string()],
)?;
}
Ok(())
}
pub fn register(&self, record: &WorktreeRecord) -> Result<()> {
queries::register(&self.conn, record)
}
pub fn unregister(&self, id: &str) -> Result<bool> {
queries::unregister(&self.conn, id)
}
pub fn unregister_by_path(&self, path: &Path) -> Result<bool> {
queries::unregister_by_path(&self.conn, path)
}
pub fn mark_dead(&self, id: &str) -> Result<bool> {
queries::mark_dead(&self.conn, id)
}
pub fn touch(&self, id: &str) -> Result<bool> {
queries::touch(&self.conn, id)
}
/// Look up a worktree by its DB ID only (no label or path fallback).
pub fn get_by_id(&self, id: &str) -> Result<Option<WorktreeRecord>> {
queries::get_by_id(&self.conn, id)
}
/// Look up by ID, label, or path.
///
/// If `id_or_path` contains `/`, it's treated as a path (canonicalized
/// before lookup). Otherwise it's looked up first as a DB ID, then as a
/// worktree label (stored in `metadata.label`).
pub fn get(&self, id_or_path: &str) -> Result<Option<WorktreeRecord>> {
if id_or_path.contains('/') {
let canon = PathBuf::from(id_or_path);
let canon = dunce::canonicalize(&canon).unwrap_or(canon);
queries::get_by_path(&self.conn, &canon)
} else {
let by_id = queries::get_by_id(&self.conn, id_or_path)?;
if by_id.is_some() {
return Ok(by_id);
}
queries::get_by_label(&self.conn, id_or_path)
}
}
/// Look up a worktree by its label (stored in metadata JSON).
pub fn get_by_label(&self, label: &str) -> Result<Option<WorktreeRecord>> {
queries::get_by_label(&self.conn, label)
}
pub fn list(&self, filter: &ListFilter) -> Result<Vec<WorktreeRecord>> {
queries::list(&self.conn, filter)
}
pub fn stats(&self) -> Result<DbStats> {
queries::stats(&self.conn)
}
/// Mark all records whose paths no longer exist on disk as dead.
/// Returns the number of records marked.
pub fn sweep_dead(&self) -> Result<u64> {
queries::sweep_dead(&self.conn)
}
}
/// Derive a worktree ID from its destination path: `<basename>-<hash of full path>`
/// (the last component, minus any `worktree-` prefix, plus a full-path hash).
///
/// The basename alone collides across repos, and `INSERT OR REPLACE` would then evict
/// the other repo's record; hashing the full path keeps distinct worktrees distinct.
pub fn id_from_path(path: &Path) -> String {
let name = path
.file_name()
.map(|n| n.to_string_lossy())
.unwrap_or_default();
let base = name.strip_prefix("worktree-").unwrap_or(&name);
format!("{base}-{}", crate::copy::shard::short_path_hash(path))
}
/// Extract the repo name (last component) from a source repo path.
pub fn repo_name_from_path(source: &Path) -> String {
source
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "repo".to_string())
}
pub fn now_epoch_secs() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64
}
pub fn resolve_grok_home() -> Result<PathBuf> {
if let Ok(v) = std::env::var("GROK_HOME") {
return Ok(PathBuf::from(v));
}
let home = PathBuf::from(std::env::var("HOME").context("neither $GROK_HOME nor $HOME is set")?);
// Canonicalize the home dir so worktree paths share the same physical .grok
// tree as trust/hooks even when it is symlinked. The dunce canonicalization
// must stay in sync with xai_grok_config::default_grok_home();
// home resolution deliberately differs ($HOME here vs std::env::home_dir()).
Ok(dunce::canonicalize(&home).unwrap_or(home).join(".grok"))
}
/// Serializes tests that mutate the process-global `GROK_HOME` env var so they
/// don't clobber each other under `cargo test`, where tests share one process
/// (nextest isolates per-process, but the suite must also pass under `cargo test`).
#[cfg(test)]
static GROK_HOME_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// Test-only isolation for code that resolves the DB via `open_default()`.
///
/// Holds [`GROK_HOME_ENV_LOCK`] (serializing concurrent setters), points
/// `GROK_HOME` at a fresh private tmp dir, and restores the prior value on drop.
/// Use instead of hand-rolling the lock + restore guard + tmp dir per test.
///
/// `Drop` restores `GROK_HOME` before `_lock` releases, so the env is correct
/// before another waiting setter proceeds.
#[cfg(test)]
pub(crate) struct GrokHomeFixture {
_lock: std::sync::MutexGuard<'static, ()>,
prev: Option<std::ffi::OsString>,
/// The isolated grok home; pass to `WorktreeDb::open` to read the same DB
/// `open_default()` writes to.
pub home: PathBuf,
_tmp: tempfile::TempDir,
}
#[cfg(test)]
impl GrokHomeFixture {
pub(crate) fn new() -> Self {
let lock = GROK_HOME_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let tmp = tempfile::TempDir::new().unwrap();
let home = tmp.path().join("grok-home");
std::fs::create_dir_all(&home).unwrap();
// Warm up the DB (journal-mode conversion + schema) before exposing it
// via GROK_HOME, sparing the test hot loop set_journal_mode's retry
// sleeps. This open has exclusive access (nothing reaches the path
// until GROK_HOME points here); set_journal_mode's retry is the actual
// race fix.
let _ = WorktreeDb::open(&home);
let prev = std::env::var_os("GROK_HOME");
unsafe { std::env::set_var("GROK_HOME", &home) };
Self {
_lock: lock,
prev,
home,
_tmp: tmp,
}
}
}
#[cfg(test)]
impl Drop for GrokHomeFixture {
fn drop(&mut self) {
unsafe {
match self.prev.take() {
Some(p) => std::env::set_var("GROK_HOME", p),
None => std::env::remove_var("GROK_HOME"),
}
}
}
}
#[cfg(test)]
mod tests;

View file

@ -0,0 +1,231 @@
use std::path::Path;
use anyhow::{Context, Result};
use rusqlite::{Connection, params};
use super::{DbStats, ListFilter, WorktreeKind, WorktreeRecord, WorktreeStatus, now_epoch_secs};
fn row_to_record(row: &rusqlite::Row<'_>) -> rusqlite::Result<WorktreeRecord> {
let kind_str: String = row.get("kind")?;
let status_str: String = row.get("status")?;
let path_str: String = row.get("path")?;
let source_str: String = row.get("source_repo")?;
let metadata_str: Option<String> = row.get("metadata")?;
Ok(WorktreeRecord {
id: row.get("id")?,
path: path_str.into(),
source_repo: source_str.into(),
repo_name: row.get("repo_name")?,
kind: WorktreeKind::from_str_lossy(&kind_str),
creation_mode: row.get("creation_mode")?,
git_ref: row.get("git_ref")?,
head_commit: row.get("head_commit")?,
session_id: row.get("session_id")?,
creator_pid: row.get::<_, Option<i64>>("creator_pid")?.map(|v| v as u32),
created_at: row.get("created_at")?,
last_accessed_at: row.get("last_accessed_at")?,
status: WorktreeStatus::from_str_lossy(&status_str),
metadata: metadata_str.and_then(|s| serde_json::from_str(&s).ok()),
})
}
pub fn register(conn: &Connection, record: &WorktreeRecord) -> Result<()> {
let path_str = record.path.to_string_lossy();
let source_str = record.source_repo.to_string_lossy();
let metadata_str = record
.metadata
.as_ref()
.and_then(|v| serde_json::to_string(v).ok());
conn.execute(
"INSERT OR REPLACE INTO worktrees \
(id, path, source_repo, repo_name, kind, creation_mode, git_ref, \
head_commit, session_id, creator_pid, created_at, last_accessed_at, \
status, metadata) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
params![
record.id,
path_str.as_ref(),
source_str.as_ref(),
record.repo_name,
record.kind.as_str(),
record.creation_mode,
record.git_ref,
record.head_commit,
record.session_id,
record.creator_pid.map(|p| p as i64),
record.created_at,
record.last_accessed_at,
record.status.as_str(),
metadata_str,
],
)
.context("failed to register worktree")?;
Ok(())
}
pub fn unregister(conn: &Connection, id: &str) -> Result<bool> {
let affected = conn
.execute("DELETE FROM worktrees WHERE id = ?1", params![id])
.context("failed to unregister worktree")?;
Ok(affected > 0)
}
pub fn unregister_by_path(conn: &Connection, path: &Path) -> Result<bool> {
let path_str = path.to_string_lossy();
let affected = conn
.execute(
"DELETE FROM worktrees WHERE path = ?1",
params![path_str.as_ref()],
)
.context("failed to unregister worktree by path")?;
Ok(affected > 0)
}
pub fn mark_dead(conn: &Connection, id: &str) -> Result<bool> {
let affected = conn
.execute(
"UPDATE worktrees SET status = 'dead' WHERE id = ?1",
params![id],
)
.context("failed to mark worktree dead")?;
Ok(affected > 0)
}
pub fn touch(conn: &Connection, id: &str) -> Result<bool> {
let now = now_epoch_secs();
let affected = conn
.execute(
"UPDATE worktrees SET last_accessed_at = ?1 WHERE id = ?2",
params![now, id],
)
.context("failed to touch worktree")?;
Ok(affected > 0)
}
fn get_one(conn: &Connection, sql: &str, param: &str) -> Result<Option<WorktreeRecord>> {
let mut stmt = conn.prepare(sql)?;
let mut rows = stmt.query_map(params![param], row_to_record)?;
match rows.next() {
Some(Ok(record)) => Ok(Some(record)),
Some(Err(e)) => Err(e.into()),
None => Ok(None),
}
}
pub fn get_by_id(conn: &Connection, id: &str) -> Result<Option<WorktreeRecord>> {
get_one(conn, "SELECT * FROM worktrees WHERE id = ?1", id)
}
pub fn get_by_label(conn: &Connection, label: &str) -> Result<Option<WorktreeRecord>> {
get_one(
conn,
"SELECT * FROM worktrees WHERE json_valid(metadata) AND json_extract(metadata, '$.label') = ?1 ORDER BY created_at DESC",
label,
)
}
pub fn get_by_path(conn: &Connection, path: &Path) -> Result<Option<WorktreeRecord>> {
get_one(
conn,
"SELECT * FROM worktrees WHERE path = ?1",
&path.to_string_lossy(),
)
}
pub fn list(conn: &Connection, filter: &ListFilter) -> Result<Vec<WorktreeRecord>> {
let mut sql = String::from("SELECT * FROM worktrees WHERE 1=1");
let mut idx = 0usize;
let status_str = filter.status.map(|s| s.as_str());
let kind_str = filter.kind.map(|k| k.as_str());
let source_repo_str = filter
.source_repo
.as_ref()
.map(|p| p.to_string_lossy().into_owned());
if !filter.include_dead {
sql.push_str(" AND status = 'alive'");
}
if status_str.is_some() {
idx += 1;
sql.push_str(&format!(" AND status = ?{idx}"));
}
if kind_str.is_some() {
idx += 1;
sql.push_str(&format!(" AND kind = ?{idx}"));
}
if filter.repo_name.is_some() {
idx += 1;
sql.push_str(&format!(" AND repo_name = ?{idx}"));
}
if source_repo_str.is_some() {
idx += 1;
sql.push_str(&format!(" AND source_repo = ?{idx}"));
}
sql.push_str(" ORDER BY created_at DESC");
let mut params: Vec<&dyn rusqlite::types::ToSql> = Vec::with_capacity(idx);
if let Some(ref s) = status_str {
params.push(s);
}
if let Some(ref k) = kind_str {
params.push(k);
}
if let Some(ref r) = filter.repo_name {
params.push(r);
}
if let Some(ref s) = source_repo_str {
params.push(s);
}
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(params.as_slice(), row_to_record)?;
rows.collect::<rusqlite::Result<Vec<_>>>()
.map_err(Into::into)
}
pub fn stats(conn: &Connection) -> Result<DbStats> {
let total: u64 = conn.query_row("SELECT COUNT(*) FROM worktrees", [], |row| row.get(0))?;
let alive: u64 = conn.query_row(
"SELECT COUNT(*) FROM worktrees WHERE status = 'alive'",
[],
|row| row.get(0),
)?;
let page_count: u64 = conn
.query_row("PRAGMA page_count", [], |row| row.get(0))
.unwrap_or(0);
let page_size: u64 = conn
.query_row("PRAGMA page_size", [], |row| row.get(0))
.unwrap_or(0);
Ok(DbStats {
total_records: total,
alive_count: alive,
dead_count: total.saturating_sub(alive),
db_file_bytes: page_count * page_size,
})
}
pub fn sweep_dead(conn: &Connection) -> Result<u64> {
let alive_paths: Vec<(String, String)> = {
let mut stmt = conn.prepare("SELECT id, path FROM worktrees WHERE status = 'alive'")?;
let rows = stmt.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})?;
rows.filter_map(|r| r.ok()).collect()
};
let mut marked = 0u64;
for (id, path_str) in alive_paths {
if !Path::new(&path_str).exists() {
conn.execute(
"UPDATE worktrees SET status = 'dead' WHERE id = ?1",
params![id],
)?;
marked += 1;
}
}
Ok(marked)
}

View file

@ -0,0 +1,35 @@
pub const SCHEMA_VERSION: u32 = 1;
pub const INIT_SQL: &str = r#"
PRAGMA busy_timeout = 5000;
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS worktrees (
id TEXT PRIMARY KEY,
path TEXT UNIQUE NOT NULL,
source_repo TEXT NOT NULL,
repo_name TEXT NOT NULL,
kind TEXT NOT NULL DEFAULT 'session',
creation_mode TEXT NOT NULL DEFAULT 'linked',
git_ref TEXT,
head_commit TEXT,
session_id TEXT,
creator_pid INTEGER,
created_at INTEGER NOT NULL,
last_accessed_at INTEGER,
status TEXT NOT NULL DEFAULT 'alive',
metadata TEXT
);
CREATE INDEX IF NOT EXISTS idx_worktrees_repo ON worktrees(repo_name);
CREATE INDEX IF NOT EXISTS idx_worktrees_status_kind ON worktrees(status, kind);
CREATE INDEX IF NOT EXISTS idx_worktrees_session ON worktrees(session_id);
CREATE INDEX IF NOT EXISTS idx_worktrees_created ON worktrees(created_at);
"#;
pub const UPSERT_META: &str = "INSERT OR REPLACE INTO meta(key, value) VALUES (?1, ?2)";
pub const GET_META: &str = "SELECT value FROM meta WHERE key = ?1";

View file

@ -0,0 +1,690 @@
use super::*;
fn make_record(id: &str, path: &str, kind: WorktreeKind) -> WorktreeRecord {
WorktreeRecord {
id: id.to_string(),
path: PathBuf::from(path),
source_repo: PathBuf::from("/src/repo"),
repo_name: "repo".to_string(),
kind,
creation_mode: "linked".to_string(),
git_ref: Some("main".to_string()),
head_commit: Some("abc123".to_string()),
session_id: Some(format!("sess-{id}")),
creator_pid: Some(12345),
created_at: 1000,
last_accessed_at: None,
status: WorktreeStatus::Alive,
metadata: None,
}
}
fn make_labeled_record(id: &str, path: &str, label: &str) -> WorktreeRecord {
let mut rec = make_record(id, path, WorktreeKind::Session);
rec.metadata = Some(serde_json::json!({"label": label, "user_provided": true}));
rec
}
#[test]
fn register_and_get_by_id() {
let db = WorktreeDb::open_in_memory().unwrap();
let rec = make_record("abc", "/tmp/wt-abc", WorktreeKind::Session);
db.register(&rec).unwrap();
let fetched = db.get("abc").unwrap().expect("should find by id");
assert_eq!(fetched.id, "abc");
assert_eq!(fetched.path, PathBuf::from("/tmp/wt-abc"));
assert_eq!(fetched.kind, WorktreeKind::Session);
assert_eq!(fetched.status, WorktreeStatus::Alive);
assert_eq!(fetched.creator_pid, Some(12345));
assert_eq!(fetched.git_ref.as_deref(), Some("main"));
assert_eq!(fetched.session_id.as_deref(), Some("sess-abc"));
}
#[test]
fn get_by_path() {
let db = WorktreeDb::open_in_memory().unwrap();
let rec = make_record("xyz", "/tmp/wt-xyz", WorktreeKind::Fork);
db.register(&rec).unwrap();
let fetched = db.get("/tmp/wt-xyz").unwrap().expect("should find by path");
assert_eq!(fetched.id, "xyz");
assert_eq!(fetched.kind, WorktreeKind::Fork);
}
#[test]
fn get_missing_returns_none() {
let db = WorktreeDb::open_in_memory().unwrap();
assert!(db.get("nonexistent").unwrap().is_none());
assert!(db.get("/no/such/path").unwrap().is_none());
}
#[test]
fn unregister_by_id() {
let db = WorktreeDb::open_in_memory().unwrap();
db.register(&make_record("a", "/tmp/a", WorktreeKind::Session))
.unwrap();
assert!(db.unregister("a").unwrap());
assert!(db.get("a").unwrap().is_none());
assert!(!db.unregister("a").unwrap()); // second call returns false
}
#[test]
fn unregister_by_path() {
let db = WorktreeDb::open_in_memory().unwrap();
db.register(&make_record("b", "/tmp/b", WorktreeKind::Pool))
.unwrap();
assert!(db.unregister_by_path(Path::new("/tmp/b")).unwrap());
assert!(db.get("b").unwrap().is_none());
}
#[test]
fn mark_dead_and_list_filter() {
let db = WorktreeDb::open_in_memory().unwrap();
db.register(&make_record("live", "/tmp/live", WorktreeKind::Session))
.unwrap();
db.register(&make_record("gone", "/tmp/gone", WorktreeKind::Session))
.unwrap();
db.mark_dead("gone").unwrap();
// Default filter excludes dead
let alive = db.list(&ListFilter::default()).unwrap();
assert_eq!(alive.len(), 1);
assert_eq!(alive[0].id, "live");
// include_dead shows both
let all = db
.list(&ListFilter {
include_dead: true,
..Default::default()
})
.unwrap();
assert_eq!(all.len(), 2);
let dead_rec = all.iter().find(|r| r.id == "gone").unwrap();
assert_eq!(dead_rec.status, WorktreeStatus::Dead);
}
#[test]
fn list_filter_by_kind() {
let db = WorktreeDb::open_in_memory().unwrap();
db.register(&make_record("s1", "/tmp/s1", WorktreeKind::Session))
.unwrap();
db.register(&make_record("p1", "/tmp/p1", WorktreeKind::Pool))
.unwrap();
db.register(&make_record("f1", "/tmp/f1", WorktreeKind::Fork))
.unwrap();
let sessions = db
.list(&ListFilter {
kind: Some(WorktreeKind::Session),
..Default::default()
})
.unwrap();
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0].id, "s1");
let pools = db
.list(&ListFilter {
kind: Some(WorktreeKind::Pool),
..Default::default()
})
.unwrap();
assert_eq!(pools.len(), 1);
assert_eq!(pools[0].id, "p1");
}
#[test]
fn list_filter_by_repo() {
let db = WorktreeDb::open_in_memory().unwrap();
let mut r1 = make_record("a", "/tmp/a", WorktreeKind::Session);
r1.repo_name = "myrepo".to_string();
let mut r2 = make_record("b", "/tmp/b", WorktreeKind::Session);
r2.repo_name = "other".to_string();
db.register(&r1).unwrap();
db.register(&r2).unwrap();
let matched = db
.list(&ListFilter {
repo_name: Some("myrepo".to_string()),
..Default::default()
})
.unwrap();
assert_eq!(matched.len(), 1);
assert_eq!(matched[0].id, "a");
}
#[test]
fn touch_updates_last_accessed() {
let db = WorktreeDb::open_in_memory().unwrap();
db.register(&make_record("t", "/tmp/t", WorktreeKind::Session))
.unwrap();
let before = db.get("t").unwrap().unwrap();
assert!(before.last_accessed_at.is_none());
db.touch("t").unwrap();
let after = db.get("t").unwrap().unwrap();
assert!(after.last_accessed_at.is_some());
assert!(after.last_accessed_at.unwrap() > 0);
}
#[test]
fn stats_counts() {
let db = WorktreeDb::open_in_memory().unwrap();
db.register(&make_record("a", "/tmp/a", WorktreeKind::Session))
.unwrap();
db.register(&make_record("b", "/tmp/b", WorktreeKind::Pool))
.unwrap();
db.register(&make_record("c", "/tmp/c", WorktreeKind::Fork))
.unwrap();
db.mark_dead("c").unwrap();
let stats = db.stats().unwrap();
assert_eq!(stats.total_records, 3);
assert_eq!(stats.alive_count, 2);
assert_eq!(stats.dead_count, 1);
}
#[test]
fn sweep_dead_marks_missing_paths() {
let db = WorktreeDb::open_in_memory().unwrap();
let tmp = tempfile::TempDir::new().unwrap();
let existing = tmp.path().join("exists");
std::fs::create_dir(&existing).unwrap();
db.register(&make_record(
"exists",
&existing.to_string_lossy(),
WorktreeKind::Session,
))
.unwrap();
db.register(&make_record(
"gone",
"/nonexistent/path/xyz",
WorktreeKind::Session,
))
.unwrap();
let marked = db.sweep_dead().unwrap();
assert_eq!(marked, 1);
let gone_rec = db
.list(&ListFilter {
include_dead: true,
..Default::default()
})
.unwrap()
.into_iter()
.find(|r| r.id == "gone")
.unwrap();
assert_eq!(gone_rec.status, WorktreeStatus::Dead);
let exists_rec = db.get("exists").unwrap().unwrap();
assert_eq!(exists_rec.status, WorktreeStatus::Alive);
}
#[test]
fn register_upsert_overwrites() {
let db = WorktreeDb::open_in_memory().unwrap();
let mut rec = make_record("up", "/tmp/up", WorktreeKind::Session);
db.register(&rec).unwrap();
rec.head_commit = Some("new-sha".to_string());
rec.kind = WorktreeKind::Fork;
db.register(&rec).unwrap();
let fetched = db.get("up").unwrap().unwrap();
assert_eq!(fetched.head_commit.as_deref(), Some("new-sha"));
assert_eq!(fetched.kind, WorktreeKind::Fork);
}
#[test]
fn list_ordered_by_created_at_desc() {
let db = WorktreeDb::open_in_memory().unwrap();
let mut r1 = make_record("old", "/tmp/old", WorktreeKind::Session);
r1.created_at = 100;
let mut r2 = make_record("new", "/tmp/new", WorktreeKind::Session);
r2.created_at = 200;
let mut r3 = make_record("mid", "/tmp/mid", WorktreeKind::Session);
r3.created_at = 150;
db.register(&r1).unwrap();
db.register(&r2).unwrap();
db.register(&r3).unwrap();
let all = db.list(&ListFilter::default()).unwrap();
assert_eq!(all.len(), 3);
assert_eq!(all[0].id, "new");
assert_eq!(all[1].id, "mid");
assert_eq!(all[2].id, "old");
}
#[test]
fn metadata_json_roundtrip() {
let db = WorktreeDb::open_in_memory().unwrap();
let mut rec = make_record("meta", "/tmp/meta", WorktreeKind::Session);
rec.metadata = Some(serde_json::json!({"tags": ["important"], "notes": "test"}));
db.register(&rec).unwrap();
let fetched = db.get("meta").unwrap().unwrap();
let meta = fetched.metadata.unwrap();
assert_eq!(meta["tags"][0], "important");
assert_eq!(meta["notes"], "test");
}
/// The derived id keeps the basename (minus any `worktree-` prefix) and appends
/// a 16-hex hash of the full path. Assert the shape rather than a literal hash.
fn assert_id_shape(id: &str, basename: &str) {
let hash = id
.strip_prefix(&format!("{basename}-"))
.unwrap_or_else(|| panic!("id {id:?} must keep the `{basename}-` prefix"));
assert_eq!(hash.len(), 16, "hash must be 16 hex chars: {id:?}");
assert!(
hash.bytes().all(|b| b.is_ascii_hexdigit()),
"hash must be hex: {id:?}"
);
}
#[test]
fn id_from_path_strips_worktree_prefix_and_hashes_full_path() {
let p = Path::new("/home/.grok/worktrees/myrepo/worktree-019caa03");
assert_id_shape(&id_from_path(p), "019caa03");
assert_id_shape(
&id_from_path(Path::new("/home/.grok/worktree_pool/inst/a1b2c3")),
"a1b2c3",
);
assert_id_shape(&id_from_path(Path::new("/tmp/my-worktree")), "my-worktree");
// No file name → empty basename, still suffixed with a hash.
assert!(id_from_path(Path::new("/")).starts_with('-'));
// Deterministic.
assert_eq!(id_from_path(p), id_from_path(p));
}
#[test]
fn id_from_path_differs_for_same_basename_in_different_repos() {
// The eviction bug root cause: same basename, different repo → must differ.
let a = id_from_path(Path::new("/home/.grok/worktrees/repo-a/session/wt-abc"));
let b = id_from_path(Path::new("/home/.grok/worktrees/repo-b/session/wt-abc"));
assert_ne!(
a, b,
"same-basename worktrees in different repos must get distinct ids"
);
assert_id_shape(&a, "wt-abc");
assert_id_shape(&b, "wt-abc");
}
#[test]
fn same_basename_worktrees_in_different_repos_coexist() {
// Two repos each have a `wt-abc` worktree. Registering both (the way
// discovery/register derive ids) must keep BOTH records — neither evicts
// the other via the `id` PRIMARY KEY or the `path UNIQUE` constraint.
let db = WorktreeDb::open_in_memory().unwrap();
let path_a = "/home/.grok/worktrees/repo-a/session/wt-abc";
let path_b = "/home/.grok/worktrees/repo-b/session/wt-abc";
let mut rec_a = make_record(
&id_from_path(Path::new(path_a)),
path_a,
WorktreeKind::Session,
);
rec_a.repo_name = "repo-a".into();
rec_a.source_repo = PathBuf::from("/src/repo-a");
let mut rec_b = make_record(
&id_from_path(Path::new(path_b)),
path_b,
WorktreeKind::Session,
);
rec_b.repo_name = "repo-b".into();
rec_b.source_repo = PathBuf::from("/src/repo-b");
db.register(&rec_a).unwrap();
db.register(&rec_b).unwrap();
// Both rows survive and resolve independently by id and by path.
assert_eq!(
db.list(&ListFilter::default()).unwrap().len(),
2,
"both same-basename worktrees must coexist"
);
assert_eq!(db.get(path_a).unwrap().unwrap().repo_name, "repo-a");
assert_eq!(db.get(path_b).unwrap().unwrap().repo_name, "repo-b");
assert_eq!(
db.get_by_id(&rec_a.id).unwrap().unwrap().path,
PathBuf::from(path_a)
);
assert_eq!(
db.get_by_id(&rec_b.id).unwrap().unwrap().path,
PathBuf::from(path_b)
);
// Removing one (by path) leaves the other intact.
assert!(db.unregister_by_path(Path::new(path_a)).unwrap());
assert!(db.get(path_a).unwrap().is_none());
assert_eq!(db.get(path_b).unwrap().unwrap().repo_name, "repo-b");
}
#[test]
fn repo_name_from_path_extracts_last_component() {
assert_eq!(
repo_name_from_path(Path::new("/Users/me/work/myrepo")),
"myrepo"
);
assert_eq!(repo_name_from_path(Path::new("/")), "repo");
}
#[test]
fn kind_str_roundtrip() {
for kind in [
WorktreeKind::Session,
WorktreeKind::Ab,
WorktreeKind::Pool,
WorktreeKind::Fork,
WorktreeKind::Manual,
WorktreeKind::Subagent,
] {
assert_eq!(WorktreeKind::from_str_lossy(kind.as_str()), kind);
}
assert_eq!(
WorktreeKind::from_str_lossy("garbage"),
WorktreeKind::Manual
);
}
#[test]
fn list_filter_by_source_repo() {
let db = WorktreeDb::open_in_memory().unwrap();
let mut r1 = make_record("wt-1", "/wt/1", WorktreeKind::Session);
r1.source_repo = PathBuf::from("/src/repo-A");
r1.repo_name = "repo-A".into();
db.register(&r1).unwrap();
let mut r2 = make_record("wt-2", "/wt/2", WorktreeKind::Session);
r2.source_repo = PathBuf::from("/src/repo-A");
r2.repo_name = "repo-A".into();
db.register(&r2).unwrap();
let mut r3 = make_record("wt-3", "/wt/3", WorktreeKind::Session);
r3.source_repo = PathBuf::from("/src/repo-B");
r3.repo_name = "repo-B".into();
db.register(&r3).unwrap();
// Filter by source_repo = repo-A: should get 2
let filter = ListFilter {
source_repo: Some(PathBuf::from("/src/repo-A")),
..Default::default()
};
let results = db.list(&filter).unwrap();
assert_eq!(results.len(), 2);
assert!(
results
.iter()
.all(|r| r.source_repo == Path::new("/src/repo-A"))
);
// Filter by source_repo = repo-B: should get 1
let filter = ListFilter {
source_repo: Some(PathBuf::from("/src/repo-B")),
..Default::default()
};
let results = db.list(&filter).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].id, "wt-3");
// Filter by nonexistent source_repo: should get 0
let filter = ListFilter {
source_repo: Some(PathBuf::from("/src/nonexistent")),
..Default::default()
};
let results = db.list(&filter).unwrap();
assert!(results.is_empty());
// No source_repo filter: should get all 3
let results = db.list(&ListFilter::default()).unwrap();
assert_eq!(results.len(), 3);
}
#[test]
fn get_by_label_returns_matching_record() {
let db = WorktreeDb::open_in_memory().unwrap();
let rec = make_labeled_record("wt-abc123", "/tmp/wt-abc123", "my-feature");
db.register(&rec).unwrap();
let fetched = db
.get_by_label("my-feature")
.unwrap()
.expect("should find by label");
assert_eq!(fetched.id, "wt-abc123");
assert_eq!(fetched.path, PathBuf::from("/tmp/wt-abc123"));
}
#[test]
fn get_by_label_returns_none_for_no_match() {
let db = WorktreeDb::open_in_memory().unwrap();
let rec = make_labeled_record("wt-1", "/tmp/wt-1", "existing-label");
db.register(&rec).unwrap();
assert!(db.get_by_label("nonexistent-label").unwrap().is_none());
}
#[test]
fn get_by_label_ignores_records_without_metadata() {
let db = WorktreeDb::open_in_memory().unwrap();
let rec = make_record("wt-plain", "/tmp/wt-plain", WorktreeKind::Session);
db.register(&rec).unwrap();
assert!(db.get_by_label("wt-plain").unwrap().is_none());
}
#[test]
fn get_resolves_by_label_when_id_misses() {
let db = WorktreeDb::open_in_memory().unwrap();
let rec = make_labeled_record("wt-abc123", "/tmp/wt-abc123", "test-2");
db.register(&rec).unwrap();
// "test-2" doesn't match any ID, so it should fall back to label lookup
let fetched = db.get("test-2").unwrap().expect("should resolve by label");
assert_eq!(fetched.id, "wt-abc123");
}
#[test]
fn get_prefers_id_over_label() {
let db = WorktreeDb::open_in_memory().unwrap();
// Record whose ID is "ambiguous"
let r1 = make_record("ambiguous", "/tmp/wt-by-id", WorktreeKind::Session);
db.register(&r1).unwrap();
// Record whose label is "ambiguous"
let r2 = make_labeled_record("wt-other", "/tmp/wt-other", "ambiguous");
db.register(&r2).unwrap();
let fetched = db
.get("ambiguous")
.unwrap()
.expect("should find by id first");
assert_eq!(fetched.id, "ambiguous");
assert_eq!(fetched.path, PathBuf::from("/tmp/wt-by-id"));
}
#[test]
fn get_label_fallback_returns_none_when_both_miss() {
let db = WorktreeDb::open_in_memory().unwrap();
let rec = make_labeled_record("wt-x", "/tmp/wt-x", "some-label");
db.register(&rec).unwrap();
assert!(db.get("no-such-id-or-label").unwrap().is_none());
}
#[test]
fn get_by_label_ignores_malformed_metadata() {
let db = WorktreeDb::open_in_memory().unwrap();
let rec = make_record("wt-bad", "/tmp/wt-bad", WorktreeKind::Session);
db.register(&rec).unwrap();
// Overwrite metadata with non-JSON text via raw SQL
db.conn
.execute(
"UPDATE worktrees SET metadata = 'not json at all' WHERE id = 'wt-bad'",
[],
)
.unwrap();
assert!(db.get_by_label("not json at all").unwrap().is_none());
assert!(db.get_by_label("anything").unwrap().is_none());
}
#[test]
fn get_by_label_returns_most_recent_on_duplicate_labels() {
let db = WorktreeDb::open_in_memory().unwrap();
let mut older = make_labeled_record("wt-old", "/tmp/wt-old", "shared-label");
older.created_at = 100;
db.register(&older).unwrap();
let mut newer = make_labeled_record("wt-new", "/tmp/wt-new", "shared-label");
newer.created_at = 200;
db.register(&newer).unwrap();
let fetched = db
.get_by_label("shared-label")
.unwrap()
.expect("should find the most recent");
assert_eq!(fetched.id, "wt-new");
// Also verify via the get() fallback path
let via_get = db
.get("shared-label")
.unwrap()
.expect("should resolve via label fallback");
assert_eq!(via_get.id, "wt-new");
}
#[test]
fn concurrent_open_at_survives_wal_conversion_race() {
// Many openers hitting a FRESH db at once race the one-time WAL conversion
// (which ignores busy_timeout). set_journal_mode's retry must make every
// open succeed rather than intermittently returning Err (which callers
// swallow, silently dropping worktree tracking). Without the retry this
// flakes.
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("worktrees.db");
let handles: Vec<_> = (0..16)
.map(|_| {
let path = path.clone();
std::thread::spawn(move || WorktreeDb::open_at(&path).is_ok())
})
.collect();
for h in handles {
assert!(
h.join().unwrap(),
"concurrent open_at must not fail on the WAL conversion race"
);
}
}
fn journal_mode(db: &WorktreeDb) -> String {
db.conn
.query_row("PRAGMA journal_mode", [], |r| r.get(0))
.unwrap()
}
#[test]
fn open_at_uses_wal_on_local_fs() {
// Ambient kill-switch would override the decision; skip if set.
if std::env::var("GROK_SQLITE_JOURNAL_MODE").is_ok() {
return;
}
let tmp = tempfile::TempDir::new().unwrap();
let db = WorktreeDb::open_at(&tmp.path().join("worktrees.db")).unwrap();
assert_eq!(journal_mode(&db), "wal");
}
#[test]
fn network_mode_uses_fresh_per_host_truncate_db() {
// Network mode opens a per-host sibling of the given path (the legacy
// shared file is left untouched — a live old binary can flip it back to
// WAL at any time) in rollback-journal mode.
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("worktrees.db");
{
let db = WorktreeDb::open_at(&path).unwrap();
db.register(&make_record(
"wt-legacy",
"/tmp/wt-legacy",
WorktreeKind::Session,
))
.unwrap();
}
let db = WorktreeDb::open_at_with_journal_mode(&path, JournalMode::Truncate).unwrap();
assert_eq!(journal_mode(&db), "truncate");
// Fresh per-host DB: legacy rows are intentionally not visible.
assert!(db.get("wt-legacy").unwrap().is_none());
db.register(&make_record("wt-nfs", "/tmp/wt-nfs", WorktreeKind::Manual))
.unwrap();
assert!(db.get("wt-nfs").unwrap().is_some());
drop(db);
let eff = JournalMode::Truncate.effective_db_path(&path);
assert_ne!(eff, path);
let base = eff.display().to_string();
assert!(!std::fs::exists(format!("{base}-wal")).unwrap());
assert!(!std::fs::exists(format!("{base}-shm")).unwrap());
}
#[test]
fn journal_conversion_respects_deadline_under_contention() {
use std::time::{Duration, Instant};
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("worktrees.db");
// WAL-stamp the exact file the forced-network open will use.
let eff = JournalMode::Truncate.effective_db_path(&path);
{
let conn = rusqlite::Connection::open(&eff).unwrap();
JournalMode::Wal.apply(&conn).unwrap();
conn.execute_batch("CREATE TABLE t (v TEXT); INSERT INTO t VALUES ('x');")
.unwrap();
}
// A held WAL read transaction blocks the exclusive lock the WAL->TRUNCATE
// conversion needs, so the open must give up at the deadline instead of
// stalling for attempts x busy_timeout.
let holder = rusqlite::Connection::open(&eff).unwrap();
holder
.execute_batch("BEGIN; SELECT COUNT(*) FROM t;")
.unwrap();
let start = Instant::now();
let res = WorktreeDb::open_at_with_journal_mode(&path, JournalMode::Truncate);
let elapsed = start.elapsed();
let err = match res {
Ok(_) => panic!("conversion must fail while a WAL reader holds the DB"),
Err(e) => e,
};
assert!(
format!("{err:#}").contains("database busy after"),
"expected the deadline-busy error, got: {err:#}"
);
assert!(
elapsed < Duration::from_secs(20),
"10s budget (+slack) exceeded: {elapsed:?}"
);
// Release the reader: the same open now converts and succeeds.
holder.execute_batch("COMMIT;").unwrap();
drop(holder);
let db = WorktreeDb::open_at_with_journal_mode(&path, JournalMode::Truncate).unwrap();
assert_eq!(journal_mode(&db), "truncate");
}

View file

@ -0,0 +1,327 @@
//! Filesystem scanner for discovering worktrees not yet tracked in the DB.
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use crate::db::{
WorktreeKind, WorktreeRecord, WorktreeStatus, id_from_path, now_epoch_secs, repo_name_from_path,
};
#[derive(Debug)]
pub struct DiscoveredWorktree {
pub path: PathBuf,
pub kind: WorktreeKind,
pub creation_mode: &'static str,
pub source_repo: Option<PathBuf>,
}
#[derive(Debug, Default)]
pub struct DiscoveryReport {
pub found: Vec<DiscoveredWorktree>,
pub skipped: u64,
}
fn should_skip_entry(name: &str) -> bool {
name.starts_with('.')
|| name.ends_with(".ready")
|| name.ends_with(".claimed")
|| name.ends_with(".claiming")
}
fn detect_creation_mode(worktree_path: &Path) -> &'static str {
let git_entry = worktree_path.join(".git");
if git_entry.is_file() {
"linked"
} else if git_entry.is_dir() {
"standalone"
} else {
"unknown"
}
}
fn detect_source_repo(worktree_path: &Path) -> Option<PathBuf> {
let git_entry = worktree_path.join(".git");
if git_entry.is_file() {
let content = std::fs::read_to_string(&git_entry).ok()?;
let gitdir = content.trim().strip_prefix("gitdir: ")?;
// Walk up from .git/worktrees/<name> → .git → repo root
Path::new(gitdir)
.parent()?
.parent()?
.parent()
.map(|p| p.to_path_buf())
} else if git_entry.is_dir() {
Some(worktree_path.to_path_buf())
} else {
None
}
}
fn scan_two_level_dir(base_dir: &Path, kind: WorktreeKind, report: &mut DiscoveryReport) {
let Ok(outer_entries) = std::fs::read_dir(base_dir) else {
return;
};
for outer in outer_entries.flatten() {
let outer_path = outer.path();
if !outer_path.is_dir() {
continue;
}
let outer_name = outer.file_name();
if should_skip_entry(&outer_name.to_string_lossy()) {
report.skipped += 1;
continue;
}
let Ok(inner_entries) = std::fs::read_dir(&outer_path) else {
continue;
};
for inner in inner_entries.flatten() {
let path = inner.path();
if !path.is_dir() || should_skip_entry(&inner.file_name().to_string_lossy()) {
report.skipped += 1;
continue;
}
report.found.push(DiscoveredWorktree {
creation_mode: detect_creation_mode(&path),
source_repo: detect_source_repo(&path),
path,
kind,
});
}
}
}
pub fn discover_worktrees(grok_home: &Path) -> DiscoveryReport {
let mut report = DiscoveryReport::default();
scan_two_level_dir(
&grok_home.join("worktrees"),
WorktreeKind::Session,
&mut report,
);
scan_two_level_dir(
&grok_home.join("worktree_pool"),
WorktreeKind::Pool,
&mut report,
);
report
}
fn fs_creation_time(path: &Path) -> i64 {
std::fs::metadata(path)
.and_then(|m| m.created())
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs() as i64)
.unwrap_or_else(now_epoch_secs)
}
impl DiscoveredWorktree {
pub fn into_record(self) -> WorktreeRecord {
let repo_name = self
.source_repo
.as_deref()
.map(repo_name_from_path)
.unwrap_or_else(|| "unknown".to_string());
let source_repo = self.source_repo.unwrap_or_else(|| PathBuf::from("unknown"));
let created_at = fs_creation_time(&self.path);
WorktreeRecord {
id: id_from_path(&self.path),
path: self.path,
source_repo,
repo_name,
kind: self.kind,
creation_mode: self.creation_mode.to_owned(),
git_ref: None,
head_commit: None,
session_id: None,
creator_pid: None,
created_at,
last_accessed_at: None,
status: WorktreeStatus::Alive,
metadata: None,
}
}
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct RebuildReport {
pub discovered: u64,
pub registered: u64,
pub already_tracked: u64,
}
pub fn rebuild_worktree_db(
db: &crate::db::WorktreeDb,
grok_home: &Path,
) -> anyhow::Result<RebuildReport> {
let discovery = discover_worktrees(grok_home);
let mut report = RebuildReport {
discovered: discovery.found.len() as u64,
..Default::default()
};
for wt in discovery.found {
let id = id_from_path(&wt.path);
let path_str = wt.path.to_string_lossy();
if db.get_by_id(&id)?.is_some() || db.get(&path_str)?.is_some() {
report.already_tracked += 1;
continue;
}
db.register(&wt.into_record())?;
report.registered += 1;
}
Ok(report)
}
#[cfg(test)]
mod tests {
use super::*;
fn make_fake_linked_worktree(path: &Path, gitdir_target: &str) {
std::fs::create_dir_all(path).unwrap();
std::fs::write(path.join(".git"), format!("gitdir: {gitdir_target}\n")).unwrap();
}
fn make_fake_standalone_worktree(path: &Path) {
std::fs::create_dir_all(path.join(".git")).unwrap();
}
#[test]
fn discover_session_worktrees() {
let tmp = tempfile::TempDir::new().unwrap();
let grok_home = tmp.path();
let wt = grok_home.join("worktrees/myrepo/worktree-abc123");
make_fake_linked_worktree(&wt, "/repo/.git/worktrees/abc123");
let report = discover_worktrees(grok_home);
assert_eq!(report.found.len(), 1);
assert_eq!(report.found[0].kind, WorktreeKind::Session);
assert_eq!(report.found[0].creation_mode, "linked");
assert_eq!(report.found[0].path, wt);
}
#[test]
fn discover_pool_worktrees() {
let tmp = tempfile::TempDir::new().unwrap();
let grok_home = tmp.path();
let wt = grok_home.join("worktree_pool/inst-1/pool-a");
make_fake_standalone_worktree(&wt);
let report = discover_worktrees(grok_home);
assert_eq!(report.found.len(), 1);
assert_eq!(report.found[0].kind, WorktreeKind::Pool);
assert_eq!(report.found[0].creation_mode, "standalone");
}
#[test]
fn skips_dot_prefixed_and_markers() {
let tmp = tempfile::TempDir::new().unwrap();
let grok_home = tmp.path();
let base = grok_home.join("worktrees/myrepo");
std::fs::create_dir_all(&base).unwrap();
std::fs::create_dir_all(base.join(".tmp_creating")).unwrap();
std::fs::create_dir_all(base.join(".hidden")).unwrap();
std::fs::write(base.join("abc.ready"), "").unwrap();
std::fs::write(base.join("abc.claimed"), "").unwrap();
make_fake_standalone_worktree(&base.join("real-session"));
let report = discover_worktrees(grok_home);
assert_eq!(report.found.len(), 1);
assert_eq!(report.found[0].path, base.join("real-session"));
assert!(report.skipped > 0);
}
#[test]
fn discover_empty_dirs_is_fine() {
let tmp = tempfile::TempDir::new().unwrap();
let report = discover_worktrees(tmp.path());
assert!(report.found.is_empty());
assert_eq!(report.skipped, 0);
}
#[test]
fn rebuild_registers_and_skips_duplicates() {
let tmp = tempfile::TempDir::new().unwrap();
let grok_home = tmp.path();
let wt = grok_home.join("worktrees/repo/worktree-sess1");
make_fake_standalone_worktree(&wt);
let db = crate::db::WorktreeDb::open_in_memory().unwrap();
let r1 = rebuild_worktree_db(&db, grok_home).unwrap();
assert_eq!(r1.discovered, 1);
assert_eq!(r1.registered, 1);
assert_eq!(r1.already_tracked, 0);
let r2 = rebuild_worktree_db(&db, grok_home).unwrap();
assert_eq!(r2.discovered, 1);
assert_eq!(r2.registered, 0);
assert_eq!(r2.already_tracked, 1);
}
#[test]
fn rebuild_keeps_same_basename_worktrees_in_different_repos() {
// The cross-repo eviction bug: two repos each have a `wt-abc`
// worktree. Discovery + rebuild must register BOTH (distinct ids), not
// collapse them into one and then permanently skip the other.
let tmp = tempfile::TempDir::new().unwrap();
let grok_home = tmp.path();
let wt_a = grok_home.join("worktrees/repo-a/wt-abc");
let wt_b = grok_home.join("worktrees/repo-b/wt-abc");
make_fake_standalone_worktree(&wt_a);
make_fake_standalone_worktree(&wt_b);
let db = crate::db::WorktreeDb::open_in_memory().unwrap();
let report = rebuild_worktree_db(&db, grok_home).unwrap();
assert_eq!(report.discovered, 2);
assert_eq!(
report.registered, 2,
"both same-basename worktrees must register"
);
let all = db.list(&crate::db::ListFilter::default()).unwrap();
assert_eq!(all.len(), 2);
assert!(db.get(&wt_a.to_string_lossy()).unwrap().is_some());
assert!(db.get(&wt_b.to_string_lossy()).unwrap().is_some());
// Idempotent: a second rebuild finds both already tracked, skips neither.
let report2 = rebuild_worktree_db(&db, grok_home).unwrap();
assert_eq!(report2.registered, 0);
assert_eq!(report2.already_tracked, 2);
}
#[test]
fn detect_source_repo_from_linked() {
let tmp = tempfile::TempDir::new().unwrap();
let wt = tmp.path().join("wt");
let gitdir = "/home/user/myrepo/.git/worktrees/wt";
make_fake_linked_worktree(&wt, gitdir);
let source = detect_source_repo(&wt);
assert_eq!(source, Some(PathBuf::from("/home/user/myrepo")));
}
#[test]
fn rebuild_report_serde_round_trip() {
let report = RebuildReport {
discovered: 5,
registered: 3,
already_tracked: 2,
};
let json = serde_json::to_string(&report).unwrap();
let deser: RebuildReport = serde_json::from_str(&json).unwrap();
assert_eq!(deser.discovered, 5);
assert_eq!(deser.registered, 3);
assert_eq!(deser.already_tracked, 2);
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,151 @@
//! Repository/worktree discovery helpers.
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
/// Find the git directory for a path using gix (handles both repos and worktrees).
///
/// For a regular repo, returns the `.git` directory. For a linked worktree,
/// returns the worktree's git dir under `.git/worktrees/<name>`.
///
/// Note: currently unused in production code — retained for future use and
/// tested below. See `find_worktree_git_dir` for the version used by
/// `copy_git_index`.
#[allow(dead_code)]
pub(crate) fn find_git_dir(path: &Path) -> Result<PathBuf> {
let repo = gix::discover(path)
.with_context(|| format!("failed to discover git repo at {}", path.display()))?;
Ok(repo.git_dir().to_path_buf())
}
/// Find the worktree's git directory from its `.git` file.
///
/// Worktrees have a `.git` file (not directory) that points to the actual git dir.
/// For regular repos, returns the `.git` directory.
pub(crate) fn find_worktree_git_dir(worktree_path: &Path) -> Result<PathBuf> {
let git_path = worktree_path.join(".git");
if git_path.is_file() {
// Worktree: .git is a file containing "gitdir: <path>"
let content = std::fs::read_to_string(&git_path)
.with_context(|| format!("failed to read .git file at {}", git_path.display()))?;
let raw = content
.strip_prefix("gitdir: ")
.ok_or_else(|| anyhow::anyhow!("invalid .git file format: {}", content.trim()))?
.trim();
// git may write a RELATIVE pointer (worktrees added with a relative
// path). Resolve it against the worktree dir — otherwise downstream
// index lookups join it against the CWD and break (mirrors
// `read_worktree_gitdir` in api.rs).
let raw_path = Path::new(raw);
let resolved = if raw_path.is_relative() {
worktree_path.join(raw_path)
} else {
raw_path.to_path_buf()
};
Ok(dunce::canonicalize(&resolved).unwrap_or(resolved))
} else if git_path.is_dir() {
// Regular repository
Ok(git_path)
} else {
anyhow::bail!(
"no .git file or directory found at {}",
worktree_path.display()
)
}
}
/// Find the worktree root (working directory root) for a path.
///
/// This handles both regular repositories and worktrees correctly.
/// For a regular repo at `/repo`, returns `/repo`.
/// For a worktree at `/worktrees/wt1`, returns `/worktrees/wt1`.
/// For a subdirectory `/repo/subdir`, returns `/repo`.
pub(crate) fn find_worktree_root(path: &Path) -> Result<PathBuf> {
let repo = gix::discover(path)
.with_context(|| format!("failed to discover git repo at {}", path.display()))?;
// workdir() returns the working directory root for both repos and worktrees
let work_dir = repo
.workdir()
.ok_or_else(|| anyhow::anyhow!("bare repository has no working directory"))?;
Ok(work_dir.to_path_buf())
}
/// Get the HEAD commit hash using gix.
pub(crate) fn get_head_commit(path: &Path) -> Result<String> {
let repo = gix::discover(path)
.with_context(|| format!("failed to discover git repo at {}", path.display()))?;
let head = repo
.head()
.context("failed to get HEAD")?
.peel_to_commit()
.context("failed to peel HEAD to commit")?;
Ok(head.id().to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
use xai_test_utils::git::{git_commit_all, init_git_repo};
#[test]
fn test_find_git_dir() {
xai_test_utils::require_git!();
let temp = TempDir::new().unwrap();
init_git_repo(temp.path());
let git_dir = find_git_dir(temp.path()).unwrap();
assert!(git_dir.ends_with(".git"));
assert!(git_dir.is_dir());
}
#[test]
fn test_find_worktree_git_dir_resolves_relative_gitdir() {
// A worktree `.git` file can hold a RELATIVE `gitdir:` pointer; it must
// resolve against the worktree dir, not be returned as-is (which would
// break index copy for relative-gitdir worktrees).
let temp = TempDir::new().unwrap();
let worktree = temp.path().join("wt");
std::fs::create_dir_all(&worktree).unwrap();
let real_git = temp.path().join("repo/.git/worktrees/wt");
std::fs::create_dir_all(&real_git).unwrap();
std::fs::write(worktree.join(".git"), "gitdir: ../repo/.git/worktrees/wt\n").unwrap();
let resolved = find_worktree_git_dir(&worktree).unwrap();
assert_eq!(resolved, dunce::canonicalize(&real_git).unwrap());
}
#[test]
fn test_find_worktree_git_dir_regular_repo() {
xai_test_utils::require_git!();
let temp = TempDir::new().unwrap();
init_git_repo(temp.path());
let git_dir = find_worktree_git_dir(temp.path()).unwrap();
assert!(git_dir.ends_with(".git"));
}
#[test]
fn test_get_head_commit() {
xai_test_utils::require_git!();
let temp = TempDir::new().unwrap();
init_git_repo(temp.path());
// Create a commit
std::fs::write(temp.path().join("file.txt"), "content").unwrap();
git_commit_all(temp.path(), "initial");
let commit = get_head_commit(temp.path()).unwrap();
assert_eq!(commit.len(), 40); // SHA-1 hex string
assert!(commit.chars().all(|c| c.is_ascii_hexdigit()));
}
}

View file

@ -0,0 +1,307 @@
//! Git index operations used during worktree creation.
use std::fs::Metadata;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use crate::copy::cow::clone_file;
use crate::git::discovery::find_worktree_git_dir;
/// Copy the git index from source to destination worktree.
///
/// Resolves the actual git directory for both sides, handling linked worktrees
/// where `.git` is a file pointing to the real git dir. Both sides use
/// `find_worktree_git_dir` for consistency: for a regular repo it returns
/// `.git/`, for a linked worktree it follows the `gitdir:` pointer.
///
/// Uses CoW (reflink) copy for efficiency on APFS/Btrfs.
///
/// Returns `true` if the index was actually copied, `false` if the source
/// has no index file.
pub(crate) fn copy_git_index(source: &Path, dest_worktree: &Path) -> Result<bool> {
let source_git_dir = find_worktree_git_dir(source)?;
let dest_git_dir = find_worktree_git_dir(dest_worktree)?;
let source_index = source_git_dir.join("index");
let dest_index = dest_git_dir.join("index");
if source_index.exists() {
// reflink_or_copy cannot overwrite — remove destination first
if dest_index.exists() {
let _ = std::fs::remove_file(&dest_index);
}
clone_file(&source_index, &dest_index).with_context(|| {
format!(
"failed to copy index from {} to {}",
source_index.display(),
dest_index.display()
)
})?;
// Handle split index: when core.splitIndex is enabled, the index
// file references a `sharedindex.<hash>` file that must be
// reachable from the same directory as the index. For linked
// worktrees the shared index lives in the common git dir (the
// main repo's `.git/`), not in `.git/worktrees/<name>/`.
// Symlink any sharedindex.* files from the source's common dir
// into the dest git dir so gix can resolve them.
link_shared_indexes(&source_git_dir, &dest_git_dir)?;
tracing::debug!(
source = %source_index.display(),
dest = %dest_index.display(),
"copied git index (reflink)"
);
Ok(true)
} else {
Ok(false)
}
}
/// Symlink `sharedindex.*` files from the source into the destination
/// git directory.
///
/// When `core.splitIndex` is enabled, the main index file contains a
/// `link` extension referencing a content-addressed `sharedindex.<hash>`
/// file. `gix::index::File::at()` looks for this file in the **same
/// directory** as the index file. For linked worktrees the index lives
/// in `.git/worktrees/<name>/` but the shared index lives in the common
/// `.git/` directory. We bridge this by symlinking.
///
/// We scan **two** directories for shared index files:
/// 1. The source's **common dir** (main repo `.git/`) — where git
/// typically stores shared index files.
/// 2. The source's **own git dir** (`.git/worktrees/<name>/`) — git may
/// create new shared index files directly here when running inside a
/// linked worktree with `core.splitIndex` enabled.
///
/// No-op if there are no `sharedindex.*` files (i.e. split index is not
/// in use).
fn link_shared_indexes(source_git_dir: &Path, dest_git_dir: &Path) -> Result<()> {
// Resolve the common dir: for a linked worktree the `commondir` file
// points to the shared `.git/`. For a regular repo the git dir IS the
// common dir.
let source_common_dir = resolve_common_dir(source_git_dir);
// Collect directories to scan. Always include the common dir. If the
// source git dir is different (i.e. source is a linked worktree), also
// scan the source git dir itself — git may have created shared index
// files directly there.
let mut dirs_to_scan: Vec<&Path> = vec![&source_common_dir];
if source_git_dir != source_common_dir {
dirs_to_scan.push(source_git_dir);
}
let mut linked = 0u32;
for scan_dir in &dirs_to_scan {
let entries = match std::fs::read_dir(scan_dir) {
Ok(e) => e,
Err(_) => continue,
};
for entry in entries.flatten() {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if !name_str.starts_with("sharedindex.") {
continue;
}
let src = entry.path();
let dst = dest_git_dir.join(&name);
// Skip if already present (e.g. dest IS the common dir, or
// already linked from a previous scan_dir iteration).
if dst.exists() {
continue;
}
// Symlink is ideal: instant, zero-copy, shared index is
// read-only content-addressed data.
#[cfg(unix)]
{
std::os::unix::fs::symlink(&src, &dst).with_context(|| {
format!(
"failed to symlink sharedindex {} -> {}",
dst.display(),
src.display()
)
})?;
}
#[cfg(not(unix))]
{
// Fallback: reflink/copy on Windows.
clone_file(&src, &dst).with_context(|| {
format!(
"failed to copy sharedindex {} -> {}",
src.display(),
dst.display()
)
})?;
}
linked += 1;
}
}
if linked > 0 {
tracing::debug!(
source_common_dir = %source_common_dir.display(),
dest_git_dir = %dest_git_dir.display(),
linked,
"linked sharedindex files for split-index support"
);
}
Ok(())
}
/// Resolve the common git directory from a worktree git dir.
///
/// For a linked worktree, `.git/worktrees/<name>/commondir` contains a
/// relative path (typically `../..`) pointing to the shared `.git/`.
/// For a regular repo, the git dir itself is the common dir.
fn resolve_common_dir(git_dir: &Path) -> PathBuf {
let commondir_file = git_dir.join("commondir");
if let Ok(content) = std::fs::read_to_string(&commondir_file) {
let relative = content.trim();
let resolved = git_dir.join(relative);
// Canonicalize to clean up `../..` etc.
dunce::canonicalize(&resolved).unwrap_or(resolved)
} else {
git_dir.to_path_buf()
}
}
/// Update index entries with new stat information from file metadata.
///
/// This updates the stat cache (mtime, size, etc.) for files that were copied,
/// avoiding the need for a full `git update-index --refresh`.
pub(crate) fn update_index_stats(
worktree_path: &Path,
file_metadata: &[(PathBuf, Metadata)],
) -> Result<()> {
if file_metadata.is_empty() {
return Ok(());
}
let start = std::time::Instant::now();
let git_dir = find_worktree_git_dir(worktree_path)?;
let index_path = git_dir.join("index");
// If index doesn't exist yet, there's nothing to update
if !index_path.exists() {
tracing::debug!(
path = %worktree_path.display(),
"index file doesn't exist yet, skipping update"
);
return Ok(());
}
// Guard against empty index files — gix-index panics when the file
// is 0 bytes because it tries to slice the trailing hash from an
// empty mmap (integer underflow in the slice range).
if index_path.metadata().map_or(true, |m| m.len() == 0) {
tracing::debug!(
path = %worktree_path.display(),
"index file is empty, skipping update"
);
return Ok(());
}
// Open the index file directly for modification
let mut index = gix::index::File::at(
&index_path,
gix::hash::Kind::Sha1,
false,
Default::default(),
)
.context("failed to open git index")?;
// Update stat info for each file that was copied
let mut updated_count = 0;
for entry in file_metadata.iter() {
let (path, metadata) = (&entry.0, &entry.1);
// Convert path to BStr for gix
let path_str = path.to_string_lossy();
let path_bytes: &gix::bstr::BStr = path_str.as_bytes().into();
// Find the entry in the index
if let Ok(entry_index) = index.entry_index_by_path(path_bytes) {
let entry = &mut index.entries_mut()[entry_index];
updated_count += 1;
// Update stat fields from metadata
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
// mtime (modification time)
entry.stat.mtime.secs = metadata
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs() as u32)
.unwrap_or(0);
entry.stat.mtime.nsecs = metadata
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.subsec_nanos())
.unwrap_or(0);
// ctime (change time) - MUST be the actual ctime, not mtime
entry.stat.ctime.secs = metadata.ctime() as u32;
entry.stat.ctime.nsecs = metadata.ctime_nsec() as u32;
// Other stat fields
entry.stat.size = metadata.len() as u32;
entry.stat.dev = metadata.dev() as u32;
entry.stat.ino = metadata.ino() as u32;
entry.stat.uid = metadata.uid();
entry.stat.gid = metadata.gid();
// Note: mode is on the entry itself, not stat
}
#[cfg(not(unix))]
{
// On non-Unix systems, use mtime for both
entry.stat.mtime.secs = metadata
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs() as u32)
.unwrap_or(0);
entry.stat.mtime.nsecs = metadata
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.subsec_nanos())
.unwrap_or(0);
entry.stat.ctime.secs = entry.stat.mtime.secs;
entry.stat.ctime.nsecs = entry.stat.mtime.nsecs;
entry.stat.size = metadata.len() as u32;
}
}
}
// Count how many entries were actually updated
let num_updated = updated_count;
// Write the updated index
index.write(Default::default())?;
tracing::debug!(
path = %worktree_path.display(),
files_updated = num_updated,
elapsed = ?start.elapsed(),
"updated index stat cache"
);
Ok(())
}

View file

@ -0,0 +1,20 @@
//! Git operations used by fast worktree creation.
//!
//! This module isolates git-specific functionality (worktree creation, status, index refresh)
//! from filesystem copy logic and orchestration.
pub(crate) mod checkout;
pub(crate) mod discovery;
pub(crate) mod index;
pub(crate) mod status;
pub(crate) mod worktree;
pub(crate) use checkout::checkout_ref;
pub(crate) use checkout::{git_clean_fd, git_reset_hard_command};
// Only consumed by the Linux-only snapshot finalize path.
#[cfg(target_os = "linux")]
pub(crate) use checkout::{has_staged_changes, worktree_at_ref, worktree_has_tracked_changes};
pub(crate) use discovery::{find_worktree_root, get_head_commit};
pub(crate) use index::{copy_git_index, update_index_stats};
pub(crate) use status::get_modified_files;
pub(crate) use worktree::worktree_add_no_checkout;

View file

@ -0,0 +1,106 @@
//! Git status helpers (compute dirty paths).
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use dashmap::DashSet;
use gix::bstr::BString;
use gix::status::index_worktree::Item;
use gix_status::index_as_worktree::{Change, EntryStatus};
use crate::copy::DirtyFilesReport;
/// Result of scanning for modified files, including both paths and counts by category.
pub(crate) struct ModifiedFilesResult {
/// Set of all relative paths that are modified/untracked/deleted.
pub paths: DashSet<PathBuf>,
/// Categorized counts of dirty files.
pub report: DirtyFilesReport,
}
/// Get modified files from the source repository.
///
/// This uses `gix`'s `index_worktree_iter` which compares the **index** to the
/// **worktree**. It reports which files have been modified/added/deleted relative
/// to what's staged, but does **not** expose the two-column staged-vs-worktree
/// status (`XY` in porcelain output). For full `XY` semantics (needed by
/// `sync::WorktreeSync`), see the CLI-based parser in `sync.rs`.
///
/// This is a blocking operation.
pub(crate) fn get_modified_files(source: &Path) -> Result<ModifiedFilesResult> {
let repo = gix::discover(source).context("failed to discover git repository")?;
let modified: DashSet<PathBuf> = DashSet::new();
// Guard against empty index files — gix-index panics when the file
// is 0 bytes because it tries to slice the trailing hash from an
// empty mmap (integer underflow in the slice range).
let index_path = repo.git_dir().join("index");
if index_path.metadata().map_or(true, |m| m.len() == 0) {
tracing::debug!(
path = %source.display(),
"index file is empty or missing, returning empty modified set"
);
return Ok(ModifiedFilesResult {
paths: modified,
report: DirtyFilesReport {
modified_files: 0,
untracked_files: 0,
deleted_files: 0,
},
});
}
let mut modified_count = 0u64;
let mut untracked_count = 0u64;
let mut deleted_count = 0u64;
// Cap produce workers: gix-features spawn-EAGAIN aborts under panic=abort.
let status = xai_gix_status::with_budgeted_thread_limit(repo.status(gix::progress::Discard)?);
let iter = status.into_index_worktree_iter(Vec::<BString>::new())?;
for item_result in iter {
let item = item_result?;
let path = match &item {
Item::Modification {
rela_path, status, ..
} => {
// Check if it's a deletion (file exists in index but not in worktree)
match status {
EntryStatus::Change(Change::Removed) => deleted_count += 1,
_ => modified_count += 1,
}
rela_path.to_string()
}
Item::DirectoryContents { entry, .. } => {
// DirectoryContents = untracked files from directory walk
untracked_count += 1;
entry.rela_path.to_string()
}
Item::Rewrite { dirwalk_entry, .. } => {
// Rewrite = file was renamed (tracked as modified)
modified_count += 1;
dirwalk_entry.rela_path.to_string()
}
};
modified.insert(PathBuf::from(path));
}
tracing::info!(
count = modified.len(),
modified = modified_count,
untracked = untracked_count,
deleted = deleted_count,
"found modified files"
);
Ok(ModifiedFilesResult {
paths: modified,
report: DirtyFilesReport {
modified_files: modified_count,
untracked_files: untracked_count,
deleted_files: deleted_count,
},
})
}

View file

@ -0,0 +1,30 @@
//! Git worktree operations.
use std::path::Path;
use anyhow::{Context, Result};
use crate::git::checkout::git_command;
/// Create a git worktree with `--no-checkout`. Blocking.
pub(crate) fn worktree_add_no_checkout(source: &Path, dest: &str, git_ref: &str) -> Result<()> {
let output = git_command()
.current_dir(source)
.args([
"worktree",
"add",
"--detach",
"--no-checkout",
dest,
git_ref,
])
.output()
.context("failed to run git worktree add")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("git worktree add failed: {}", stderr);
}
Ok(())
}

View file

@ -0,0 +1,67 @@
//! High-performance git worktree creation using CoW cloning.
//!
//! This crate provides fast worktree creation by:
//! 1. Using `git worktree add --no-checkout` (instant metadata creation)
//! 2. Parallel CoW file cloning with hash-based sharding
//! 3. Optional dirty file replication and ignored file copying
//! 4. BTRFS snapshot support on Linux for O(1) cloning
//! 5. Worktree sync API for pre-created worktree pools
//! 6. SQLite metadata tracking (behind `metadata` feature)
mod api;
#[cfg(target_os = "linux")]
pub mod btrfs;
mod copy;
#[cfg(feature = "metadata")]
pub mod db;
#[cfg(feature = "metadata")]
pub mod discovery;
mod git;
#[cfg(target_os = "linux")]
pub(crate) mod mount_info;
#[cfg(target_os = "linux")]
mod overlay;
pub mod sync;
#[cfg(target_os = "linux")]
pub(crate) mod util;
mod worktree;
#[cfg(target_os = "linux")]
pub use api::cleanup_orphaned_btrfs_snapshots;
#[cfg(target_os = "linux")]
pub use api::cleanup_orphaned_overlay_snapshots;
#[cfg(feature = "metadata")]
pub use api::gc::{GcOptions, GcReport, gc_worktrees, gc_worktrees_with_delegate};
pub use api::{
BtrfsDelegate, BtrfsMode, CleanupReport, CopyReport, CreationMode, DelegateSnapshotResult,
DirtyFilesReport, ENOSPC_OS_MESSAGE, IgnoredFilesMode, OUT_OF_DISK_CONTEXT, RemoveReport,
WorkingTreeMode, WorktreeBuilder, WorktreeReport, cleanup_worktrees_in,
cleanup_worktrees_in_with_delegate, remove_worktree, remove_worktree_with_delegate,
};
#[cfg(feature = "metadata")]
pub use db::{
DbStats, ListFilter, WorktreeDb, WorktreeKind, WorktreeRecord, WorktreeStatus, id_from_path,
now_epoch_secs, repo_name_from_path, resolve_grok_home,
};
#[cfg(feature = "metadata")]
pub use discovery::{RebuildReport, discover_worktrees, rebuild_worktree_db};
pub use git::checkout::{
rehydrate_worktree_from_ref, snapshot_worktree_to_ref, transfer_snapshot_to_repo,
};
pub use sync::{SourceDirtyState, SyncReport, WorktreeSync, collect_source_dirty_state};
#[cfg(target_os = "linux")]
pub use worktree::execute::cleanup_snapshot_git_state;
/// Count the number of tracked files in a git repository's index.
///
/// Reads the index header via `gix`, which contains the entry count — this
/// is an O(1) read (no directory walk). Useful for deciding whether a repo
/// is large enough to benefit from worktree pooling.
pub fn count_tracked_files(repo_path: &std::path::Path) -> anyhow::Result<usize> {
let repo = gix::discover(repo_path)
.map_err(|e| anyhow::anyhow!("failed to discover git repo: {e}"))?;
let index = repo
.index_or_load_from_head()
.map_err(|e| anyhow::anyhow!("failed to load git index: {e}"))?;
Ok(index.entries().len())
}

View file

@ -0,0 +1,531 @@
//! Shared `/proc/self/mountinfo` parser.
//!
//! Parses mountinfo once into structured `MountEntry` values, shared across
//! overlay and btrfs detection so we avoid duplicate parsing.
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
/// A parsed entry from `/proc/self/mountinfo`.
///
/// Format per line:
/// ```text
/// ID PARENT MAJOR:MINOR ROOT MOUNTPOINT OPTIONS - FSTYPE SOURCE SUPER_OPTIONS
/// ```
#[derive(Debug, Clone)]
pub struct MountEntry {
/// Mount ID.
#[allow(dead_code)]
pub mount_id: u32,
/// Parent mount ID.
#[allow(dead_code)]
pub parent_id: u32,
/// Root of the mount within the filesystem.
#[allow(dead_code)]
pub root: String,
/// Mount point (where it's visible in the VFS).
pub mount_point: PathBuf,
/// Filesystem type (e.g., "overlay", "fuse.repo-fuse", "btrfs").
pub fs_type: String,
/// Mount source (device or special).
#[allow(dead_code)]
pub source: String,
/// Super-block options (e.g., "lowerdir=...,upperdir=...,workdir=...").
pub super_options: String,
}
/// Overlay-specific mount options parsed from `super_options`.
#[derive(Debug, Clone)]
pub struct OverlayMountInfo {
/// The underlying `MountEntry`.
pub entry: MountEntry,
/// First (or only) lower directory.
pub lower_dir: PathBuf,
/// Upper directory.
pub upper_dir: PathBuf,
/// Work directory.
pub work_dir: PathBuf,
}
/// Read and parse `/proc/self/mountinfo`.
pub fn parse_mountinfo() -> Result<Vec<MountEntry>> {
let content =
std::fs::read_to_string("/proc/self/mountinfo").context("read /proc/self/mountinfo")?;
Ok(parse_mountinfo_from(&content))
}
/// Every overlay `upperdir` mounted across **all** namespaces, by scanning each
/// `/proc/<pid>/mountinfo`. Overlay worktrees may live in a different process's
/// mount namespace than the cleanup caller, so cleanup must check across
/// namespaces before deleting an overlay's backing snapshot. Unreadable entries
/// are skipped (a limited-visibility caller still sees its own namespace);
/// empty on platforms without `/proc`.
pub fn overlay_upperdirs_all_namespaces() -> std::collections::HashSet<PathBuf> {
let mut uppers = std::collections::HashSet::new();
let Ok(procs) = std::fs::read_dir("/proc") else {
return uppers;
};
for proc in procs.flatten() {
// Only numeric PID entries.
if !proc
.file_name()
.to_string_lossy()
.bytes()
.all(|b| b.is_ascii_digit())
{
continue;
}
let Ok(content) = std::fs::read_to_string(proc.path().join("mountinfo")) else {
continue;
};
for entry in parse_mountinfo_from(&content) {
if entry.fs_type == "overlay"
&& let Some(u) = extract_option(&entry.super_options, "upperdir")
{
// Unescape mountinfo octal escapes (e.g. `\040` → space) so the
// stored upperdir matches the real filesystem paths callers
// compare against (mirrors `find_overlay_mount`).
uppers.insert(PathBuf::from(unescape_mountinfo(&u)));
}
}
}
uppers
}
/// Parse mountinfo from a string (testable without /proc).
pub fn parse_mountinfo_from(content: &str) -> Vec<MountEntry> {
content.lines().filter_map(parse_line).collect()
}
/// Find the mount entry for `path` (longest mount_point prefix match).
#[allow(dead_code)]
pub fn find_mount_for_path<'a>(entries: &'a [MountEntry], path: &Path) -> Option<&'a MountEntry> {
let path_str = path.to_string_lossy();
let mut best: Option<&MountEntry> = None;
let mut best_len = 0;
for entry in entries {
let mp = entry.mount_point.to_string_lossy();
if path_str.starts_with(mp.as_ref())
&& (path_str.len() == mp.len() || path_str.as_bytes().get(mp.len()) == Some(&b'/'))
&& mp.len() > best_len
{
best_len = mp.len();
best = Some(entry);
}
}
best
}
/// Find an overlay mount containing `path` and parse its options.
pub fn find_overlay_mount(entries: &[MountEntry], path: &Path) -> Option<OverlayMountInfo> {
let path_str = path.to_string_lossy();
let mut best: Option<&MountEntry> = None;
let mut best_len = 0;
for entry in entries {
if entry.fs_type != "overlay" {
continue;
}
let mp = entry.mount_point.to_string_lossy();
if path_str.starts_with(mp.as_ref())
&& (path_str.len() == mp.len() || path_str.as_bytes().get(mp.len()) == Some(&b'/'))
&& mp.len() > best_len
{
best_len = mp.len();
best = Some(entry);
}
}
let entry = best?;
let lower = extract_option(&entry.super_options, "lowerdir")?;
let upper = extract_option(&entry.super_options, "upperdir")?;
let work = extract_option(&entry.super_options, "workdir")?;
// lowerdir can be colon-separated (multi-layer). Take the first one.
let first_lower = lower.split(':').next().unwrap_or(&lower);
Some(OverlayMountInfo {
entry: entry.clone(),
lower_dir: PathBuf::from(unescape_mountinfo(first_lower)),
upper_dir: PathBuf::from(unescape_mountinfo(&upper)),
work_dir: PathBuf::from(unescape_mountinfo(&work)),
})
}
/// Result of comparing the current process's mount namespace with PID 1's.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MountNsStatus {
/// Current process is in a mount namespace distinct from PID 1's.
Private,
/// Current process shares PID 1's mount namespace.
Host,
/// Could not determine — e.g. `/proc/1/ns/mnt` is unreadable for a non-root
/// process (needs to own PID 1 or have `CAP_SYS_PTRACE`).
Unknown,
}
/// Classify the current process's mount namespace relative to PID 1's.
///
/// Mounts created inside a private mount namespace (e.g. a container, a
/// `PrivateMounts=` systemd unit, or `unshare -m`) are invisible to processes
/// in other namespaces and are torn down when the namespace's last process
/// exits. Worktree strategies that materialize the worktree as a kernel mount
/// (bind mount, overlayfs) must avoid this so the worktree survives process
/// restart and is visible from the user's other shells.
///
/// Compares the mount-namespace identity of the current process
/// (`/proc/self/ns/mnt`) against PID 1 (`/proc/1/ns/mnt`). `Unknown` is returned
/// when either link is unreadable — most commonly a non-root process that can't
/// read `/proc/1/ns/mnt` (needs to own PID 1 or have `CAP_SYS_PTRACE`).
///
/// **Load-bearing assumption (see callers):** the only namespace-local strategy
/// gated on this is the overlay path; callers treat `Unknown` as *not* private
/// (overlay stays enabled) so a non-root caller on a normal host namespace is
/// not silently degraded to the slow copy path. This relies on environments
/// that actually exhibit the private-namespace issue typically running as
/// **root** (PID 1 readable → a genuine private namespace is detected as
/// `Private`). The btrfs-snapshot-symlink path is namespace-independent and
/// correct regardless of this classification; only the overlay (FUSE upper)
/// path could re-introduce an ephemeral worktree for a non-root process inside
/// a genuine private namespace — an accepted, documented residual.
pub fn current_mount_ns_status() -> MountNsStatus {
let status = mount_ns_status(
std::fs::read_link("/proc/self/ns/mnt"),
std::fs::read_link("/proc/1/ns/mnt"),
);
if status == MountNsStatus::Unknown {
log_unknown_mount_ns_once();
}
status
}
/// Decide mount-namespace status from the two `read_link` results.
///
/// Pure helper so the comparison/permission logic is unit-testable without
/// procfs.
fn mount_ns_status(
self_ns: std::io::Result<PathBuf>,
pid1_ns: std::io::Result<PathBuf>,
) -> MountNsStatus {
match (self_ns, pid1_ns) {
(Ok(self_ns), Ok(pid1_ns)) if self_ns == pid1_ns => MountNsStatus::Host,
(Ok(_), Ok(_)) => MountNsStatus::Private,
_ => MountNsStatus::Unknown,
}
}
/// Emit a single diagnostic (across the process lifetime) noting that the
/// mount-namespace decision could not be made because `/proc/1/ns/mnt` was
/// unreadable, so namespace-local strategies remain enabled.
fn log_unknown_mount_ns_once() {
use std::sync::atomic::{AtomicBool, Ordering};
static LOGGED: AtomicBool = AtomicBool::new(false);
if !LOGGED.swap(true, Ordering::Relaxed) {
tracing::info!(
"cannot read /proc/1/ns/mnt (likely non-root); treating mount namespace as \
non-private overlay/bind strategies stay enabled. If grok is in a private \
namespace as non-root, worktrees may be ephemeral."
);
}
}
/// Check if `path` is a FUSE mount.
pub fn is_fuse_mount(entries: &[MountEntry], path: &Path) -> bool {
let path_str = path.to_string_lossy();
entries.iter().any(|e| {
e.mount_point.to_string_lossy() == path_str
&& (e.fs_type == "fuse"
|| e.fs_type.starts_with("fuse.")
|| e.fs_type.starts_with("fuseblk"))
})
}
// ── Internal helpers ─────────────────────────────────────────────────────
/// Parse a single mountinfo line.
fn parse_line(line: &str) -> Option<MountEntry> {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() < 10 {
return None;
}
let mount_id = parts[0].parse::<u32>().ok()?;
let parent_id = parts[1].parse::<u32>().ok()?;
let root = parts[3].to_string();
let mount_point = PathBuf::from(unescape_mountinfo(parts[4]));
// Find the `-` separator.
let sep_idx = parts.iter().position(|&p| p == "-")?;
let fs_type = parts.get(sep_idx + 1)?.to_string();
let source = parts.get(sep_idx + 2).unwrap_or(&"").to_string();
let super_options = parts.get(sep_idx + 3).unwrap_or(&"").to_string();
Some(MountEntry {
mount_id,
parent_id,
root,
mount_point,
fs_type,
source,
super_options,
})
}
/// Extract `key=value` from a comma-separated options string.
pub(crate) fn extract_option(options: &str, key: &str) -> Option<String> {
let prefix = format!("{key}=");
for part in options.split(',') {
if let Some(val) = part.strip_prefix(&prefix) {
return Some(val.to_string());
}
}
None
}
/// Unescape octal escapes in mountinfo fields (e.g., `\040` → space).
fn unescape_mountinfo(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'\\' && i + 3 < bytes.len() {
let o1 = bytes[i + 1];
let o2 = bytes[i + 2];
let o3 = bytes[i + 3];
if o1.is_ascii_digit() && o2.is_ascii_digit() && o3.is_ascii_digit() {
let val = (o1 - b'0') * 64 + (o2 - b'0') * 8 + (o3 - b'0');
result.push(val as char);
i += 4;
continue;
}
}
result.push(bytes[i] as char);
i += 1;
}
result
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE_MOUNTINFO: &str = "\
22 1 8:1 / / rw,relatime shared:1 - ext4 /dev/sda1 rw,errors=continue
50 22 0:44 / /var/lib/repo-fuse/instance/fuse-lower rw,nosuid,nodev,relatime - fuse.repo-fuse repo-fuse rw,user_id=0,group_id=0,allow_other
42 22 0:38 / /workspace/repo rw,relatime shared:2 - overlay overlay rw,lowerdir=/var/lib/repo-fuse/instance/fuse-lower,upperdir=/var/lib/repo-fuse/instance/upper,workdir=/var/lib/repo-fuse/instance/work,index=on
55 22 259:1 /btrfs-img /var/lib/repo-fuse/instance rw,relatime - btrfs /dev/loop0 rw,space_cache=v2,subvolid=256
";
#[test]
fn test_parse_mountinfo_entry_count() {
let entries = parse_mountinfo_from(SAMPLE_MOUNTINFO);
assert_eq!(entries.len(), 4);
}
#[test]
fn test_parse_mountinfo_overlay_entry() {
let entries = parse_mountinfo_from(SAMPLE_MOUNTINFO);
let overlay = entries.iter().find(|e| e.fs_type == "overlay").unwrap();
assert_eq!(overlay.mount_point, PathBuf::from("/workspace/repo"));
assert!(overlay.super_options.contains("lowerdir="));
assert!(overlay.super_options.contains("upperdir="));
}
#[test]
fn test_parse_mountinfo_fuse_entry() {
let entries = parse_mountinfo_from(SAMPLE_MOUNTINFO);
let fuse = entries
.iter()
.find(|e| e.fs_type.starts_with("fuse."))
.unwrap();
assert_eq!(
fuse.mount_point,
PathBuf::from("/var/lib/repo-fuse/instance/fuse-lower")
);
assert_eq!(fuse.fs_type, "fuse.repo-fuse");
}
#[test]
fn test_find_mount_for_path() {
let entries = parse_mountinfo_from(SAMPLE_MOUNTINFO);
let m = find_mount_for_path(&entries, Path::new("/workspace/repo")).unwrap();
assert_eq!(m.fs_type, "overlay");
let m2 = find_mount_for_path(&entries, Path::new("/workspace/repo/crates/foo")).unwrap();
assert_eq!(m2.fs_type, "overlay");
}
#[test]
fn test_find_overlay_mount() {
let entries = parse_mountinfo_from(SAMPLE_MOUNTINFO);
let info = find_overlay_mount(&entries, Path::new("/workspace/repo")).unwrap();
assert_eq!(
info.lower_dir,
PathBuf::from("/var/lib/repo-fuse/instance/fuse-lower")
);
assert_eq!(
info.upper_dir,
PathBuf::from("/var/lib/repo-fuse/instance/upper")
);
assert_eq!(
info.work_dir,
PathBuf::from("/var/lib/repo-fuse/instance/work")
);
}
#[test]
fn test_find_overlay_mount_subdirectory() {
let entries = parse_mountinfo_from(SAMPLE_MOUNTINFO);
let info = find_overlay_mount(&entries, Path::new("/workspace/repo/crates/foo")).unwrap();
assert_eq!(info.entry.mount_point, PathBuf::from("/workspace/repo"));
}
#[test]
fn test_find_overlay_mount_none() {
let entries = parse_mountinfo_from(SAMPLE_MOUNTINFO);
assert!(find_overlay_mount(&entries, Path::new("/tmp")).is_none());
}
#[test]
fn test_find_overlay_mount_path_boundary() {
// /workspace/repo-extra should NOT match a mount at /workspace/repo.
let entries = parse_mountinfo_from(SAMPLE_MOUNTINFO);
assert!(
find_overlay_mount(&entries, Path::new("/workspace/repo-extra")).is_none(),
"/workspace/repo-extra should not match overlay at /workspace/repo"
);
// But /workspace/repo itself should match.
assert!(find_overlay_mount(&entries, Path::new("/workspace/repo")).is_some());
// And a proper subpath should match.
assert!(find_overlay_mount(&entries, Path::new("/workspace/repo/foo")).is_some());
}
#[test]
fn test_is_fuse_mount() {
let entries = parse_mountinfo_from(SAMPLE_MOUNTINFO);
assert!(is_fuse_mount(
&entries,
Path::new("/var/lib/repo-fuse/instance/fuse-lower")
));
assert!(!is_fuse_mount(&entries, Path::new("/workspace/repo")));
assert!(!is_fuse_mount(&entries, Path::new("/")));
}
#[test]
fn test_extract_option() {
let opts = "rw,lowerdir=/a/b,upperdir=/c/d,workdir=/e/f,index=on";
assert_eq!(extract_option(opts, "lowerdir"), Some("/a/b".to_string()));
assert_eq!(extract_option(opts, "upperdir"), Some("/c/d".to_string()));
assert_eq!(extract_option(opts, "workdir"), Some("/e/f".to_string()));
assert_eq!(extract_option(opts, "index"), Some("on".to_string()));
assert_eq!(extract_option(opts, "missing"), None);
}
#[test]
fn test_extract_option_multi_lower() {
let opts = "rw,lowerdir=/a:/b:/c,upperdir=/d,workdir=/e";
let lower = extract_option(opts, "lowerdir").unwrap();
let first = lower.split(':').next().unwrap();
assert_eq!(first, "/a");
}
#[test]
fn test_unescape_mountinfo_no_escapes() {
assert_eq!(unescape_mountinfo("/a/b/c"), "/a/b/c");
}
#[test]
fn test_unescape_mountinfo_space() {
assert_eq!(unescape_mountinfo("/a\\040b/c"), "/a b/c");
}
#[test]
fn test_unescape_mountinfo_backslash() {
// \134 is ASCII backslash
assert_eq!(unescape_mountinfo("/a\\134b"), "/a\\b");
}
#[test]
fn test_parse_mountinfo_ids() {
let entries = parse_mountinfo_from(SAMPLE_MOUNTINFO);
let root = entries
.iter()
.find(|e| e.mount_point == Path::new("/"))
.unwrap();
assert_eq!(root.mount_id, 22);
assert_eq!(root.parent_id, 1);
}
#[test]
fn test_parse_empty_mountinfo() {
let entries = parse_mountinfo_from("");
assert!(entries.is_empty());
}
#[test]
fn test_parse_malformed_line() {
let entries = parse_mountinfo_from("garbage data");
assert!(entries.is_empty());
}
#[test]
fn test_mount_ns_status_distinct_links_is_private() {
assert_eq!(
mount_ns_status(
Ok(PathBuf::from("mnt:[4026531840]")),
Ok(PathBuf::from("mnt:[4026532998]")),
),
MountNsStatus::Private
);
}
#[test]
fn test_mount_ns_status_identical_links_is_host() {
assert_eq!(
mount_ns_status(
Ok(PathBuf::from("mnt:[4026531840]")),
Ok(PathBuf::from("mnt:[4026531840]")),
),
MountNsStatus::Host
);
}
#[test]
fn test_mount_ns_status_unreadable_is_unknown() {
let perm = || std::io::Error::from(std::io::ErrorKind::PermissionDenied);
// The non-root case is pid1 unreadable; self-unreadable and both-unreadable
// are also Unknown rather than Private.
assert_eq!(
mount_ns_status(Ok(PathBuf::from("mnt:[4026531840]")), Err(perm())),
MountNsStatus::Unknown
);
assert_eq!(
mount_ns_status(Err(perm()), Ok(PathBuf::from("mnt:[4026531840]"))),
MountNsStatus::Unknown
);
assert_eq!(
mount_ns_status(Err(perm()), Err(perm())),
MountNsStatus::Unknown
);
}
#[test]
fn test_current_mount_ns_status_consistent() {
assert_eq!(current_mount_ns_status(), current_mount_ns_status());
}
#[test]
fn test_overlay_options_escaped_colons() {
// Escaped colon in lowerdir path: /a\072b means /a:b
let line =
"42 1 0:38 / /mnt rw - overlay overlay rw,lowerdir=/a\\072b,upperdir=/u,workdir=/w";
let entries = parse_mountinfo_from(line);
let info = find_overlay_mount(&entries, Path::new("/mnt")).unwrap();
assert_eq!(info.lower_dir, PathBuf::from("/a:b"));
}
}

View file

@ -0,0 +1,190 @@
//! FUSE+overlay detection.
//!
//! Detects when a path sits on a FUSE+overlayfs stack with a btrfs upper dir.
//! All four conditions must hold for the overlay worktree path to be used:
//! 1. Path is on an overlayfs mount
//! 2. The overlay's lowerdir is a FUSE mount
//! 3. The overlay's upperdir is on btrfs (snapshotable)
//! 4. workdir is parseable
use std::path::{Path, PathBuf};
use anyhow::Result;
use crate::mount_info;
/// Information about a FUSE+overlay mount.
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct OverlayInfo {
/// The overlayfs mount point (e.g., `/workspace/repo`).
pub mount_point: PathBuf,
/// The FUSE lower dir (e.g., `/var/lib/repo-fuse/instance/fuse-lower`).
pub lower_dir: PathBuf,
/// The overlay upper dir (e.g., `/var/lib/repo-fuse/instance/upper`).
pub upper_dir: PathBuf,
/// The overlay work dir (e.g., `/var/lib/repo-fuse/instance/work`).
pub work_dir: PathBuf,
/// The root directory that contains upper/ and work/ — sibling directory
/// for worktree snapshots (e.g., `/var/lib/repo-fuse/instance`).
pub overlay_root: PathBuf,
}
/// Detect if `path` is on a FUSE+overlayfs stack with btrfs upper.
///
/// Returns `Ok(Some(OverlayInfo))` if all conditions are met, `Ok(None)` otherwise.
/// Handles `EIO`/`ENOTCONN` from a crashed FUSE daemon gracefully by returning `Ok(None)`.
pub fn detect_fuse_overlay(path: &Path) -> Result<Option<OverlayInfo>> {
let entries = match mount_info::parse_mountinfo() {
Ok(entries) => entries,
Err(e) => {
tracing::debug!(error = %e, "failed to parse mountinfo, skipping overlay detection");
return Ok(None);
}
};
detect_fuse_overlay_from_entries(path, &entries)
}
/// Testable version that takes pre-parsed entries.
pub(crate) fn detect_fuse_overlay_from_entries(
path: &Path,
entries: &[mount_info::MountEntry],
) -> Result<Option<OverlayInfo>> {
// Step 1: Find overlay mount containing this path.
let overlay = match mount_info::find_overlay_mount(entries, path) {
Some(info) => info,
None => {
tracing::debug!(path = %path.display(), "not on an overlayfs mount");
return Ok(None);
}
};
// Step 2: Verify the lower layer is a FUSE mount.
if !mount_info::is_fuse_mount(entries, &overlay.lower_dir) {
tracing::debug!(
lower = %overlay.lower_dir.display(),
"overlay lower layer is not a FUSE mount"
);
return Ok(None);
}
// Step 3: Verify the upper layer is on btrfs.
let upper_on_btrfs = match crate::btrfs::is_btrfs(&overlay.upper_dir) {
Ok(true) => true,
Ok(false) => false,
Err(e) => {
// EIO / ENOTCONN from crashed FUSE — treat as "not available"
tracing::debug!(
upper = %overlay.upper_dir.display(),
error = %e,
"btrfs check failed on overlay upper, skipping"
);
false
}
};
if !upper_on_btrfs {
tracing::debug!(
upper = %overlay.upper_dir.display(),
"overlay upper dir is not on btrfs"
);
return Ok(None);
}
// Derive overlay_root — parent of upper_dir (sibling of upper/ and work/).
let overlay_root = overlay
.upper_dir
.parent()
.unwrap_or(&overlay.upper_dir)
.to_path_buf();
tracing::info!(
mount_point = %overlay.entry.mount_point.display(),
lower = %overlay.lower_dir.display(),
upper = %overlay.upper_dir.display(),
overlay_root = %overlay_root.display(),
"detected FUSE+overlay with btrfs upper"
);
Ok(Some(OverlayInfo {
mount_point: overlay.entry.mount_point.clone(),
lower_dir: overlay.lower_dir,
upper_dir: overlay.upper_dir,
work_dir: overlay.work_dir,
overlay_root,
}))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mount_info::parse_mountinfo_from;
const FUSE_OVERLAY_MOUNTINFO: &str = "\
22 1 8:1 / / rw,relatime shared:1 - ext4 /dev/sda1 rw
50 22 0:44 / /var/lib/repo-fuse/instance/fuse-lower rw,nosuid,nodev - fuse.repo-fuse repo-fuse rw,user_id=0,allow_other
55 22 259:1 /img /var/lib/repo-fuse/instance rw,relatime - btrfs /dev/loop0 rw,space_cache=v2,subvolid=256
42 22 0:38 / /workspace/repo rw,relatime shared:2 - overlay overlay rw,lowerdir=/var/lib/repo-fuse/instance/fuse-lower,upperdir=/var/lib/repo-fuse/instance/upper,workdir=/var/lib/repo-fuse/instance/work,index=on
";
#[test]
fn test_detect_non_overlay() {
// /tmp is unlikely to be on overlayfs in tests.
let result = detect_fuse_overlay(Path::new("/tmp"));
assert!(result.is_ok());
assert!(result.unwrap().is_none());
}
#[test]
fn test_overlay_info_debug_clone() {
let info = OverlayInfo {
mount_point: PathBuf::from("/workspace/repo"),
lower_dir: PathBuf::from("/var/lib/repo-fuse/fuse-lower"),
upper_dir: PathBuf::from("/var/lib/repo-fuse/upper"),
work_dir: PathBuf::from("/var/lib/repo-fuse/work"),
overlay_root: PathBuf::from("/var/lib/repo-fuse"),
};
let cloned = info.clone();
assert_eq!(format!("{:?}", info), format!("{:?}", cloned));
}
#[test]
fn test_detect_overlay_without_fuse_lower() {
// Overlay where lower is ext4, not FUSE — should return None.
let mountinfo = "\
22 1 8:1 / / rw - ext4 /dev/sda1 rw
30 22 8:2 / /lower rw - ext4 /dev/sda2 rw
42 22 0:38 / /workspace/repo rw - overlay overlay rw,lowerdir=/lower,upperdir=/upper,workdir=/work
";
let entries = parse_mountinfo_from(mountinfo);
let result =
detect_fuse_overlay_from_entries(Path::new("/workspace/repo"), &entries).unwrap();
assert!(result.is_none());
}
#[test]
fn test_detect_overlay_without_overlay_mount() {
let mountinfo = "\
22 1 8:1 / / rw - ext4 /dev/sda1 rw
";
let entries = parse_mountinfo_from(mountinfo);
let result =
detect_fuse_overlay_from_entries(Path::new("/workspace/repo"), &entries).unwrap();
assert!(result.is_none());
}
#[test]
fn test_overlay_info_fields() {
// We can't run the btrfs check in unit tests (no btrfs fs), but we
// can verify the parsing portion works by calling the internal function
// and checking that step 3 (btrfs) is the failing point.
let entries = parse_mountinfo_from(FUSE_OVERLAY_MOUNTINFO);
// This will return None because the sample upper path doesn't exist,
// so is_btrfs will fail — but that's expected in a unit test.
let result = detect_fuse_overlay_from_entries(Path::new("/workspace/repo"), &entries);
assert!(result.is_ok());
// On a system without the actual btrfs mount, this returns None.
// On a host with a live FUSE+overlay stack it would return Some.
}
}

View file

@ -0,0 +1,15 @@
//! Overlay-on-FUSE worktree support.
//!
//! When the source repo is on a FUSE+overlayfs stack (repo-fuse), we can
//! create worktrees via a new overlay mount that shares the same FUSE lower
//! layer, with a btrfs snapshot of the current upper dir as the new upper.
//! This gives O(1) worktree creation without any file copies.
pub(crate) mod detect;
pub(crate) mod snapshot;
pub(crate) use detect::{OverlayInfo, detect_fuse_overlay};
pub(crate) use snapshot::{
cleanup_orphaned_overlay_snapshots, create_overlay_worktree, remove_overlay_worktree,
try_remove_from_metadata, try_remove_from_mountinfo,
};

View file

@ -0,0 +1,769 @@
//! Overlay worktree creation and removal.
//!
//! Orchestrates btrfs snapshot of the overlay upper dir, metadata persistence,
//! overlayfs mount, and cleanup.
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use super::detect::OverlayInfo;
use crate::api::RemoveReport;
use crate::btrfs::snapshot::create_snapshot;
use crate::util::unix_timestamp_string;
/// Result of creating an overlay worktree.
#[derive(Debug)]
#[allow(dead_code)]
pub struct OverlayWorktreeResult {
/// The final worktree path (the overlay mount target).
pub worktree_path: PathBuf,
/// The btrfs snapshot root (the subvolume, for cleanup).
pub snapshot_root: PathBuf,
/// The work dir path (for cleanup).
pub work_dir: PathBuf,
}
/// Metadata persisted alongside the snapshot for crash recovery.
#[derive(Debug, serde::Serialize, serde::Deserialize)]
struct OverlayMetadata {
/// Always "overlay".
#[serde(rename = "type")]
kind: String,
/// Path to the btrfs snapshot root (the subvolume to pass to `btrfs subvolume delete`).
///
/// - **New layout:** `<wt_base>/root` — the entire overlay_root is snapshotted,
/// and `root/upper/` is the overlayfs upper dir.
/// - **Old layout (via alias):** `<wt_base>/upper` — the upper dir was itself the
/// btrfs subvolume and also the overlayfs upper dir.
///
/// In both cases, this field is the correct path for `btrfs subvolume delete`.
#[serde(alias = "snapshot_upper")]
snapshot_root: PathBuf,
/// Path to the overlay work dir.
work_dir: PathBuf,
/// Path to the FUSE lower dir.
lower_dir: PathBuf,
/// Path where the overlay was mounted.
mount_target: PathBuf,
/// ISO 8601 timestamp.
created_at: String,
}
const METADATA_FILENAME: &str = ".fast-worktree-meta.json";
/// Create an overlay worktree: snapshot upper → write metadata → mount overlay.
///
/// # Arguments
/// * `info` - The detected FUSE+overlay info for the source repo.
/// * `dest` - Where the worktree should appear (the overlay mount target).
pub fn create_overlay_worktree(
info: &OverlayInfo,
dest: &Path,
delegate: Option<&std::sync::Arc<dyn crate::BtrfsDelegate>>,
) -> Result<OverlayWorktreeResult> {
// Derive worktree name from dest path.
let wt_name = dest
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("overlay-wt");
// Layout: <overlay_root>/worktrees/<wt_name>/root (btrfs snapshot)
// root/upper — the snapshot's upper dir (used as overlayfs upperdir)
// root/overlay-work — the overlay work dir (created fresh; see below)
// root/work — inert copy of the source's work dir, reclaimed with
// the snapshot subvolume (not used by this worktree)
//
// IMPORTANT: work dir MUST be inside the snapshot (same btrfs subvolume as
// upper). Btrfs snapshots get their own subvolume with a distinct device ID.
// If work is outside the snapshot (different device), overlayfs returns
// EXDEV ("Invalid cross-device link") on unlink() — breaking git and most
// file-replacing workflows. This was the case with the old layout where
// work lived at <wt_base>/work (parent subvolume, different device ID).
let wt_base = info.overlay_root.join("worktrees").join(wt_name);
let snapshot_root = wt_base.join("root");
// Dedicated work dir, not the source's `work/`: the snapshot copies that
// `work/` whose root-owned, mode-000 internals a rootless creator can't
// delete, so a fresh name avoids that cleanup. (Still inside the snapshot
// per the same-subvolume requirement above; the stale copy is reclaimed
// with the snapshot subvolume.)
let work_dir = snapshot_root.join("overlay-work");
// Clean up if a previous attempt left debris.
if snapshot_root.exists() {
tracing::warn!(
snapshot = %snapshot_root.display(),
"stale snapshot exists, deleting"
);
let _ = delete_btrfs_snapshot(&snapshot_root);
}
// Clean up old-layout work dir (was at wt_base/work, now inside snapshot).
let old_work_dir = wt_base.join("work");
if old_work_dir.exists() {
let _ = std::fs::remove_dir_all(&old_work_dir);
}
// Ensure parent directories exist.
std::fs::create_dir_all(&wt_base)
.with_context(|| format!("create worktree base dir {}", wt_base.display()))?;
// Step 1: Snapshot the overlay root (the btrfs subvolume).
//
// The overlay_root is the btrfs subvolume; upper_dir is a regular directory
// inside it — not a subvolume itself. `btrfs subvolume snapshot` requires the
// source to be a subvolume, so we snapshot overlay_root and then use the
// `upper/` subdirectory from within the snapshot.
tracing::debug!(
source = %info.overlay_root.display(),
dest = %snapshot_root.display(),
"snapshotting overlay root (btrfs subvolume)"
);
create_snapshot(&info.overlay_root, &snapshot_root)
.context("failed to snapshot overlay root")?;
// The snapshot's upper dir is at the same relative position inside the snapshot.
let snapshot_upper = snapshot_root.join("upper");
// Step 2: Create the worktree's overlay work dir (fresh + empty). The
// snapshot was just (re)created from overlay_root, which has no
// `overlay-work` entry, so this name never pre-exists.
std::fs::create_dir(&work_dir)
.with_context(|| format!("create overlay work dir {}", work_dir.display()))?;
// Step 3: Write metadata for crash recovery.
// Written to wt_base (not inside the snapshot) so it survives overlay unmount.
write_metadata(&wt_base, &snapshot_root, &work_dir, &info.lower_dir, dest)?;
// Step 4: Mount overlay at dest.
std::fs::create_dir_all(dest)
.with_context(|| format!("create overlay mount target {}", dest.display()))?;
// Rootless callers delegate the mount to a privileged helper (it mounts in
// our namespace); callers with full caps mount in-process.
let mount_result = match delegate {
Some(d) => d.mount_overlay(&info.lower_dir, &snapshot_upper, &work_dir, dest),
None => mount_overlay(&info.lower_dir, &snapshot_upper, &work_dir, dest),
};
if let Err(e) = mount_result {
// Clean up the snapshot if mount fails.
tracing::warn!(error = %e, "overlay mount failed, cleaning up snapshot");
let _ = delete_btrfs_snapshot(&snapshot_root);
let _ = std::fs::remove_dir_all(&work_dir);
let _ = std::fs::remove_dir_all(&wt_base);
return Err(e.context("mount overlay"));
}
tracing::info!(
dest = %dest.display(),
overlay_upper = %snapshot_upper.display(),
snapshot_root = %snapshot_root.display(),
"overlay worktree mounted"
);
Ok(OverlayWorktreeResult {
worktree_path: dest.to_path_buf(),
snapshot_root,
work_dir,
})
}
/// Remove an overlay worktree: unmount → delete snapshot → cleanup.
///
/// `delegate` is `Some` for rootless callers that lack `CAP_SYS_ADMIN`; the
/// overlay unmount is then delegated to a privileged helper (mirroring the
/// create path). Privileged callers (e.g. an orphan-cleanup job) pass `None`
/// and unmount in-process.
pub fn remove_overlay_worktree(
target: &Path,
snapshot_root: &Path,
work_dir: &Path,
delegate: Option<&std::sync::Arc<dyn crate::BtrfsDelegate>>,
) -> Result<RemoveReport> {
// Unmount the overlay (delegated when rootless; see fn docs).
let unmount_result = match delegate {
Some(d) => d.unmount_overlay(target),
None => unmount_overlay(target),
};
if let Err(e) = unmount_result {
tracing::warn!(
target = %target.display(),
error = %e,
"overlay unmount failed (may already be unmounted)"
);
}
// Cross-namespace safety gate: an in-process (`delegate: None`) unmount can't
// detach an overlay living in another process's namespace, so before deleting
// the backing snapshot confirm it's unmounted in ANY namespace — else we'd
// reclaim the lower/upper under a live worktree. upperdir is
// `<snapshot_root>/upper` (new layout) or `<snapshot_root>` (old).
let overlay_upper = if snapshot_root.file_name().is_some_and(|n| n == "root") {
snapshot_root.join("upper")
} else {
snapshot_root.to_path_buf()
};
if crate::mount_info::overlay_upperdirs_all_namespaces().contains(&overlay_upper) {
anyhow::bail!(
"refusing to remove overlay worktree {}: still mounted (upper {}) — \
likely in another mount namespace this caller can't detach",
target.display(),
overlay_upper.display()
);
}
// Remove the (now empty) mount point directory.
let _ = std::fs::remove_dir(target);
// Delete the btrfs snapshot (subvolume). Best-effort so we don't skip
// cleaning up work dirs, metadata, and parent dirs on failure — orphan
// cleanup reclaims leftover snapshots on a later run.
let mut snapshot_delete_err = None;
if snapshot_root.exists()
&& let Err(e) = delete_btrfs_snapshot(snapshot_root)
{
tracing::warn!(
path = %snapshot_root.display(),
error = %e,
"failed to delete overlay btrfs snapshot, continuing cleanup"
);
snapshot_delete_err = Some(e);
}
// Remove work dir (only relevant for old layout where work dir is outside
// the snapshot; for new layout it's inside and already gone with the snapshot).
let _ = std::fs::remove_dir_all(work_dir);
// Clean up the metadata file — it lives outside the snapshot (at wt_base
// level), so snapshot deletion above doesn't remove it.
if let Some(wt_base) = snapshot_root.parent() {
let meta_path = wt_base.join(METADATA_FILENAME);
let _ = std::fs::remove_file(&meta_path);
// Remove parent (worktrees/<name>/) if now empty.
let _ = std::fs::remove_dir(wt_base);
}
tracing::info!(
target = %target.display(),
snapshot = %snapshot_root.display(),
"overlay worktree removed"
);
// Report snapshot deletion failure after completing all other cleanup.
if let Some(err) = snapshot_delete_err {
return Err(err.context(format!(
"overlay worktree at {} partially removed: btrfs snapshot {} could not be deleted",
target.display(),
snapshot_root.display()
)));
}
Ok(RemoveReport {
used_btrfs_delete: true,
unmounted_bind: false,
unmounted_overlay: true,
})
}
/// Try to remove via live mountinfo (Method 1).
pub fn try_remove_from_mountinfo(
target: &Path,
delegate: Option<&std::sync::Arc<dyn crate::BtrfsDelegate>>,
) -> Result<Option<RemoveReport>> {
let entries = match crate::mount_info::parse_mountinfo() {
Ok(e) => e,
Err(_) => return Ok(None),
};
let target_str = target.to_string_lossy();
let overlay_entry = entries
.iter()
.find(|e| e.fs_type == "overlay" && e.mount_point.to_string_lossy() == target_str);
let entry = match overlay_entry {
Some(e) => e,
None => return Ok(None),
};
// Extract upperdir and workdir from mount options.
let upper_dir = match crate::mount_info::extract_option(&entry.super_options, "upperdir") {
Some(v) => PathBuf::from(v),
None => return Ok(None),
};
let work_dir = match crate::mount_info::extract_option(&entry.super_options, "workdir") {
Some(v) => PathBuf::from(v),
None => return Ok(None),
};
// The overlayfs upperdir path always ends with `/upper` in both layouts:
// - New: `.../worktrees/<name>/root/upper` → snapshot subvol = parent (`.../root`)
// - Old: `.../worktrees/<name>/upper` → snapshot subvol = upper_dir itself
let snapshot_root = if let Some(parent) = upper_dir.parent() {
if parent.ends_with("root") {
parent.to_path_buf()
} else {
upper_dir.clone()
}
} else {
upper_dir.clone()
};
tracing::info!(
target = %target.display(),
upper = %upper_dir.display(),
snapshot_root = %snapshot_root.display(),
"detected overlay mount via mountinfo, removing"
);
remove_overlay_worktree(target, &snapshot_root, &work_dir, delegate).map(Some)
}
/// Try to remove via persisted metadata (Method 2 — crash recovery).
///
/// Scans known overlay roots under `/local/repo-fuse-*/worktrees/*/` for
/// `.fast-worktree-meta.json` files whose `mount_target` matches `target`.
/// This works even after the overlay has been unmounted — the metadata lives
/// on the btrfs filesystem next to the snapshot upper dir.
pub fn try_remove_from_metadata(
target: &Path,
delegate: Option<&std::sync::Arc<dyn crate::BtrfsDelegate>>,
) -> Result<Option<RemoveReport>> {
// Scan common overlay roots for metadata files.
let local = Path::new("/local");
if !local.exists() {
return Ok(None);
}
let target_str = target.to_string_lossy();
let Ok(entries) = std::fs::read_dir(local) else {
return Ok(None);
};
for dir_entry in entries.flatten() {
let name = dir_entry.file_name();
let name_str = name.to_string_lossy();
if !name_str.starts_with("repo-fuse-") {
continue;
}
let worktrees_dir = dir_entry.path().join("worktrees");
let Ok(wt_entries) = std::fs::read_dir(&worktrees_dir) else {
continue;
};
for wt_entry in wt_entries.flatten() {
let meta_path = wt_entry.path().join(METADATA_FILENAME);
if !meta_path.exists() {
continue;
}
let Ok(content) = std::fs::read_to_string(&meta_path) else {
continue;
};
let Ok(meta) = serde_json::from_str::<OverlayMetadata>(&content) else {
continue;
};
if meta.mount_target.to_string_lossy() == target_str {
tracing::info!(
target = %target.display(),
snapshot = %meta.snapshot_root.display(),
meta_path = %meta_path.display(),
"found overlay metadata via filesystem scan"
);
return remove_overlay_worktree(
target,
&meta.snapshot_root,
&meta.work_dir,
delegate,
)
.map(Some);
}
}
}
Ok(None)
}
/// Scan known overlay roots under `/local/repo-fuse-*/worktrees/` for orphaned
/// overlay snapshots.
///
/// An overlay snapshot is orphaned if:
/// - Its metadata file exists but the `mount_target` doesn't exist or isn't mounted
/// - Or the worktrees/ dir contains snapshot dirs without metadata (crashed mid-create)
///
/// For each orphan: delete the btrfs snapshot, remove the work dir,
/// remove the metadata file, clean up the parent dir.
///
/// Intended for host startup / periodic cleanup of leftovers left behind when
/// a previous session exited uncleanly.
pub fn cleanup_orphaned_overlay_snapshots() -> crate::api::CleanupReport {
let mut report = crate::api::CleanupReport::default();
let local = Path::new("/local");
if !local.exists() {
return report;
}
let Ok(entries) = std::fs::read_dir(local) else {
return report;
};
// Active overlay upperdirs across ALL namespaces (overlays may live in
// another process's namespace) — never delete a snapshot still backing a
// mounted overlay.
let active_uppers = crate::mount_info::overlay_upperdirs_all_namespaces();
for dir_entry in entries.flatten() {
let name = dir_entry.file_name();
let name_str = name.to_string_lossy();
if !name_str.starts_with("repo-fuse-") {
continue;
}
let worktrees_dir = dir_entry.path().join("worktrees");
let Ok(wt_entries) = std::fs::read_dir(&worktrees_dir) else {
continue;
};
for wt_entry in wt_entries.flatten() {
let wt_path = wt_entry.path();
let meta_path = wt_path.join(METADATA_FILENAME);
let work_dir = wt_path.join("work");
// Detect layout: new layout has `root/` (snapshot), old has `upper/` (snapshot).
// The overlayfs upperdir is `root/upper` (new) or `upper/` (old).
let (snapshot_path, overlay_upper) = {
let new_root = wt_path.join("root");
if new_root.exists() {
let upper = new_root.join("upper");
(new_root, upper)
} else {
let old_upper = wt_path.join("upper");
(old_upper.clone(), old_upper)
}
};
// Still mounted in any namespace? (upperdir match, both layouts)
let is_active = active_uppers.contains(&overlay_upper);
if is_active {
tracing::debug!(
snapshot = %snapshot_path.display(),
"skipping active overlay snapshot"
);
continue;
}
// Try to read metadata for additional cleanup (unmount stale target).
if let Ok(content) = std::fs::read_to_string(&meta_path)
&& let Ok(meta) = serde_json::from_str::<OverlayMetadata>(&content)
{
tracing::info!(
target = %meta.mount_target.display(),
snapshot = %meta.snapshot_root.display(),
"cleaning up orphaned overlay snapshot"
);
// Unmount target if it's somehow still a mountpoint (stale).
let _ = unmount_overlay(&meta.mount_target);
let _ = std::fs::remove_dir(&meta.mount_target);
} else {
tracing::info!(
snapshot = %snapshot_path.display(),
"cleaning up orphaned snapshot (no or corrupt metadata)"
);
}
// Clean up btrfs subvolume + work dir + metadata.
if snapshot_path.exists() {
if let Err(e) = delete_btrfs_snapshot(&snapshot_path) {
tracing::warn!(
path = %snapshot_path.display(),
error = %e,
"failed to delete orphaned btrfs snapshot"
);
report.errors += 1;
// Still clean up metadata + work dir below
} else {
report.btrfs_deleted += 1;
}
}
let _ = std::fs::remove_dir_all(&work_dir);
let _ = std::fs::remove_file(&meta_path);
let _ = std::fs::remove_dir(&wt_path);
report.removed += 1;
}
// Remove the worktrees/ dir if now empty.
let _ = std::fs::remove_dir(&worktrees_dir);
}
if report.removed > 0 || report.errors > 0 {
tracing::info!(
removed = report.removed,
btrfs = report.btrfs_deleted,
errors = report.errors,
"orphaned overlay snapshot cleanup complete"
);
}
report
}
// ── Internal helpers ─────────────────────────────────────────────────────
/// Mount overlayfs using `libc::mount()` syscall.
fn mount_overlay(lower: &Path, upper: &Path, work: &Path, target: &Path) -> Result<()> {
use std::ffi::CString;
// index=on enables correct rename() and hardlink semantics.
let mount_data = format!(
"lowerdir={},upperdir={},workdir={},index=on",
lower.display(),
upper.display(),
work.display(),
);
tracing::debug!(
lower = %lower.display(),
upper = %upper.display(),
work = %work.display(),
target = %target.display(),
"mounting overlayfs"
);
let c_source = CString::new("overlay").unwrap();
let c_target =
CString::new(target.as_os_str().as_encoded_bytes()).context("target path not C-safe")?;
let c_fstype = CString::new("overlay").unwrap();
let c_data = CString::new(mount_data.as_str()).context("mount data not C-safe")?;
// SAFETY: all pointers are valid CStrings that outlive the call.
let rc = unsafe {
libc::mount(
c_source.as_ptr(),
c_target.as_ptr(),
c_fstype.as_ptr(),
0,
c_data.as_ptr().cast(),
)
};
if rc != 0 {
let err = std::io::Error::last_os_error();
bail!("mount overlay at {}: {err}", target.display());
}
Ok(())
}
/// Unmount a filesystem (lazy/detach to avoid EBUSY).
fn unmount_overlay(target: &Path) -> Result<()> {
use std::ffi::CString;
let c_target =
CString::new(target.as_os_str().as_encoded_bytes()).context("target path not C-safe")?;
// SAFETY: c_target is a valid CString.
let rc = unsafe { libc::umount2(c_target.as_ptr(), libc::MNT_DETACH) };
if rc != 0 {
let err = std::io::Error::last_os_error();
bail!("unmount {}: {err}", target.display());
}
Ok(())
}
/// Delete a btrfs subvolume/snapshot (delegates to shared btrfs module).
fn delete_btrfs_snapshot(path: &Path) -> Result<()> {
crate::btrfs::snapshot::delete_snapshot(path)
}
/// Write metadata JSON to the worktree base dir for crash recovery.
///
/// Written to `<wt_base>/.fast-worktree-meta.json` (next to `upper/` and
/// `work/` dirs), NOT inside the overlay. This ensures the metadata is
/// always readable from the btrfs filesystem regardless of overlay mount state.
fn write_metadata(
wt_base: &Path,
snapshot_root: &Path,
work_dir: &Path,
lower_dir: &Path,
mount_target: &Path,
) -> Result<()> {
let meta = OverlayMetadata {
kind: "overlay".to_string(),
snapshot_root: snapshot_root.to_path_buf(),
work_dir: work_dir.to_path_buf(),
lower_dir: lower_dir.to_path_buf(),
mount_target: mount_target.to_path_buf(),
created_at: unix_timestamp_string(),
};
let meta_path = wt_base.join(METADATA_FILENAME);
let content = serde_json::to_string_pretty(&meta).context("serialize overlay metadata")?;
std::fs::write(&meta_path, &content)
.with_context(|| format!("write overlay metadata to {}", meta_path.display()))?;
tracing::debug!(path = %meta_path.display(), "overlay metadata written");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_metadata_serialization() {
let meta = OverlayMetadata {
kind: "overlay".to_string(),
snapshot_root: PathBuf::from("/var/lib/repo-fuse/instance/worktrees/abc/root"),
work_dir: PathBuf::from("/var/lib/repo-fuse/instance/worktrees/abc/work"),
lower_dir: PathBuf::from("/var/lib/repo-fuse/instance/fuse-lower"),
mount_target: PathBuf::from("/home/user/.grok/worktrees/abc"),
created_at: "2026-02-19T22:38:00Z".to_string(),
};
let json = serde_json::to_string_pretty(&meta).unwrap();
assert!(json.contains("\"type\": \"overlay\""));
assert!(json.contains("snapshot_root"));
assert!(json.contains("work_dir"));
assert!(json.contains("lower_dir"));
assert!(json.contains("mount_target"));
// Round-trip.
let parsed: OverlayMetadata = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.kind, "overlay");
assert_eq!(parsed.snapshot_root, meta.snapshot_root);
assert_eq!(parsed.work_dir, meta.work_dir);
assert_eq!(parsed.lower_dir, meta.lower_dir);
assert_eq!(parsed.mount_target, meta.mount_target);
}
#[test]
fn test_metadata_deserialization_from_fixture() {
let json = r#"{
"type": "overlay",
"snapshot_upper": "/var/lib/repo-fuse/instance/worktrees/abc123/upper",
"work_dir": "/var/lib/repo-fuse/instance/worktrees/abc123/work",
"lower_dir": "/var/lib/repo-fuse/instance/fuse-lower",
"mount_target": "/home/user/.grok/worktrees/abc123",
"created_at": "2026-02-19T22:38:00Z"
}"#;
let meta: OverlayMetadata = serde_json::from_str(json).unwrap();
assert_eq!(meta.kind, "overlay");
// "snapshot_upper" in JSON is deserialized into snapshot_root via serde alias
assert_eq!(
meta.snapshot_root,
PathBuf::from("/var/lib/repo-fuse/instance/worktrees/abc123/upper")
);
}
#[test]
fn test_unix_timestamp_string() {
let ts = unix_timestamp_string();
assert!(ts.contains("s-since-epoch"));
}
#[test]
fn test_overlay_worktree_result_debug() {
let result = OverlayWorktreeResult {
worktree_path: PathBuf::from("/dest"),
snapshot_root: PathBuf::from("/snap/root"),
work_dir: PathBuf::from("/snap/work"),
};
let debug = format!("{:?}", result);
assert!(debug.contains("OverlayWorktreeResult"));
assert!(debug.contains("/dest"));
}
#[test]
fn test_try_remove_from_mountinfo_no_overlay() {
// When the target isn't an overlay mount, should return None.
let result = try_remove_from_mountinfo(Path::new("/tmp/nonexistent"), None);
assert!(result.is_ok());
assert!(result.unwrap().is_none());
}
#[test]
fn test_try_remove_from_metadata_no_local() {
// When /local doesn't exist or has no repo-fuse dirs, should return None.
let result = try_remove_from_metadata(Path::new("/tmp/nonexistent-worktree"), None);
assert!(result.is_ok());
assert!(result.unwrap().is_none());
}
#[test]
fn test_metadata_written_to_wt_base() {
// Verify that write_metadata creates the file in wt_base, not inside the snapshot.
let tmp = tempfile::TempDir::new().unwrap();
let wt_base = tmp.path().join("wt_base");
let snapshot_root = wt_base.join("root");
let work_dir = wt_base.join("work");
std::fs::create_dir_all(&snapshot_root).unwrap();
std::fs::create_dir_all(&work_dir).unwrap();
write_metadata(
&wt_base,
&snapshot_root,
&work_dir,
Path::new("/lower"),
Path::new("/mount/target"),
)
.unwrap();
// Metadata should be at wt_base level.
let meta_path = wt_base.join(METADATA_FILENAME);
assert!(meta_path.exists(), "metadata file should exist at wt_base");
// Should NOT be inside snapshot_root.
let wrong_path = snapshot_root.join(METADATA_FILENAME);
assert!(
!wrong_path.exists(),
"metadata should not be in snapshot_root"
);
// Verify content round-trips.
let content = std::fs::read_to_string(&meta_path).unwrap();
let meta: OverlayMetadata = serde_json::from_str(&content).unwrap();
assert_eq!(meta.kind, "overlay");
assert_eq!(meta.snapshot_root, snapshot_root);
assert_eq!(meta.mount_target, PathBuf::from("/mount/target"));
}
#[test]
fn test_cleanup_orphaned_no_local_dir() {
// Hosts without a `/local` overlay root should return an empty report.
let report = cleanup_orphaned_overlay_snapshots();
assert_eq!(report.removed, 0);
assert_eq!(report.errors, 0);
}
#[test]
fn test_metadata_scan_finds_matching_target() {
// Verify metadata deserialization + match logic without full removal
// (full removal needs a live btrfs host).
let json = r#"{
"type": "overlay",
"snapshot_upper": "/var/lib/repo-fuse/instance/worktrees/wt1/upper",
"work_dir": "/var/lib/repo-fuse/instance/worktrees/wt1/work",
"lower_dir": "/var/lib/repo-fuse/instance/fuse-lower",
"mount_target": "/home/user/.grok/worktrees/wt1",
"created_at": "1740000000s-since-epoch"
}"#;
let meta: OverlayMetadata = serde_json::from_str(json).unwrap();
assert_eq!(meta.kind, "overlay");
assert_eq!(
meta.mount_target,
PathBuf::from("/home/user/.grok/worktrees/wt1")
);
// "snapshot_upper" in JSON maps to snapshot_root via serde alias
assert_eq!(
meta.snapshot_root,
PathBuf::from("/var/lib/repo-fuse/instance/worktrees/wt1/upper")
);
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,7 @@
/// Return current time as a unix timestamp string (e.g., `"1740000000s-since-epoch"`).
pub(crate) fn unix_timestamp_string() -> String {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
format!("{}s-since-epoch", now.as_secs())
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,64 @@
//! Worktree execution planning.
//!
//! `WorktreePlan` makes the worktree creation pipeline explicit and testable.
use std::path::PathBuf;
use std::sync::Arc;
use tokio_util::sync::CancellationToken;
use crate::{BtrfsDelegate, CreationMode, IgnoredFilesMode, WorkingTreeMode};
#[derive(Clone)]
pub(crate) struct WorktreePlan {
// Note: manual Debug impl below (Arc<dyn BtrfsDelegate> isn't Debug)
pub source: PathBuf,
pub dest: PathBuf,
pub git_ref: String,
pub parallelism: usize,
pub channel_buffer: usize,
pub working_tree: WorkingTreeMode,
pub ignored_files: IgnoredFilesMode,
pub ignored_parallelism: usize,
/// Strategy for worktree creation (linked, standalone, or git checkout).
pub creation_mode: CreationMode,
/// Cancellation token for aborting file copy mid-flight.
pub cancellation_token: CancellationToken,
/// Optional delegate for privileged btrfs operations (used when the caller
/// lacks CAP_SYS_ADMIN, e.g., inside a bwrap sandbox).
/// Only read on Linux (in `try_btrfs_delegate`).
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
pub btrfs_delegate: Option<Arc<dyn BtrfsDelegate>>,
}
impl std::fmt::Debug for WorktreePlan {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("WorktreePlan")
.field("source", &self.source)
.field("dest", &self.dest)
.field("git_ref", &self.git_ref)
.field("parallelism", &self.parallelism)
.field("working_tree", &self.working_tree)
.field("creation_mode", &self.creation_mode)
.field("has_btrfs_delegate", &self.btrfs_delegate.is_some())
.finish()
}
}
impl WorktreePlan {
pub(crate) fn effective_parallelism(&self) -> usize {
if self.parallelism == 0 {
num_cpus::get()
} else {
self.parallelism
}
}
pub(crate) fn effective_ignored_parallelism(&self) -> usize {
if self.ignored_parallelism == 0 {
num_cpus::get()
} else {
self.ignored_parallelism
}
}
}