Skip to content
Draft
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.

3 changes: 2 additions & 1 deletion crates/but-api/src/commands/worktree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,13 @@ use crate::error::Error;
pub fn worktree_new(
project_id: ProjectId,
reference: gix::refs::FullName,
name: Option<String>,
) -> Result<NewWorktreeOutcome, Error> {
let project = gitbutler_project::get(project_id)?;
let guard = project.exclusive_worktree_access();
let mut ctx = CommandContext::open(&project, AppSettings::load_from_default_path_creating()?)?;

but_worktrees::new::worktree_new(&mut ctx, guard.read_permission(), reference.as_ref())
but_worktrees::new::worktree_new(&mut ctx, guard.read_permission(), reference.as_ref(), name)
.map_err(Into::into)
}

Expand Down
1 change: 1 addition & 0 deletions crates/but-worktrees/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ gitbutler-stack.workspace = true
gitbutler-oxidize.workspace = true
gitbutler-workspace.workspace = true
gitbutler-branch-actions.workspace = true
gitbutler-serde.workspace = true
serde.workspace = true
uuid.workspace = true
bstr.workspace = true
Expand Down
4 changes: 3 additions & 1 deletion crates/but-worktrees/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ pub mod new;

/// A worktree name.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct WorktreeId(BString);
pub struct WorktreeId(#[serde(with = "gitbutler_serde::bstring_lossy")] BString);

impl WorktreeId {
/// Create a new worktree ID using a random UUID.
Expand Down Expand Up @@ -79,7 +79,9 @@ pub struct Worktree {
/// The canonicalized filesystem path to the worktree.
pub path: PathBuf,
/// The git reference this worktree was created from.
#[serde(with = "gitbutler_serde::fullname_opt")]
pub created_from_ref: Option<gix::refs::FullName>,
/// The base which we will use in a cherry-pick.
#[serde(with = "gitbutler_serde::object_id_opt")]
pub base: Option<gix::ObjectId>,
}
66 changes: 63 additions & 3 deletions crates/but-worktrees/src/new.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,54 @@ use crate::{Worktree, WorktreeId, WorktreeMeta, db::save_worktree_meta, git::git
/// This gets used as a public API in the CLI so be careful when modifying.
pub struct NewWorktreeOutcome {
pub created: Worktree,
/// The git branch name created for this worktree (e.g., "gitbutler/worktree/name-a")
pub branch_name: String,
/// The commit message (first line) of the base commit
pub base_commit_message: Option<String>,
}

/// Generates a unique branch name by appending letters (-a, -b, -c, etc.) if needed.
///
/// Returns the deduplicated name without the "gitbutler/worktree/" prefix.
fn deduplicate_branch_name(repo: &gix::Repository, base_name: &str) -> Result<String> {
let mut name = base_name.to_string();

// Check if the base name already exists
let full_ref = format!("refs/heads/gitbutler/worktree/{}", name);
if gix::refs::FullName::try_from(full_ref.clone())
.ok()
.and_then(|r| repo.find_reference(&r).ok())
.is_none()
{
return Ok(name);
}

// Try appending letters a-z
for c in 'a'..='z' {
name = format!("{}-{}", base_name, c);
let full_ref = format!("refs/heads/gitbutler/worktree/{}", name);
if gix::refs::FullName::try_from(full_ref.clone())
.ok()
.and_then(|r| repo.find_reference(&r).ok())
.is_none()
{
return Ok(name);
}
}

// If we exhausted all letters, fall back to UUID
Ok(WorktreeId::new().as_str().to_string())
}

/// Creates a new worktree off of a branches given name.
///
/// # Parameters
/// - `name`: Optional custom name for the worktree branch. If None, generates a UUID.
pub fn worktree_new(
ctx: &mut CommandContext,
perm: &WorktreeReadPermission,
refname: &gix::refs::FullNameRef,
name: Option<String>,
) -> Result<NewWorktreeOutcome> {
let repo = ctx.gix_repo_for_merging()?;

Expand All @@ -30,12 +71,22 @@ pub fn worktree_new(

let to_checkout = repo.find_reference(refname)?.id();

// Generate a new worktree ID
let id = WorktreeId::new();
// Determine the branch name to use
let base_name = if let Some(custom_name) = name {
custom_name
} else {
refname.shorten().to_string()
};

// Deduplicate the branch name
let deduplicated_name = deduplicate_branch_name(&repo, &base_name)?;

// Generate a new worktree ID from the deduplicated name
let id = WorktreeId::from_bstr(deduplicated_name.clone());

let path = worktree_path(ctx.project(), &id);
let branch_name =
gix::refs::PartialName::try_from(format!("gitbutler/worktree/{}", id.as_str()))?;
gix::refs::PartialName::try_from(format!("gitbutler/worktree/{}", deduplicated_name))?;

git_worktree_add(
&ctx.project().common_git_dir()?,
Expand All @@ -46,6 +97,13 @@ pub fn worktree_new(

let path = path.canonicalize()?;

// Get the commit message for the base commit
let base_commit_message = repo
.find_object(to_checkout.detach())
.ok()
.and_then(|obj| obj.try_into_commit().ok())
.and_then(|commit| commit.message().ok().map(|msg| msg.title.to_string()));

let meta = WorktreeMeta {
id: id.clone(),
created_from_ref: Some(refname.to_owned()),
Expand All @@ -61,6 +119,8 @@ pub fn worktree_new(
path,
base: Some(to_checkout.detach()),
},
branch_name: branch_name.as_ref().to_string(),
base_commit_message,
})
}

Expand Down
35 changes: 30 additions & 5 deletions crates/but-worktrees/tests/worktree/integrate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,12 @@ fn test_create_unrelated_change_and_reintroduce() -> anyhow::Result<()> {

let feature_a_name = gix::refs::FullName::try_from("refs/heads/feature-a")?;
let feature_b_name = gix::refs::FullName::try_from("refs/heads/feature-b")?;
let a = worktree_new(&mut ctx, guard.read_permission(), feature_a_name.as_ref())?;
let a = worktree_new(
&mut ctx,
guard.read_permission(),
feature_a_name.as_ref(),
None,
)?;

bash_at(
&a.created.path,
Expand Down Expand Up @@ -99,7 +104,12 @@ fn test_causes_conflicts_above() -> anyhow::Result<()> {

let feature_a_name = gix::refs::FullName::try_from("refs/heads/feature-a")?;
let feature_b_name = gix::refs::FullName::try_from("refs/heads/feature-b")?;
let a = worktree_new(&mut ctx, guard.read_permission(), feature_a_name.as_ref())?;
let a = worktree_new(
&mut ctx,
guard.read_permission(),
feature_a_name.as_ref(),
None,
)?;

bash_at(
&a.created.path,
Expand Down Expand Up @@ -182,7 +192,12 @@ fn test_causes_workdir_conflicts_simple() -> anyhow::Result<()> {
let mut guard = ctx.project().exclusive_worktree_access();

let feature_b_name = gix::refs::FullName::try_from("refs/heads/feature-b")?;
let b = worktree_new(&mut ctx, guard.read_permission(), feature_b_name.as_ref())?;
let b = worktree_new(
&mut ctx,
guard.read_permission(),
feature_b_name.as_ref(),
None,
)?;

bash_at(&path, r#"echo "qux" > foo.txt"#)?;
bash_at(
Expand Down Expand Up @@ -239,7 +254,12 @@ fn test_causes_workdir_conflicts_complex() -> anyhow::Result<()> {

let feature_a_name = gix::refs::FullName::try_from("refs/heads/feature-a")?;
let feature_b_name = gix::refs::FullName::try_from("refs/heads/feature-b")?;
let a = worktree_new(&mut ctx, guard.read_permission(), feature_a_name.as_ref())?;
let a = worktree_new(
&mut ctx,
guard.read_permission(),
feature_a_name.as_ref(),
None,
)?;

bash_at(&path, r#"echo "qux" > foo.txt"#)?;
bash_at(
Expand Down Expand Up @@ -310,7 +330,12 @@ fn test_causes_workspace_conflict() -> anyhow::Result<()> {
let feature_a_name = gix::refs::FullName::try_from("refs/heads/feature-a")?;
let feature_b_name = gix::refs::FullName::try_from("refs/heads/feature-b")?;
let feature_c_name = gix::refs::FullName::try_from("refs/heads/feature-c")?;
let c = worktree_new(&mut ctx, guard.read_permission(), feature_c_name.as_ref())?;
let c = worktree_new(
&mut ctx,
guard.read_permission(),
feature_c_name.as_ref(),
None,
)?;

bash_at(
&c.created.path,
Expand Down
87 changes: 75 additions & 12 deletions crates/but-worktrees/tests/worktree/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ mod worktree_new {
&mut test_ctx.ctx,
guard.read_permission(),
feature_a_name.as_ref(),
None,
)?;

assert_eq!(
Expand Down Expand Up @@ -118,6 +119,7 @@ mod worktree_new {
&mut test_ctx.ctx,
guard.read_permission(),
feature_b_name.as_ref(),
None,
)?;

assert_eq!(
Expand Down Expand Up @@ -166,6 +168,7 @@ mod worktree_new {
&mut test_ctx.ctx,
guard.read_permission(),
feature_c_name.as_ref(),
None,
)?;

assert_eq!(
Expand Down Expand Up @@ -205,11 +208,36 @@ mod worktree_list {

let feature_a_name = gix::refs::FullName::try_from("refs/heads/feature-a")?;
let feature_c_name = gix::refs::FullName::try_from("refs/heads/feature-c")?;
let a = worktree_new(&mut ctx, guard.read_permission(), feature_a_name.as_ref())?;
let b = worktree_new(&mut ctx, guard.read_permission(), feature_a_name.as_ref())?;
let c = worktree_new(&mut ctx, guard.read_permission(), feature_a_name.as_ref())?;
let d = worktree_new(&mut ctx, guard.read_permission(), feature_a_name.as_ref())?;
let e = worktree_new(&mut ctx, guard.read_permission(), feature_c_name.as_ref())?;
let a = worktree_new(
&mut ctx,
guard.read_permission(),
feature_a_name.as_ref(),
None,
)?;
let b = worktree_new(
&mut ctx,
guard.read_permission(),
feature_a_name.as_ref(),
None,
)?;
let c = worktree_new(
&mut ctx,
guard.read_permission(),
feature_a_name.as_ref(),
None,
)?;
let d = worktree_new(
&mut ctx,
guard.read_permission(),
feature_a_name.as_ref(),
None,
)?;
let e = worktree_new(
&mut ctx,
guard.read_permission(),
feature_c_name.as_ref(),
None,
)?;

let all = &[&a, &b, &c, &d, &e];

Expand Down Expand Up @@ -243,7 +271,12 @@ mod worktree_destroy {
let mut guard = ctx.project().exclusive_worktree_access();

let feature_a_name = gix::refs::FullName::try_from("refs/heads/feature-a")?;
let outcome = worktree_new(&mut ctx, guard.read_permission(), feature_a_name.as_ref())?;
let outcome = worktree_new(
&mut ctx,
guard.read_permission(),
feature_a_name.as_ref(),
None,
)?;

// Verify it was created
let list_before = worktree_list(&mut ctx, guard.read_permission())?;
Expand Down Expand Up @@ -275,11 +308,36 @@ mod worktree_destroy {
let feature_c_name = gix::refs::FullName::try_from("refs/heads/feature-c")?;

// Create 3 worktrees from feature-a and 2 from feature-c
worktree_new(&mut ctx, guard.read_permission(), feature_a_name.as_ref())?;
worktree_new(&mut ctx, guard.read_permission(), feature_a_name.as_ref())?;
worktree_new(&mut ctx, guard.read_permission(), feature_a_name.as_ref())?;
worktree_new(&mut ctx, guard.read_permission(), feature_c_name.as_ref())?;
worktree_new(&mut ctx, guard.read_permission(), feature_c_name.as_ref())?;
worktree_new(
&mut ctx,
guard.read_permission(),
feature_a_name.as_ref(),
None,
)?;
worktree_new(
&mut ctx,
guard.read_permission(),
feature_a_name.as_ref(),
None,
)?;
worktree_new(
&mut ctx,
guard.read_permission(),
feature_a_name.as_ref(),
None,
)?;
worktree_new(
&mut ctx,
guard.read_permission(),
feature_c_name.as_ref(),
None,
)?;
worktree_new(
&mut ctx,
guard.read_permission(),
feature_c_name.as_ref(),
None,
)?;

// Verify all 5 were created
let list_before = worktree_list(&mut ctx, guard.read_permission())?;
Expand Down Expand Up @@ -318,7 +376,12 @@ mod worktree_destroy {
let feature_b_name = gix::refs::FullName::try_from("refs/heads/feature-b")?;

// Create worktrees from feature-a
worktree_new(&mut ctx, guard.read_permission(), feature_a_name.as_ref())?;
worktree_new(
&mut ctx,
guard.read_permission(),
feature_a_name.as_ref(),
None,
)?;

// Try to destroy worktrees from feature-b (which don't exist)
let destroy_outcome = worktree_destroy_by_reference(
Expand Down
Loading
Loading