Publish harness and TUI open-source
initial sync from the monorepo
This commit is contained in:
commit
c68e39f604
2734 changed files with 1437016 additions and 0 deletions
3607
crates/codegen/xai-fast-worktree/src/api.rs
Normal file
3607
crates/codegen/xai-fast-worktree/src/api.rs
Normal file
File diff suppressed because it is too large
Load diff
214
crates/codegen/xai-fast-worktree/src/bin/cli.rs
Normal file
214
crates/codegen/xai-fast-worktree/src/bin/cli.rs
Normal 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(())
|
||||
}
|
||||
808
crates/codegen/xai-fast-worktree/src/bin/pool_perf_bench.rs
Normal file
808
crates/codegen/xai-fast-worktree/src/bin/pool_perf_bench.rs
Normal 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(())
|
||||
}
|
||||
728
crates/codegen/xai-fast-worktree/src/btrfs/detect.rs
Normal file
728
crates/codegen/xai-fast-worktree/src/btrfs/detect.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
24
crates/codegen/xai-fast-worktree/src/btrfs/mod.rs
Normal file
24
crates/codegen/xai-fast-worktree/src/btrfs/mod.rs
Normal 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,
|
||||
};
|
||||
1048
crates/codegen/xai-fast-worktree/src/btrfs/snapshot.rs
Normal file
1048
crates/codegen/xai-fast-worktree/src/btrfs/snapshot.rs
Normal file
File diff suppressed because it is too large
Load diff
117
crates/codegen/xai-fast-worktree/src/copy/cow.rs
Normal file
117
crates/codegen/xai-fast-worktree/src/copy/cow.rs
Normal 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")
|
||||
);
|
||||
}
|
||||
}
|
||||
442
crates/codegen/xai-fast-worktree/src/copy/engine.rs
Normal file
442
crates/codegen/xai-fast-worktree/src/copy/engine.rs
Normal 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")
|
||||
);
|
||||
}
|
||||
}
|
||||
569
crates/codegen/xai-fast-worktree/src/copy/gitdir.rs
Normal file
569
crates/codegen/xai-fast-worktree/src/copy/gitdir.rs
Normal 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")
|
||||
);
|
||||
}
|
||||
}
|
||||
15
crates/codegen/xai-fast-worktree/src/copy/mod.rs
Normal file
15
crates/codegen/xai-fast-worktree/src/copy/mod.rs
Normal 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;
|
||||
90
crates/codegen/xai-fast-worktree/src/copy/shard.rs
Normal file
90
crates/codegen/xai-fast-worktree/src/copy/shard.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
112
crates/codegen/xai-fast-worktree/src/copy/skip.rs
Normal file
112
crates/codegen/xai-fast-worktree/src/copy/skip.rs
Normal 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)"
|
||||
);
|
||||
}
|
||||
}
|
||||
77
crates/codegen/xai-fast-worktree/src/copy/types.rs
Normal file
77
crates/codegen/xai-fast-worktree/src/copy/types.rs
Normal 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>,
|
||||
}
|
||||
135
crates/codegen/xai-fast-worktree/src/copy/worker.rs
Normal file
135
crates/codegen/xai-fast-worktree/src/copy/worker.rs
Normal 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
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
413
crates/codegen/xai-fast-worktree/src/db/mod.rs
Normal file
413
crates/codegen/xai-fast-worktree/src/db/mod.rs
Normal 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;
|
||||
231
crates/codegen/xai-fast-worktree/src/db/queries.rs
Normal file
231
crates/codegen/xai-fast-worktree/src/db/queries.rs
Normal 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)
|
||||
}
|
||||
35
crates/codegen/xai-fast-worktree/src/db/schema.rs
Normal file
35
crates/codegen/xai-fast-worktree/src/db/schema.rs
Normal 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";
|
||||
690
crates/codegen/xai-fast-worktree/src/db/tests.rs
Normal file
690
crates/codegen/xai-fast-worktree/src/db/tests.rs
Normal 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");
|
||||
}
|
||||
327
crates/codegen/xai-fast-worktree/src/discovery.rs
Normal file
327
crates/codegen/xai-fast-worktree/src/discovery.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
1227
crates/codegen/xai-fast-worktree/src/git/checkout.rs
Normal file
1227
crates/codegen/xai-fast-worktree/src/git/checkout.rs
Normal file
File diff suppressed because it is too large
Load diff
151
crates/codegen/xai-fast-worktree/src/git/discovery.rs
Normal file
151
crates/codegen/xai-fast-worktree/src/git/discovery.rs
Normal 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()));
|
||||
}
|
||||
}
|
||||
307
crates/codegen/xai-fast-worktree/src/git/index.rs
Normal file
307
crates/codegen/xai-fast-worktree/src/git/index.rs
Normal 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(())
|
||||
}
|
||||
20
crates/codegen/xai-fast-worktree/src/git/mod.rs
Normal file
20
crates/codegen/xai-fast-worktree/src/git/mod.rs
Normal 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;
|
||||
106
crates/codegen/xai-fast-worktree/src/git/status.rs
Normal file
106
crates/codegen/xai-fast-worktree/src/git/status.rs
Normal 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,
|
||||
},
|
||||
})
|
||||
}
|
||||
30
crates/codegen/xai-fast-worktree/src/git/worktree.rs
Normal file
30
crates/codegen/xai-fast-worktree/src/git/worktree.rs
Normal 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(())
|
||||
}
|
||||
67
crates/codegen/xai-fast-worktree/src/lib.rs
Normal file
67
crates/codegen/xai-fast-worktree/src/lib.rs
Normal 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())
|
||||
}
|
||||
531
crates/codegen/xai-fast-worktree/src/mount_info.rs
Normal file
531
crates/codegen/xai-fast-worktree/src/mount_info.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
190
crates/codegen/xai-fast-worktree/src/overlay/detect.rs
Normal file
190
crates/codegen/xai-fast-worktree/src/overlay/detect.rs
Normal 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.
|
||||
}
|
||||
}
|
||||
15
crates/codegen/xai-fast-worktree/src/overlay/mod.rs
Normal file
15
crates/codegen/xai-fast-worktree/src/overlay/mod.rs
Normal 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,
|
||||
};
|
||||
769
crates/codegen/xai-fast-worktree/src/overlay/snapshot.rs
Normal file
769
crates/codegen/xai-fast-worktree/src/overlay/snapshot.rs
Normal 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")
|
||||
);
|
||||
}
|
||||
}
|
||||
1912
crates/codegen/xai-fast-worktree/src/sync.rs
Normal file
1912
crates/codegen/xai-fast-worktree/src/sync.rs
Normal file
File diff suppressed because it is too large
Load diff
7
crates/codegen/xai-fast-worktree/src/util.rs
Normal file
7
crates/codegen/xai-fast-worktree/src/util.rs
Normal 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())
|
||||
}
|
||||
1731
crates/codegen/xai-fast-worktree/src/worktree/execute.rs
Normal file
1731
crates/codegen/xai-fast-worktree/src/worktree/execute.rs
Normal file
File diff suppressed because it is too large
Load diff
1352
crates/codegen/xai-fast-worktree/src/worktree/mod.rs
Normal file
1352
crates/codegen/xai-fast-worktree/src/worktree/mod.rs
Normal file
File diff suppressed because it is too large
Load diff
64
crates/codegen/xai-fast-worktree/src/worktree/plan.rs
Normal file
64
crates/codegen/xai-fast-worktree/src/worktree/plan.rs
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue