Merge pull request #4993 from gitbutlerapp/fix-create-wd-tree

Use new git2 implementation
This commit is contained in:
Caleb Owens 2024-09-30 00:12:17 +02:00 committed by GitHub
commit 7422110a6d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 593 additions and 73 deletions

View File

@ -327,12 +327,14 @@ impl BranchManager<'_> {
// We don't support having two branches applied that conflict with each other
{
let mut index = repo.index()?;
index.add_all(["*"], git2::IndexAddOption::default(), None)?;
let index_tree = index.write_tree()?;
let index_tree = repo.find_tree(index_tree)?;
let uncommited_changes_tree = repo.create_wd_tree()?;
let branch_merged_with_other_applied_branches = repo
.merge_trees(&merge_base_tree, &branch_tree, &index_tree, None)
.merge_trees(
&merge_base_tree,
&branch_tree,
&uncommited_changes_tree,
None,
)
.context("failed to merge trees")?;
if branch_merged_with_other_applied_branches.has_conflicts() {

View File

@ -60,3 +60,103 @@ pub(crate) fn checkout_branch_trees<'a>(
Ok(final_tree)
}
}
// These possibly could be considered more "integration" tests, but there is no
// need for `checkout_branch_trees` to be public, so it is tested here.
#[cfg(test)]
mod test {
use std::fs;
use bstr::ByteSlice as _;
use gitbutler_branch::BranchCreateRequest;
use gitbutler_command_context::CommandContext;
use gitbutler_repo::RepositoryExt as _;
use gitbutler_testsupport::{paths, testing_repository::assert_tree_matches, TestProject};
#[test]
fn checkout_with_two_branches() {
let test_project = &TestProject::default();
let data_dir = paths::data_dir();
let projects = gitbutler_project::Controller::from_path(data_dir.path());
let project = projects
.add(test_project.path())
.expect("failed to add project");
crate::set_base_branch(&project, &"refs/remotes/origin/master".parse().unwrap()).unwrap();
let branch_1 =
crate::create_virtual_branch(&project, &BranchCreateRequest::default()).unwrap();
fs::write(test_project.path().join("foo.txt"), "content").unwrap();
crate::create_commit(&project, branch_1, "commit one", None, false).unwrap();
let branch_2 =
crate::create_virtual_branch(&project, &BranchCreateRequest::default()).unwrap();
fs::write(test_project.path().join("bar.txt"), "content").unwrap();
crate::create_commit(&project, branch_2, "commit two", None, false).unwrap();
let tree = test_project.local_repository.create_wd_tree().unwrap();
// Assert original state
assert_tree_matches(
&test_project.local_repository,
&tree,
&[("foo.txt", b"content"), ("bar.txt", b"content")],
);
assert_eq!(tree.len(), 2);
// Checkout an empty tree
{
let tree_oid = test_project
.local_repository
.treebuilder(None)
.unwrap()
.write()
.unwrap();
let tree = test_project.local_repository.find_tree(tree_oid).unwrap();
test_project
.local_repository
.checkout_tree_builder(&tree)
.force()
.remove_untracked()
.checkout()
.unwrap();
}
// Assert tree is indeed empty
{
let tree: git2::Tree = test_project.local_repository.create_wd_tree().unwrap();
dbg!(tree
.into_iter()
.map(|t| t.name_bytes().to_str_lossy().to_string())
.collect::<Vec<_>>());
// Tree should be empty
assert_eq!(
tree.len(),
0,
"Should be empty after checking out an empty tree"
);
}
let ctx = CommandContext::open(&project).unwrap();
let mut guard = project.exclusive_worktree_access();
super::checkout_branch_trees(&ctx, guard.write_permission()).unwrap();
let tree = test_project.local_repository.create_wd_tree().unwrap();
// Should be back to original state
assert_tree_matches(
&test_project.local_repository,
&tree,
&[("foo.txt", b"content"), ("bar.txt", b"content")],
);
assert_eq!(tree.len(), 2, "Should match original state");
}
}

View File

