cherry-pick onto virtual branch

This commit is contained in:
Nikita Galaiko 2023-10-23 15:51:31 +02:00 committed by GitButler
parent 1925a06ce0
commit 552fe0c5cd
3 changed files with 449 additions and 4 deletions

View File

@ -526,4 +526,25 @@ impl Controller {
})
.await
}
pub async fn cherry_pick(
&self,
project_id: &ProjectId,
branch_id: &BranchId,
commit_oid: git::Oid,
) -> Result<Option<git::Oid>, Error> {
self.with_lock(project_id, || {
self.with_verify_branch(project_id, |gb_repository, project_repository, _| {
if conflicts::is_conflicting(project_repository, None)
.context("failed to check for conflicts")?
{
return Err(Error::Conflicting);
}
super::cherry_pick(gb_repository, project_repository, branch_id, commit_oid)
.map_err(Error::Other)
})
})
.await
}
}

View File

@ -781,7 +781,7 @@ fn calculate_non_commited_files(
default_target: &target::Target,
files: &[VirtualBranchFile],
) -> Result<Vec<VirtualBranchFile>> {
if default_target.sha == branch.head {
if default_target.sha == branch.head && !branch.applied {
return Ok(files.to_vec());
};
@ -2353,3 +2353,184 @@ pub fn is_virtual_branch_mergeable(
Ok(is_mergeable)
}
pub fn cherry_pick(
gb_repository: &gb_repository::Repository,
project_repository: &project_repository::Repository,
branch_id: &BranchId,
target_commit_oid: git::Oid,
) -> Result<Option<git::Oid>> {
if conflicts::is_conflicting(project_repository, None)? {
bail!("cannot cherry pick while conflicted");
}
let current_session = gb_repository
.get_or_create_current_session()
.context("failed to get or create current session")?;
let current_session_reader = sessions::Reader::open(gb_repository, &current_session)
.context("failed to open current session")?;
let branch_reader = branch::Reader::new(&current_session_reader);
let branch = branch_reader
.read(branch_id)
.context("failed to read branch")?;
if !branch.applied {
// todo?
bail!("cannot cherry pick unapplied branch");
}
let target_commit = project_repository
.git_repository
.find_commit(target_commit_oid)
.context("failed to find target commit")?;
let target_commit_tree = target_commit
.tree()
.context("failed to find target commit tree")?;
let branch_head_commit = project_repository
.git_repository
.find_commit(branch.head)
.context("failed to find branch head commit")?;
let branch_tree = project_repository
.git_repository
.find_tree(branch.tree)
.context("failed to find branch tree")?;
let default_target = get_default_target(&current_session_reader)
.context("failed to read default target")?
.context("no default target set")?;
let applied_branches = Iterator::new(&current_session_reader)
.context("failed to create branch iterator")?
.collect::<Result<Vec<branch::Branch>, reader::Error>>()
.context("failed to read virtual branches")?
.into_iter()
.filter(|b| b.applied)
.collect::<Vec<_>>();
let applied_statuses = get_applied_status(
gb_repository,
project_repository,
&default_target,
applied_branches.clone(),
)
.context("failed to get status by branch")?;
let files = applied_statuses
.iter()
.find_map(|(b, f)| (b.id == *branch_id).then_some(f))
.context("branch status not found")?;
let wip_branch_tree_oid = write_tree_onto_commit(project_repository, branch.head, files)?;
let wip_branch_tree = project_repository
.git_repository
.find_tree(wip_branch_tree_oid)
.context("failed to find wip branch tree")?;
let mut merge_index = project_repository
.git_repository
.merge_trees(&branch_tree, &wip_branch_tree, &target_commit_tree)
.context("failed to merge trees")?;
let commit_oid = if merge_index.has_conflicts() {
// unapply other branches
for other_branch in applied_branches.iter().filter(|b| b.id != branch.id) {
unapply_branch(gb_repository, project_repository, &other_branch.id)
.context("failed to unapply branch")?;
}
// checkout the conflicts
project_repository
.git_repository
.checkout_index(&mut merge_index)
.allow_conflicts()
.conflict_style_merge()
.force()
.checkout()?;
// mark conflicts
let conflicts = merge_index.conflicts()?;
let mut merge_conflicts = Vec::new();
for path in conflicts.flatten() {
if let Some(ours) = path.our {
let path = std::str::from_utf8(&ours.path)?.to_string();
merge_conflicts.push(path);
}
}
conflicts::mark(
project_repository,
&merge_conflicts,
Some(target_commit_oid),
)?;
None
} else {
let merge_tree_oid = merge_index
.write_tree_to(&project_repository.git_repository)
.context("failed to write merge tree")?;
let merge_tree = project_repository
.git_repository
.find_tree(merge_tree_oid)
.context("failed to find merge tree")?;
let commit_oid = project_repository
.git_repository
.commit(
None,
&target_commit.author(),
&target_commit.committer(),
target_commit.message().unwrap_or_default(),
&merge_tree,
&[&branch_head_commit],
)
.context("failed to create commit")?;
// go through the other applied branches and merge them into the final tree
let final_tree = applied_statuses
.into_iter()
.filter(|(b, _)| b.id != *branch_id)
.fold(
target_commit.tree().context("failed to get target tree"),
|final_tree, status| {
let final_tree = final_tree?;
let tree_oid = write_tree(project_repository, &default_target, &status.1)?;
let branch_tree = project_repository.git_repository.find_tree(tree_oid)?;
let mut result = project_repository.git_repository.merge_trees(
&target_commit_tree,
&final_tree,
&branch_tree,
)?;
let final_tree_oid =
result.write_tree_to(&project_repository.git_repository)?;
project_repository
.git_repository
.find_tree(final_tree_oid)
.context("failed to find tree")
},
)?;
// checkout final_tree into the working directory
project_repository
.git_repository
.checkout_tree(&final_tree)
.force()
.remove_untracked()
.checkout()?;
// update branch status
let writer = branch::Writer::new(gb_repository);
writer
.write(&Branch {
head: commit_oid,
..branch.clone()
})
.context("failed to write branch")?;
Some(commit_oid)
};
super::integration::update_gitbutler_integration(gb_repository, project_repository)
.context("failed to update gitbutler integration")?;
Ok(commit_oid)
}

View File

@ -121,7 +121,6 @@ mod references {
.unwrap();
let branches = controller.list_virtual_branches(&project_id).await.unwrap();
dbg!(&branches);
assert_eq!(branches.len(), 2);
assert_eq!(branches[0].id, branch1_id);
assert_eq!(branches[0].name, "name");
@ -640,7 +639,6 @@ mod conflicts {
let branches = controller.list_virtual_branches(&project_id).await.unwrap();
assert_eq!(branches.len(), 1);
assert_eq!(branches[0].id, branch1_id);
dbg!(&branches[0]);
assert_eq!(branches[0].files.len(), 0);
assert_eq!(branches[0].commits.len(), 1);
assert!(!branches[0].active);
@ -902,7 +900,7 @@ mod conflicts {
}
}
mod reset {
mod reset_virtual_branch {
use super::*;
#[tokio::test]
@ -1241,3 +1239,248 @@ mod upstream {
}
}
}
mod cherry_pick {
use super::*;
mod cleanly {
use super::*;
#[tokio::test]
async fn applied() {
let Test {
repository,
project_id,
controller,
} = Test::default();
let commit_oid = {
let first = repository.commit_all("commit");
fs::write(repository.path().join("file.txt"), "content").unwrap();
let second = repository.commit_all("commit");
repository.push();
repository.reset_hard(first);
second
};
controller
.set_base_branch(
&project_id,
&git::RemoteBranchName::from_str("refs/remotes/origin/master").unwrap(),
)
.unwrap();
let branch_id = controller
.create_virtual_branch(&project_id, &branch::BranchCreateRequest::default())
.await
.unwrap();
let cherry_picked_commit_oid = controller
.cherry_pick(&project_id, &branch_id, commit_oid)
.await
.unwrap();
assert!(cherry_picked_commit_oid.is_some());
assert!(repository.path().join("file.txt").exists());
let branches = controller.list_virtual_branches(&project_id).await.unwrap();
assert_eq!(branches.len(), 1);
assert_eq!(branches[0].id, branch_id);
assert!(branches[0].active);
assert_eq!(branches[0].commits.len(), 1);
assert_eq!(branches[0].commits[0].id, cherry_picked_commit_oid.unwrap());
}
#[tokio::test]
async fn non_applied() {
let Test {
repository,
project_id,
controller,
} = Test::default();
controller
.set_base_branch(
&project_id,
&git::RemoteBranchName::from_str("refs/remotes/origin/master").unwrap(),
)
.unwrap();
let branch_id = controller
.create_virtual_branch(&project_id, &branch::BranchCreateRequest::default())
.await
.unwrap();
let commit_one_oid = {
fs::write(repository.path().join("file.txt"), "content").unwrap();
controller
.create_commit(&project_id, &branch_id, "commit", None)
.await
.unwrap()
};
{
fs::write(repository.path().join("file_two.txt"), "content two").unwrap();
controller
.create_commit(&project_id, &branch_id, "commit", None)
.await
.unwrap()
};
let commit_three_oid = {
fs::write(repository.path().join("file_three.txt"), "content three").unwrap();
controller
.create_commit(&project_id, &branch_id, "commit", None)
.await
.unwrap()
};
controller
.reset_virtual_branch(&project_id, &branch_id, commit_one_oid)
.await
.unwrap();
controller
.unapply_virtual_branch(&project_id, &branch_id)
.await
.unwrap();
assert!(matches!(
controller
.cherry_pick(&project_id, &branch_id, commit_three_oid)
.await,
Err(ControllerError::Other(_))
));
}
}
mod with_conflicts {
use super::*;
#[tokio::test]
async fn applied() {
let Test {
repository,
project_id,
controller,
} = Test::default();
let commit_oid = {
let first = repository.commit_all("commit");
fs::write(repository.path().join("file.txt"), "content").unwrap();
let second = repository.commit_all("commit");
repository.push();
repository.reset_hard(first);
second
};
controller
.set_base_branch(
&project_id,
&git::RemoteBranchName::from_str("refs/remotes/origin/master").unwrap(),
)
.unwrap();
let branch_id = controller
.create_virtual_branch(&project_id, &branch::BranchCreateRequest::default())
.await
.unwrap();
// introduce conflict with the remote commit
fs::write(repository.path().join("file.txt"), "conflict").unwrap();
{
// cherry picking leads to conflict
let cherry_picked_commit_oid = controller
.cherry_pick(&project_id, &branch_id, commit_oid)
.await
.unwrap();
assert!(cherry_picked_commit_oid.is_none());
assert_eq!(
fs::read_to_string(repository.path().join("file.txt")).unwrap(),
"<<<<<<< ours\nconflict\n=======\ncontent\n>>>>>>> theirs\n"
);
let branches = controller.list_virtual_branches(&project_id).await.unwrap();
assert_eq!(branches.len(), 1);
assert_eq!(branches[0].id, branch_id);
assert!(branches[0].active);
assert!(branches[0].conflicted);
assert_eq!(branches[0].files.len(), 1);
assert!(branches[0].files[0].conflicted);
assert_eq!(branches[0].commits.len(), 0);
}
{
// conflict can be resolved
fs::write(repository.path().join("file.txt"), "resolved").unwrap();
let commited_oid = controller
.create_commit(&project_id, &branch_id, "resolution", None)
.await
.unwrap();
let commit = repository.find_commit(commited_oid).unwrap();
assert_eq!(commit.parent_count(), 2);
let branches = controller.list_virtual_branches(&project_id).await.unwrap();
assert_eq!(branches.len(), 1);
assert_eq!(branches[0].id, branch_id);
assert!(branches[0].active);
assert!(!branches[0].conflicted);
assert_eq!(branches[0].commits.len(), 2);
// resolution commit is there
assert_eq!(branches[0].commits[0].id, commited_oid);
// cherry picked commit is there
assert_eq!(
branches[0].commits[1].files[0].hunks[0].diff,
"@@ -0,0 +1 @@\n+content\n\\ No newline at end of file\n"
);
}
}
#[tokio::test]
async fn non_applied() {
let Test {
repository,
project_id,
controller,
} = Test::default();
let commit_oid = {
let first = repository.commit_all("commit");
fs::write(repository.path().join("file.txt"), "content").unwrap();
let second = repository.commit_all("commit");
repository.push();
repository.reset_hard(first);
second
};
controller
.set_base_branch(
&project_id,
&git::RemoteBranchName::from_str("refs/remotes/origin/master").unwrap(),
)
.unwrap();
let branch_id = controller
.create_virtual_branch(&project_id, &branch::BranchCreateRequest::default())
.await
.unwrap();
// introduce conflict with the remote commit
fs::write(repository.path().join("file.txt"), "conflict").unwrap();
controller
.unapply_virtual_branch(&project_id, &branch_id)
.await
.unwrap();
assert!(matches!(
controller
.cherry_pick(&project_id, &branch_id, commit_oid)
.await,
Err(ControllerError::Other(_))
));
}
}
}