diff --git a/Cargo.lock b/Cargo.lock index 4d1a1061193..f0bc021d602 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -890,6 +890,7 @@ dependencies = [ "gix", "indexmap 2.14.0", "itertools", + "libc", "machine-uid", "minus", "nonempty", diff --git a/Cargo.toml b/Cargo.toml index 530318316f7..d093534cae7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -267,6 +267,7 @@ base64 = "0.22" bitflags = "2.11.1" notify = "8.2.0" snapbox = { version = "0.6.23", features = ["json"] } +libc = "0.2" url = "2.5.7" petgraph = { version = "0.8.3", default-features = false, features = [ "stable_graph", diff --git a/crates/but/Cargo.toml b/crates/but/Cargo.toml index c1a51430e79..79b2d3a9c6c 100644 --- a/crates/but/Cargo.toml +++ b/crates/but/Cargo.toml @@ -112,6 +112,7 @@ rmcp.workspace = true url.workspace = true command-group = { version = "5.0.1", features = ["with-tokio"] } gix = { workspace = true, features = ["tracing", "tracing-detail"] } +libc.workspace = true colored = "3.0.0" serde_json.workspace = true boolean-enums.workspace = true diff --git a/crates/but/src/args/mod.rs b/crates/but/src/args/mod.rs index 8c60dabccce..fce449f2d8b 100644 --- a/crates/but/src/args/mod.rs +++ b/crates/but/src/args/mod.rs @@ -446,6 +446,10 @@ pub enum Subcommands { #[cfg_attr(feature = "raw-clap-docs", clap(verbatim_doc_comment))] Unapply(unapply::Platform), + /// Create linked git worktrees that GitButler recognizes. + #[cfg_attr(feature = "raw-clap-docs", clap(verbatim_doc_comment))] + Worktree(worktree::Platform), + #[cfg(feature = "legacy")] #[cfg_attr(feature = "raw-clap-docs", clap(verbatim_doc_comment))] Apply(apply::Platform), @@ -1097,6 +1101,7 @@ pub mod uncommit; #[cfg(feature = "legacy")] pub mod undo; pub mod update; +pub mod worktree; pub mod actions { #[derive(Debug, clap::Parser)] diff --git a/crates/but/src/args/worktree.rs b/crates/but/src/args/worktree.rs new file mode 100644 index 00000000000..a49689618f3 --- /dev/null +++ b/crates/but/src/args/worktree.rs @@ -0,0 +1,35 @@ +use std::path::PathBuf; + +/// Create linked git worktrees that GitButler recognizes. +/// +/// A worktree created here is an ordinary git worktree — `but status` discovers it through git, +/// so nothing else is needed to make it visible. +#[derive(Debug, clap::Parser)] +#[cfg_attr(feature = "raw-clap-docs", clap(verbatim_doc_comment))] +#[deny(missing_docs)] +pub struct Platform { + /// The subcommand to run. + #[clap(subcommand)] + pub cmd: Subcommands, +} + +/// The `but worktree` subcommands. +#[derive(Debug, clap::Subcommand)] +pub enum Subcommands { + /// Create a worktree at `path`, checked out at the workspace's base commit. + /// + /// With `--cow`, the worktree is populated by cloning the current working directory + /// copy-on-write instead of checking every file out. On a filesystem that supports it + /// (APFS, btrfs, XFS with reflinks) the clone is near-instant and costs almost no disk, + /// and it carries untracked build output — `target/`, `node_modules/` — across with it, + /// so builds in the new worktree start warm. + New { + /// Where to create the worktree. + path: PathBuf, + /// Populate the worktree with a copy-on-write clone of the current working directory. + /// + /// Falls back to a normal checkout, with a notice, when the filesystem cannot clone. + #[clap(short = 'c', long = "cow")] + cow: bool, + }, +} diff --git a/crates/but/src/command/help.rs b/crates/but/src/command/help.rs index 32e9747d58f..073a214cbe9 100644 --- a/crates/but/src/command/help.rs +++ b/crates/but/src/command/help.rs @@ -130,6 +130,7 @@ fn print_grouped_with_truncation( SubcommandDiscriminant::Discard => Group::BranchingAndCommitting, #[cfg(feature = "legacy")] SubcommandDiscriminant::Unapply => Group::BranchingAndCommitting, + SubcommandDiscriminant::Worktree => Group::OtherCommands, #[cfg(feature = "legacy")] SubcommandDiscriminant::Apply => Group::BranchingAndCommitting, #[cfg(feature = "legacy")] diff --git a/crates/but/src/command/mod.rs b/crates/but/src/command/mod.rs index 907c92bbc85..490f3d7074f 100644 --- a/crates/but/src/command/mod.rs +++ b/crates/but/src/command/mod.rs @@ -1,6 +1,7 @@ //! A place for each command, i.e. `but foo` as `pub mod foo` here. -#[cfg(feature = "legacy")] pub mod legacy; +#[cfg(feature = "legacy")] +pub mod worktree; pub mod agent; pub mod alias; diff --git a/crates/but/src/command/worktree.rs b/crates/but/src/command/worktree.rs new file mode 100644 index 00000000000..a9f3e560b56 --- /dev/null +++ b/crates/but/src/command/worktree.rs @@ -0,0 +1,192 @@ +//! Implementation of the `but worktree` command. +//! +//! Worktrees created here are ordinary linked git worktrees. GitButler discovers them through +//! git's own registry and seeds their `HEAD`s as extra traversal tips, so `but status` shows one +//! as soon as it exists — this command owns creation only, never the worktree's lifecycle. + +use std::path::Path; + +use anyhow::{Context as _, Result, bail}; +use but_ctx::Context; + +use crate::utils::WriteWithUtils; + +/// Create a worktree at `path`, checked out at the workspace's base commit. +pub fn new(ctx: &Context, out: &mut dyn WriteWithUtils, path: &Path, cow: bool) -> Result<()> { + let repo = ctx.repo.get()?; + let base = workspace_base(ctx)?; + let source = ctx.workdir_or_fail()?; + + if path.exists() { + bail!("'{}' already exists", path.display()); + } + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or(Path::new(".")); + std::fs::create_dir_all(parent) + .with_context(|| format!("failed to create '{}'", parent.display()))?; + + // The clone is only worth attempting when the destination can actually share blocks with the + // source; a cross-filesystem clone silently costs a full copy, which is what we set out to + // avoid. + let clone = cow && cow_supported(&source, parent); + if cow && !clone { + writeln!( + out, + "Copy-on-write is unavailable here (unsupported filesystem, or a different one than the \ + source); falling back to a normal checkout." + )?; + } + + if clone { + git_worktree_add(repo.common_dir(), path, base, WithCheckout::No)?; + clone_worktree_contents(&source, path)?; + // The clone left the worktree holding this working directory's content while its index is + // empty. Tell git what it is looking at, then move it to the base commit: only the paths + // that actually differ get rewritten, and untracked build output is left alone. + let head = repo.head_id()?.detach(); + git_in_worktree(path, &["reset", "--mixed", &head.to_string()])?; + git_in_worktree(path, &["reset", "--hard", &base.to_string()])?; + } else { + git_worktree_add(repo.common_dir(), path, base, WithCheckout::Yes)?; + } + + writeln!(out, "Created worktree at: {}", path.display())?; + writeln!(out, "Base: {base}")?; + if clone { + writeln!( + out, + "Populated by copy-on-write clone; untracked build output came along." + )?; + } + Ok(()) +} + +/// The commit every applied branch in the workspace forks from. +fn workspace_base(ctx: &Context) -> Result { + let guard = ctx.shared_worktree_access(); + let (_repo, ws, _db) = ctx.workspace_and_db_with_perm(guard.read_permission())?; + ws.lower_bound + .context("the workspace has no common base to create a worktree from") +} + +enum WithCheckout { + Yes, + No, +} + +/// Create the linked worktree, detached at `commit`. +fn git_worktree_add( + common_dir: &Path, + path: &Path, + commit: gix::ObjectId, + checkout: WithCheckout, +) -> Result<()> { + let mut args: Vec<&str> = vec!["worktree", "add", "--detach"]; + if matches!(checkout, WithCheckout::No) { + args.push("--no-checkout"); + } + let commit = commit.to_string(); + run_git( + common_dir, + &args, + &[path.as_os_str().to_owned(), commit.into()], + ) +} + +fn git_in_worktree(worktree: &Path, args: &[&str]) -> Result<()> { + run_git(worktree, args, &[]) +} + +fn run_git(dir: &Path, args: &[&str], trailing: &[std::ffi::OsString]) -> Result<()> { + let mut command = + std::process::Command::from(gix::command::prepare(gix::path::env::exe_invocation())); + command.current_dir(dir).args(args).args(trailing); + let output = command.stderr(std::process::Stdio::piped()).output()?; + if output.status.success() { + Ok(()) + } else { + bail!( + "git {} failed\n\n{}", + args.join(" "), + String::from_utf8_lossy(&output.stderr) + ) + } +} + +/// Clone every entry of `source` into `dest`, skipping the `.git` file that marks the worktree. +fn clone_worktree_contents(source: &Path, dest: &Path) -> Result<()> { + for entry in std::fs::read_dir(source) + .with_context(|| format!("failed to read '{}'", source.display()))? + { + let entry = entry?; + // The linked worktree has its own `.git` file, pointing at its admin directory. + if entry.file_name() == ".git" { + continue; + } + let target = dest.join(entry.file_name()); + if target.exists() { + continue; + } + clone_path(&entry.path(), &target).with_context(|| { + format!( + "failed to clone '{}' to '{}'", + entry.path().display(), + target.display() + ) + })?; + } + Ok(()) +} + +/// Whether a copy-on-write clone from `source` into `dest_parent` will actually share blocks. +/// +/// Answered by cloning a probe file rather than by inspecting filesystem types: the filesystem may +/// support cloning while these two paths sit on different volumes, where it cannot help. +fn cow_supported(source: &Path, dest_parent: &Path) -> bool { + let probe = source.join(".but-cow-probe"); + let clone = dest_parent.join(".but-cow-probe-clone"); + let _ = std::fs::remove_file(&clone); + if std::fs::write(&probe, b"probe").is_err() { + return false; + } + let supported = clone_path(&probe, &clone).is_ok(); + let _ = std::fs::remove_file(&probe); + let _ = std::fs::remove_file(&clone); + supported +} + +/// Clone `source` to `dest` sharing storage, recursively for a directory. +/// +/// `clonefile` has no safe wrapper in the dependency tree, and cloning a directory tree in one +/// syscall is the whole point of this path — walking it file by file would cost one syscall per +/// file across build output. +#[cfg(target_os = "macos")] +#[allow(unsafe_code)] +fn clone_path(source: &Path, dest: &Path) -> std::io::Result<()> { + use std::ffi::CString; + use std::os::unix::ffi::OsStrExt as _; + + let source = CString::new(source.as_os_str().as_bytes()) + .map_err(|_| std::io::Error::other("path contains an interior nul byte"))?; + let dest = CString::new(dest.as_os_str().as_bytes()) + .map_err(|_| std::io::Error::other("path contains an interior nul byte"))?; + // SAFETY: both pointers come from `CString`s that outlive the call, so they are valid, + // NUL-terminated C strings; `clonefile` only reads them. A flags value of 0 is always valid. + // It clones a directory tree in one call, and refuses if the destination already exists. + let status = unsafe { libc::clonefile(source.as_ptr(), dest.as_ptr(), 0) }; + if status == 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error()) + } +} + +#[cfg(not(target_os = "macos"))] +fn clone_path(_source: &Path, _dest: &Path) -> std::io::Result<()> { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "copy-on-write cloning is not implemented on this platform", + )) +} diff --git a/crates/but/src/lib.rs b/crates/but/src/lib.rs index 01fe4b92177..fe6d7e45e7d 100644 --- a/crates/but/src/lib.rs +++ b/crates/but/src/lib.rs @@ -35,7 +35,7 @@ use clap::{CommandFactory, FromArgMatches as _, Parser as _}; pub mod args; use args::{ Args, OutputFormat, Subcommands, actions, agent, alias as alias_args, branch, forge, - update as update_args, + update as update_args, worktree, }; use but_settings::AppSettings; use gix::date::time::CustomFormat; @@ -791,6 +791,7 @@ async fn match_subcommand( Subcommands::_Expand { .. } | Subcommands::Alias(..) => { but_ctx::Context::discover(&args.current_dir)? } + Subcommands::Worktree(..) => setup::init_ctx(&args, InitCtxOptions::default(), out)?, Subcommands::Branch(branch::Platform { ref cmd }) => setup::init_ctx( &args, match cmd { @@ -942,6 +943,12 @@ async fn match_subcommand( None } }, + Subcommands::Worktree(worktree::Platform { cmd }) => match cmd { + worktree::Subcommands::New { path, cow } => { + command::worktree::new(&ctx, out, &path, cow)?; + None + } + }, Subcommands::Branch(branch::Platform { cmd }) => match cmd { #[cfg(not(feature = "legacy"))] None => todo!("implement list and call recursively"), diff --git a/crates/but/src/utils/metrics.rs b/crates/but/src/utils/metrics.rs index 010e80e6797..bf949880eb9 100644 --- a/crates/but/src/utils/metrics.rs +++ b/crates/but/src/utils/metrics.rs @@ -135,6 +135,8 @@ impl Subcommands { }, #[cfg(feature = "legacy")] Subcommands::Unapply { .. } => BranchUnapply, + // Worktree creation is local plumbing; it has no metric of its own yet. + Subcommands::Worktree(_) => Unknown, #[cfg(feature = "legacy")] Subcommands::Apply { .. } => BranchApply, #[cfg(feature = "legacy")]