diff --git a/packages/butler/src/commands/setup.rs b/packages/butler/src/commands/setup.rs index d676642f7..42cbd5c59 100644 --- a/packages/butler/src/commands/setup.rs +++ b/packages/butler/src/commands/setup.rs @@ -40,7 +40,6 @@ impl super::RunCommand for Setup { set_base_branch( &app.gb_repository(), &app.project_repository(), - app.user(), &items[index].branch().parse()?, ) .context("failed to set target branch")?; diff --git a/packages/tauri/src/virtual_branches/base.rs b/packages/tauri/src/virtual_branches/base.rs index dbbd92531..0587edab4 100644 --- a/packages/tauri/src/virtual_branches/base.rs +++ b/packages/tauri/src/virtual_branches/base.rs @@ -5,14 +5,18 @@ use serde::Serialize; use crate::{ gb_repository, - git::{self, diff}, + git::{ + self, + diff::{self, Options}, + }, keys, project_repository::{self, LogUntil}, reader, sessions, users, + virtual_branches::branch::Ownership, }; use super::{ - branch, delete_branch, + branch, errors::{self, CreateVirtualBranchFromBranchError}, integration::GITBUTLER_INTEGRATION_REFERENCE, iterator, target, BranchId, RemoteCommit, @@ -51,30 +55,29 @@ pub fn get_base_branch_data( pub fn set_base_branch( gb_repository: &gb_repository::Repository, project_repository: &project_repository::Repository, - user: Option<&users::User>, - target_branch: &git::RemoteRefname, + target_branch_ref: &git::RemoteRefname, ) -> Result { let repo = &project_repository.git_repository; // lookup a branch by name - let branch = match repo.find_branch(&target_branch.clone().into()) { + let target_branch = match repo.find_branch(&target_branch_ref.clone().into()) { Ok(branch) => Ok(branch), Err(git::Error::NotFound(_)) => Err(errors::SetBaseBranchError::BranchNotFound( - target_branch.clone(), + target_branch_ref.clone(), )), Err(error) => Err(errors::SetBaseBranchError::Other(error.into())), }?; let remote_name = repo - .branch_remote_name(branch.refname().unwrap()) + .branch_remote_name(target_branch.refname().unwrap()) .context(format!( "failed to get remote name for branch {}", - branch.name().unwrap() + target_branch.name().unwrap() ))?; let remote = repo.find_remote(&remote_name).context(format!( "failed to find remote {} for branch {}", remote_name, - branch.name().unwrap() + target_branch.name().unwrap() ))?; let remote_url = remote .url() @@ -84,34 +87,30 @@ pub fn set_base_branch( ))? .unwrap(); - // get a list of currently active virtual branches - - // if there are no applied virtual branches, calculate the sha as the merge-base between HEAD in project_repository and this target commit - let commit = branch.peel_to_commit().context(format!( + let target_branch_head = target_branch.peel_to_commit().context(format!( "failed to peel branch {} to commit", - branch.name().unwrap() + target_branch.name().unwrap() ))?; - let mut commit_oid = commit.id(); let head_ref = repo.head().context("Failed to get HEAD reference")?; let head_name: git::Refname = head_ref .name() .context("Failed to get HEAD reference name")?; - let head_oid = head_ref + let head_commit = head_ref .peel_to_commit() - .context("Failed to peel HEAD reference to commit")? - .id(); + .context("Failed to peel HEAD reference to commit")?; - if head_oid != commit_oid { - // calculate the commit as the merge-base between HEAD in project_repository and this target commit - commit_oid = repo.merge_base(head_oid, commit_oid).context(format!( + // calculate the commit as the merge-base between HEAD in project_repository and this target commit + let commit_oid = repo + .merge_base(head_commit.id(), target_branch_head.id()) + .context(format!( "Failed to calculate merge base between {} and {}", - head_oid, commit_oid + head_commit.id(), + target_branch_head.id() ))?; - } let target = target::Target { - branch: target_branch.clone(), + branch: target_branch_ref.clone(), remote_url: remote_url.to_string(), sha: commit_oid, }; @@ -119,38 +118,83 @@ pub fn set_base_branch( let target_writer = target::Writer::new(gb_repository); target_writer.write_default(&target)?; - let current_session = gb_repository - .get_or_create_current_session() - .context("failed to get current session")?; - let current_session_reader = sessions::Reader::open(gb_repository, ¤t_session) - .context("failed to open current session for reading")?; - - let virtual_branches = iterator::BranchIterator::new(¤t_session_reader) - .context("failed to create branch iterator")? - .collect::, reader::Error>>() - .context("failed to read virtual branches")?; - - let active_virtual_branches = virtual_branches - .iter() - .filter(|branch| branch.applied) - .collect::>(); - - if active_virtual_branches.is_empty() - && !head_name - .to_string() - .eq(&GITBUTLER_INTEGRATION_REFERENCE.to_string()) + if !head_name + .to_string() + .eq(&GITBUTLER_INTEGRATION_REFERENCE.to_string()) { - let branch = create_virtual_branch_from_branch( - gb_repository, - project_repository, - &head_name, - Some(true), - user, - ) - .context("failed to create virtual branch")?; - if branch.ownership.is_empty() && branch.head == target.sha { - delete_branch(gb_repository, project_repository, &branch.id) - .context("failed to delete branch")?; + // if there are any commits on the head branch or uncommitted changes in the working directory, we need to + // put them into a virtual branch + + let wd_diff = diff::workdir(repo, &head_commit.id(), &Options::default())?; + + if !wd_diff.is_empty() || head_commit.id() != commit_oid { + let hunks_by_filepath = + super::virtual_hunks_by_filepath(&project_repository.project().path, &wd_diff); + + // assign ownership to the branch + let ownership = hunks_by_filepath.values().flatten().fold( + Ownership::default(), + |mut ownership, hunk| { + ownership.put( + &format!("{}:{}", hunk.file_path.display(), hunk.id) + .parse() + .unwrap(), + ); + ownership + }, + ); + + let now_ms = time::UNIX_EPOCH + .elapsed() + .context("failed to get elapsed time")? + .as_millis(); + + let (upstream, upstream_head) = if let git::Refname::Local(head_name) = &head_name { + let upstream_name = target_branch_ref.with_branch(head_name.branch()); + if upstream_name.eq(target_branch_ref) { + (None, None) + } else { + match repo.find_reference(&git::Refname::from(&upstream_name)) { + Ok(upstream) => { + let head = upstream + .peel_to_commit() + .map(|commit| commit.id()) + .context(format!( + "failed to peel upstream {} to commit", + upstream.name().unwrap() + ))?; + Ok((Some(upstream_name), Some(head))) + } + Err(git::Error::NotFound(_)) => Ok((None, None)), + Err(error) => Err(error), + } + .context(format!("failed to find upstream for {}", head_name))? + } + } else { + (None, None) + }; + + let branch = branch::Branch { + id: BranchId::generate(), + name: head_name.to_string().replace("refs/heads/", ""), + notes: String::new(), + applied: true, + upstream, + upstream_head, + created_timestamp_ms: now_ms, + updated_timestamp_ms: now_ms, + head: head_commit.id(), + tree: super::write_tree_onto_commit( + project_repository, + head_commit.id(), + &wd_diff, + )?, + ownership, + order: 0, + }; + + let branch_writer = branch::Writer::new(gb_repository); + branch_writer.write(&branch)?; } } diff --git a/packages/tauri/src/virtual_branches/controller.rs b/packages/tauri/src/virtual_branches/controller.rs index eb3b7befe..d7f65e5f3 100644 --- a/packages/tauri/src/virtual_branches/controller.rs +++ b/packages/tauri/src/virtual_branches/controller.rs @@ -565,14 +565,8 @@ impl ControllerInner { ) .context("failed to open gitbutler repository")?; - let target = super::set_base_branch( - &gb_repository, - &project_repository, - user.as_ref(), - target_branch, - )?; - - Ok(target) + super::set_base_branch(&gb_repository, &project_repository, target_branch) + .map_err(Into::into) } pub async fn merge_virtual_branch_upstream( diff --git a/packages/tauri/tests/common.rs b/packages/tauri/tests/common.rs index 502f70531..a81c7e01b 100644 --- a/packages/tauri/tests/common.rs +++ b/packages/tauri/tests/common.rs @@ -70,6 +70,11 @@ impl TestProject { self.local_repository.workdir().unwrap() } + pub fn push_branch(&self, branch: &git::LocalRefname) { + let mut origin = self.local_repository.find_remote("origin").unwrap(); + origin.push(&[&format!("{branch}:{branch}")], None).unwrap(); + } + pub fn push(&self) { let mut origin = self.local_repository.find_remote("origin").unwrap(); origin @@ -161,8 +166,36 @@ impl TestProject { self.local_repository.find_commit(oid) } + pub fn checkout(&self, branch: git::LocalRefname) { + let branch: git::Refname = branch.into(); + let tree = match self.local_repository.find_branch(&branch) { + Ok(branch) => branch.peel_to_tree(), + Err(git::Error::NotFound(_)) => { + let head_commit = self + .local_repository + .head() + .unwrap() + .peel_to_commit() + .unwrap(); + self.local_repository + .reference(&branch, head_commit.id(), false, "new branch") + .unwrap(); + head_commit.tree() + } + Err(error) => Err(error), + } + .unwrap(); + self.local_repository.set_head(&branch).unwrap(); + self.local_repository + .checkout_tree(&tree) + .force() + .checkout() + .unwrap(); + } + /// takes all changes in the working directory and commits them into local pub fn commit_all(&self, message: &str) -> git::Oid { + let head = self.local_repository.head().unwrap(); let mut index = self.local_repository.index().expect("failed to get index"); index .add_all(["."], git2::IndexAddOption::DEFAULT, None) @@ -172,7 +205,7 @@ impl TestProject { let signature = git::Signature::now("test", "test@email.com").unwrap(); self.local_repository .commit( - Some(&"refs/heads/master".parse().unwrap()), + head.name().as_ref(), &signature, &signature, message, diff --git a/packages/tauri/tests/virtual_branches/mod.rs b/packages/tauri/tests/virtual_branches/mod.rs index 2bd323b11..10736f2a6 100644 --- a/packages/tauri/tests/virtual_branches/mod.rs +++ b/packages/tauri/tests/virtual_branches/mod.rs @@ -3440,6 +3440,140 @@ mod init { } } + #[tokio::test] + async fn dirty_non_target() { + // a situation when you initialize project while being on the local verison of the master + // that has uncommited changes. + let Test { + repository, + project_id, + controller, + .. + } = Test::default(); + + repository.checkout("refs/heads/some-feature".parse().unwrap()); + + fs::write(repository.path().join("file.txt"), "content").unwrap(); + + controller + .set_base_branch(&project_id, &"refs/remotes/origin/master".parse().unwrap()) + .await + .unwrap(); + + let branches = controller.list_virtual_branches(&project_id).await.unwrap(); + assert_eq!(branches.len(), 1); + assert_eq!(branches[0].files.len(), 1); + assert_eq!(branches[0].files[0].hunks.len(), 1); + assert!(branches[0].upstream.is_none()); + assert_eq!(branches[0].name, "some-feature"); + } + + #[tokio::test] + async fn dirty_target() { + // a situation when you initialize project while being on the local verison of the master + // that has uncommited changes. + let Test { + repository, + project_id, + controller, + .. + } = Test::default(); + + fs::write(repository.path().join("file.txt"), "content").unwrap(); + + controller + .set_base_branch(&project_id, &"refs/remotes/origin/master".parse().unwrap()) + .await + .unwrap(); + + let branches = controller.list_virtual_branches(&project_id).await.unwrap(); + assert_eq!(branches.len(), 1); + assert_eq!(branches[0].files.len(), 1); + assert_eq!(branches[0].files[0].hunks.len(), 1); + assert!(branches[0].upstream.is_none()); + assert_eq!(branches[0].name, "master"); + } + + #[tokio::test] + async fn commit_on_non_target_local() { + let Test { + repository, + project_id, + controller, + .. + } = Test::default(); + + repository.checkout("refs/heads/some-feature".parse().unwrap()); + fs::write(repository.path().join("file.txt"), "content").unwrap(); + repository.commit_all("commit on target"); + + controller + .set_base_branch(&project_id, &"refs/remotes/origin/master".parse().unwrap()) + .await + .unwrap(); + + let branches = controller.list_virtual_branches(&project_id).await.unwrap(); + dbg!(&branches); + assert_eq!(branches.len(), 1); + assert!(branches[0].files.is_empty()); + assert_eq!(branches[0].commits.len(), 1); + assert!(branches[0].upstream.is_none()); + assert_eq!(branches[0].name, "some-feature"); + } + + #[tokio::test] + async fn commit_on_non_target_remote() { + let Test { + repository, + project_id, + controller, + .. + } = Test::default(); + + repository.checkout("refs/heads/some-feature".parse().unwrap()); + fs::write(repository.path().join("file.txt"), "content").unwrap(); + repository.commit_all("commit on target"); + repository.push_branch(&"refs/heads/some-feature".parse().unwrap()); + + controller + .set_base_branch(&project_id, &"refs/remotes/origin/master".parse().unwrap()) + .await + .unwrap(); + + let branches = controller.list_virtual_branches(&project_id).await.unwrap(); + dbg!(&branches); + assert_eq!(branches.len(), 1); + assert!(branches[0].files.is_empty()); + assert_eq!(branches[0].commits.len(), 1); + assert!(branches[0].upstream.is_some()); + assert_eq!(branches[0].name, "some-feature"); + } + + #[tokio::test] + async fn commit_on_target() { + let Test { + repository, + project_id, + controller, + .. + } = Test::default(); + + fs::write(repository.path().join("file.txt"), "content").unwrap(); + repository.commit_all("commit on target"); + + controller + .set_base_branch(&project_id, &"refs/remotes/origin/master".parse().unwrap()) + .await + .unwrap(); + + let branches = controller.list_virtual_branches(&project_id).await.unwrap(); + assert_eq!(branches.len(), 1); + assert!(branches[0].files.is_empty()); + assert_eq!(branches[0].commits.len(), 1); + assert!(branches[0].upstream.is_none()); + assert_eq!(branches[0].name, "master"); + } + #[tokio::test] async fn submodule() { let Test { @@ -3467,28 +3601,6 @@ mod init { assert_eq!(branches[0].files.len(), 1); assert_eq!(branches[0].files[0].hunks.len(), 1); } - - #[tokio::test] - async fn dirty() { - let Test { - repository, - project_id, - controller, - .. - } = Test::default(); - - fs::write(repository.path().join("file.txt"), "content").unwrap(); - - controller - .set_base_branch(&project_id, &"refs/remotes/origin/master".parse().unwrap()) - .await - .unwrap(); - - let branches = controller.list_virtual_branches(&project_id).await.unwrap(); - assert_eq!(branches.len(), 1); - assert_eq!(branches[0].files.len(), 1); - assert_eq!(branches[0].files[0].hunks.len(), 1); - } } mod squash {