@ -253,10 +253,7 @@ pub(crate) fn save_and_return_to_workspace(
let parents = commit.parents().collect::<Vec<_>>();
// Recommit commit
let mut index = repository.index()?;
index.add_all(["*"], git2::IndexAddOption::DEFAULT, None)?;
let tree = index.write_tree()?;
let tree = repository.find_tree(tree)?;
let tree = repository.create_wd_tree()?;
let commit_headers = commit
.gitbutler_headers()

View File

@ -2,12 +2,13 @@
use std::os::unix::fs::PermissionsExt;
#[cfg(windows)]
use std::os::windows::process::CommandExt;
use std::path::PathBuf;
use std::{io::Write, path::Path, process::Stdio, str};
use crate::{Config, LogUntil};
use anyhow::{anyhow, bail, Context, Result};
use bstr::{BString, ByteSlice};
use git2::{BlameOptions, Tree};
use bstr::BString;
use git2::{BlameOptions, StatusOptions, Tree};
use gitbutler_branch::SignaturePurpose;
use gitbutler_commit::commit_headers::CommitHeadersV2;
use gitbutler_config::git::{GbConfig, GitConfig};
@ -17,13 +18,13 @@ use gitbutler_oxidize::{
};
use gitbutler_reference::{Refname, RemoteRefname};
use gix::objs::WriteTo;
use gix::status::plumbing::index_as_worktree::{Change, EntryStatus};
use tracing::instrument;
/// Extension trait for `git2::Repository`.
///
/// For now, it collects useful methods from `gitbutler-core::git::Repository`
pub trait RepositoryExt {
fn worktree_path(&self) -> PathBuf;
fn signatures(&self) -> Result<(git2::Signature, git2::Signature)>;
fn l(&self, from: git2::Oid, to: LogUntil) -> Result<Vec<git2::Oid>>;
fn list_commits(&self, from: git2::Oid, to: git2::Oid) -> Result<Vec<git2::Commit>>;
@ -156,73 +157,67 @@ impl RepositoryExt for git2::Repository {
/// the object database, and create a tree from it.
///
/// Note that right now, it doesn't skip big files.
///
/// It should also be noted that this will fail if run on an empty branch
/// or if the HEAD branch has no commits
#[instrument(level = tracing::Level::DEBUG, skip(self), err(Debug))]
fn create_wd_tree(&self) -> Result<Tree> {
let repo = gix::open(self.path())?;
let workdir = repo.work_dir().context("Need non-bare repository")?;
let mut head_tree_editor = repo.edit_tree(repo.head_tree_id()?)?;
let status_changes = repo
.status(gix::progress::Discard)?
.index_worktree_rewrites(None)
.index_worktree_submodules(None)
.into_index_worktree_iter(None::<BString>)?;
for change in status_changes {
let change = change?;
match change {
// modified or tracked files are unconditionally added as blob.
gix::status::index_worktree::iter::Item::Modification {
rela_path,
status: EntryStatus::Change(Change::Type | Change::Modification { .. }),
..
}
| gix::status::index_worktree::iter::Item::DirectoryContents {
entry:
gix::dir::Entry {
rela_path,
status: gix::dir::entry::Status::Untracked,
..
},
..
} => {
let path = workdir.join(gix::path::from_bstr(&rela_path));
let Ok(md) = std::fs::symlink_metadata(&path) else {
continue;
};
let (id, kind) = if md.is_symlink() {
let target = std::fs::read_link(&path).with_context(|| {
format!(
"Failed to read link at '{}' for adding to the object database",
path.display()
)
})?;
let id = repo.write_blob(gix::path::into_bstr(target).as_bytes())?;
(id, gix::object::tree::EntryKind::Link)
} else {
let mut file = std::fs::File::open(&path).with_context(|| {
format!(
"Could not open file at '{}' for adding it to the object database",
path.display()
)
})?;
let kind = if gix::fs::is_executable(&md) {
gix::object::tree::EntryKind::BlobExecutable
} else {
gix::object::tree::EntryKind::Blob
};
(repo.write_blob_stream(&mut file)?, kind)
};
let mut tree_update_builder = git2::build::TreeUpdateBuilder::new();
head_tree_editor.upsert(rela_path, kind, id)?;
}
gix::status::index_worktree::iter::Item::Rewrite { .. } => {
unreachable!("disabled")
}
_ => {}
let worktree_path = self.worktree_path();
let statuses = self.statuses(Some(
StatusOptions::new()
.renames_from_rewrites(false)
.renames_head_to_index(false)
.renames_index_to_workdir(false)
.include_untracked(true)
.recurse_untracked_dirs(true),
))?;
// Truth table for upsert/remove:
// | HEAD Tree -> Index | Index -> Worktree | Action |
// | add | delete | no-action |
// | modify | delete | remove |
// | | delete | remove |
// | delete | | remove |
// | delete | add | upsert |
// | add | | upsert |
// | | add | upsert |
// | add | modify | upsert |
// | modify | modify | upsert |
for status_entry in &statuses {
let status = status_entry.status();
let path = status_entry.path().context("Failed to get path")?;
let path = Path::new(path);
if status.is_index_new() && status.is_wt_deleted() {
// This is a no-op
} else if (status.is_index_deleted() && !status.is_wt_new()) || status.is_wt_deleted() {
tree_update_builder.remove(path);
} else {
let file_path = worktree_path.join(path);
let file = std::fs::read(file_path)?;
let blob = self.blob(&file)?;
tree_update_builder.upsert(path, blob, git2::FileMode::Blob);
}
}
let oid = git2::Oid::from_bytes(head_tree_editor.write()?.as_bytes())?;
self.find_tree(oid).map(Into::into).map_err(Into::into)
let head_tree = self.head_commit()?.tree()?;
let tree_oid = tree_update_builder.create_updated(self, &head_tree)?;
Ok(self.find_tree(tree_oid)?)
}
/// Returns the path of the working directory of the repository.
fn worktree_path(&self) -> PathBuf {
if self.is_bare() {
self.path().to_owned()
} else {
self.path().join("..")
}
}
fn workspace_ref_from_head(&self) -> Result<git2::Reference<'_>> {
@ -634,3 +629,429 @@ impl GixRepositoryExt for gix::Repository {
Ok(self)
}
}
#[cfg(test)]
mod test {
mod create_wd_tree {
use std::path::Path;
use crate::repository_ext::RepositoryExt as _;
use gitbutler_testsupport::testing_repository::{assert_tree_matches, TestingRepository};
/// These tests exercise the truth table that we use to update the HEAD
/// tree to match the worktree.
///
/// Truth table for upsert/remove:
/// | HEAD Tree -> Index | Index -> Worktree | Action |
/// | add | delete | no-action |
/// | modify | delete | remove |
/// | | delete | remove |
/// | delete | | remove |
/// | delete | add | upsert |
/// | add | | upsert |
/// | | add | upsert |
/// | add | modify | upsert |
/// | modify | modify | upsert |
#[cfg(test)]
mod head_upsert_truthtable {
use super::*;
// | add | delete | no-action |
#[test]
fn index_new_worktree_delete() {
let test_repository = TestingRepository::open();
let commit = test_repository.commit_tree(None, &[]);
test_repository
.repository
.branch("master", &commit, true)
.unwrap();
std::fs::write(test_repository.tempdir.path().join("file1.txt"), "content1")
.unwrap();
let mut index = test_repository.repository.index().unwrap();
index.add_path(Path::new("file1.txt")).unwrap();
index.write().unwrap();
std::fs::remove_file(test_repository.tempdir.path().join("file1.txt")).unwrap();
let tree: git2::Tree = test_repository.repository.create_wd_tree().unwrap();
assert_eq!(tree.len(), 0, "Tree should end up empty");
}
// | modify | delete | remove |
#[test]
fn index_modify_worktree_delete() {
let test_repository = TestingRepository::open();
let commit = test_repository.commit_tree(None, &[("file1.txt", "content1")]);
test_repository
.repository
.branch("master", &commit, true)
.unwrap();
std::fs::write(test_repository.tempdir.path().join("file1.txt"), "content2")
.unwrap();
let mut index = test_repository.repository.index().unwrap();
index.add_path(Path::new("file1.txt")).unwrap();
index.write().unwrap();
std::fs::remove_file(test_repository.tempdir.path().join("file1.txt")).unwrap();
let tree: git2::Tree = test_repository.repository.create_wd_tree().unwrap();
assert_eq!(tree.len(), 0, "Tree should end up empty");
}
// | | delete | remove |
#[test]
fn worktree_delete() {
let test_repository = TestingRepository::open();
let commit = test_repository.commit_tree(None, &[("file1.txt", "content1")]);
test_repository
.repository
.branch("master", &commit, true)
.unwrap();
std::fs::remove_file(test_repository.tempdir.path().join("file1.txt")).unwrap();
let tree: git2::Tree = test_repository.repository.create_wd_tree().unwrap();
assert_eq!(tree.len(), 0, "Tree should end up empty");
}
// | delete | | remove |
#[test]
fn index_delete() {
let test_repository = TestingRepository::open();
let commit = test_repository.commit_tree(None, &[("file1.txt", "content1")]);
test_repository
.repository
.branch("master", &commit, true)
.unwrap();
let mut index = test_repository.repository.index().unwrap();
index.remove_all(["*"], None).unwrap();
index.write().unwrap();
let tree: git2::Tree = test_repository.repository.create_wd_tree().unwrap();
// We should ignore whatever happens to the index
assert_tree_matches(
&test_repository.repository,
&tree,
&[("file1.txt", b"content1")],
);
}
// | delete | add | upsert |
#[test]
fn index_delete_worktree_add() {
let test_repository = TestingRepository::open();
let commit = test_repository.commit_tree(None, &[("file1.txt", "content1")]);
test_repository
.repository
.branch("master", &commit, true)
.unwrap();
let mut index = test_repository.repository.index().unwrap();
index.remove_all(["*"], None).unwrap();
index.write().unwrap();
std::fs::write(test_repository.tempdir.path().join("file1.txt"), "content2")
.unwrap();
let tree: git2::Tree = test_repository.repository.create_wd_tree().unwrap();
// Tree should match whatever is written on disk
assert_tree_matches(
&test_repository.repository,
&tree,
&[("file1.txt", b"content2")],
);
}
// | add | | upsert |
#[test]
fn index_add() {
let test_repository = TestingRepository::open();
let commit = test_repository.commit_tree(None, &[]);
test_repository
.repository
.branch("master", &commit, true)
.unwrap();
std::fs::write(test_repository.tempdir.path().join("file1.txt"), "content2")
.unwrap();
let mut index = test_repository.repository.index().unwrap();
index.add_path(Path::new("file1.txt")).unwrap();
index.write().unwrap();
let tree: git2::Tree = test_repository.repository.create_wd_tree().unwrap();
// Tree should match whatever is written on disk
assert_tree_matches(
&test_repository.repository,
&tree,
&[("file1.txt", b"content2")],
);
}
// | | add | upsert |
#[test]
fn worktree_add() {
let test_repository = TestingRepository::open();
let commit = test_repository.commit_tree(None, &[]);
test_repository
.repository
.branch("master", &commit, true)
.unwrap();
std::fs::write(test_repository.tempdir.path().join("file1.txt"), "content2")
.unwrap();
let tree: git2::Tree = test_repository.repository.create_wd_tree().unwrap();
// Tree should match whatever is written on disk
assert_tree_matches(
&test_repository.repository,
&tree,
&[("file1.txt", b"content2")],
);
}
// | add | modify | upsert |
#[test]
fn index_add_worktree_modify() {
let test_repository = TestingRepository::open();
let commit = test_repository.commit_tree(None, &[]);
test_repository
.repository
.branch("master", &commit, true)
.unwrap();
std::fs::write(test_repository.tempdir.path().join("file1.txt"), "content1")
.unwrap();
let mut index = test_repository.repository.index().unwrap();
index.add_path(Path::new("file1.txt")).unwrap();
index.write().unwrap();
std::fs::write(test_repository.tempdir.path().join("file1.txt"), "content2")
.unwrap();
let tree: git2::Tree = test_repository.repository.create_wd_tree().unwrap();
// Tree should match whatever is written on disk
assert_tree_matches(
&test_repository.repository,
&tree,
&[("file1.txt", b"content2")],
);
}
// | modify | modify | upsert |
#[test]
fn index_modify_worktree_modify() {
let test_repository = TestingRepository::open();
let commit = test_repository.commit_tree(None, &[("file1.txt", "content1")]);
test_repository
.repository
.branch("master", &commit, true)
.unwrap();
std::fs::write(test_repository.tempdir.path().join("file1.txt"), "content2")
.unwrap();
let mut index = test_repository.repository.index().unwrap();
index.add_path(Path::new("file1.txt")).unwrap();
index.write().unwrap();
std::fs::write(test_repository.tempdir.path().join("file1.txt"), "content3")
.unwrap();
let tree: git2::Tree = test_repository.repository.create_wd_tree().unwrap();
// Tree should match whatever is written on disk
assert_tree_matches(
&test_repository.repository,
&tree,
&[("file1.txt", b"content3")],
);
}
}
#[test]
fn lists_uncommited_changes() {
let test_repository = TestingRepository::open();
// Initial commit
// Create wd tree requires the HEAD branch to exist and for there
// to be at least one commit on that branch.
let commit = test_repository.commit_tree(None, &[]);
test_repository
.repository
.branch("master", &commit, true)
.unwrap();
std::fs::write(test_repository.tempdir.path().join("file1.txt"), "content1").unwrap();
std::fs::write(test_repository.tempdir.path().join("file2.txt"), "content2").unwrap();
let tree = test_repository.repository.create_wd_tree().unwrap();
assert_tree_matches(
&test_repository.repository,
&tree,
&[("file1.txt", b"content1"), ("file2.txt", b"content2")],
);
}
#[test]
fn does_not_include_staged_but_deleted_files() {
let test_repository = TestingRepository::open();
// Initial commit
// Create wd tree requires the HEAD branch to exist and for there
// to be at least one commit on that branch.
let commit = test_repository.commit_tree(None, &[]);
test_repository
.repository
.branch("master", &commit, true)
.unwrap();
std::fs::write(test_repository.tempdir.path().join("file1.txt"), "content1").unwrap();
std::fs::write(test_repository.tempdir.path().join("file2.txt"), "content2").unwrap();
std::fs::write(test_repository.tempdir.path().join("file3.txt"), "content2").unwrap();
let mut index: git2::Index = test_repository.repository.index().unwrap();
index.add_path(Path::new("file3.txt")).unwrap();
index.write().unwrap();
std::fs::remove_file(test_repository.tempdir.path().join("file3.txt")).unwrap();
let tree: git2::Tree = test_repository.repository.create_wd_tree().unwrap();
assert_tree_matches(
&test_repository.repository,
&tree,
&[("file1.txt", b"content1"), ("file2.txt", b"content2")],
);
assert!(tree.get_name("file3.txt").is_none());
}
#[test]
fn should_be_empty_after_checking_out_empty_tree() {
let test_repository = TestingRepository::open();
let commit = test_repository.commit_tree(
None,
&[("file1.txt", "content1"), ("file2.txt", "content2")],
);
test_repository
.repository
.branch("master", &commit, true)
.unwrap();
// Checkout an empty tree
{
let tree_oid = test_repository
.repository
.treebuilder(None)
.unwrap()
.write()
.unwrap();
let tree = test_repository.repository.find_tree(tree_oid).unwrap();
test_repository
.repository
.checkout_tree_builder(&tree)
.force()
.remove_untracked()
.checkout()
.unwrap();
}
assert!(!test_repository.tempdir.path().join("file1.txt").exists());
assert!(!test_repository.tempdir.path().join("file2.txt").exists());
let tree: git2::Tree = test_repository.repository.create_wd_tree().unwrap();
// Fails because `create_wd_tree` uses the head commit as the base,
// and then performs modifications to the tree
assert_eq!(tree.len(), 0);
}
#[test]
fn should_track_deleted_files() {
let test_repository = TestingRepository::open();
let commit = test_repository.commit_tree(
None,
&[("file1.txt", "content1"), ("file2.txt", "content2")],
);
test_repository
.repository
.branch("master", &commit, true)
.unwrap();
// Make sure the index is empty, perhaps the user did this action
let mut index: git2::Index = test_repository.repository.index().unwrap();
index.remove_all(["*"], None).unwrap();
index.write().unwrap();
std::fs::remove_file(test_repository.tempdir.path().join("file1.txt")).unwrap();
assert!(!test_repository.tempdir.path().join("file1.txt").exists());
assert!(test_repository.tempdir.path().join("file2.txt").exists());
let tree: git2::Tree = test_repository.repository.create_wd_tree().unwrap();
// Fails because `create_wd_tree` uses the head commit as the base,
// and then performs modifications to the tree
assert!(tree.get_name("file1.txt").is_none());
assert!(tree.get_name("file2.txt").is_some());
}
#[test]
fn should_not_change_index() {
let test_repository = TestingRepository::open();
let commit = test_repository.commit_tree(None, &[("file1.txt", "content1")]);
test_repository
.repository
.branch("master", &commit, true)
.unwrap();
let mut index = test_repository.repository.index().unwrap();
index.remove_all(["*"], None).unwrap();
index.write().unwrap();
let index_tree = index.write_tree().unwrap();
let index_tree = test_repository.repository.find_tree(index_tree).unwrap();
assert_eq!(index_tree.len(), 0);
let tree: git2::Tree = test_repository.repository.create_wd_tree().unwrap();
let mut index = test_repository.repository.index().unwrap();
let index_tree = index.write_tree().unwrap();
let index_tree = test_repository.repository.find_tree(index_tree).unwrap();
assert_eq!(index_tree.len(), 0);
// Tree should match whatever is written on disk
assert_tree_matches(
&test_repository.repository,
&tree,
&[("file1.txt", b"content1")],
);
}
}
}