Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions crates/but/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions crates/but/src/args/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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)]
Expand Down
35 changes: 35 additions & 0 deletions crates/but/src/args/worktree.rs
Original file line number Diff line number Diff line change
@@ -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.
Comment on lines +21 to +25
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,
},
}
1 change: 1 addition & 0 deletions crates/but/src/command/help.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down
3 changes: 2 additions & 1 deletion crates/but/src/command/mod.rs
Original file line number Diff line number Diff line change
@@ -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;
Comment on lines 1 to +4

pub mod agent;
pub mod alias;
Expand Down
192 changes: 192 additions & 0 deletions crates/but/src/command/worktree.rs
Original file line number Diff line number Diff line change
@@ -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()?;
Comment on lines +14 to +16
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<gix::ObjectId> {
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)
)
}
Comment on lines +110 to +115
}

/// 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
}
Comment on lines +147 to +158

/// 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",
))
}
9 changes: 8 additions & 1 deletion crates/but/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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"),
Expand Down
2 changes: 2 additions & 0 deletions crates/but/src/utils/metrics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down
Loading