refactor reflog.rs and slightly adjust snapshot.rs

* reflog
    - use `gix` to parse reflog, both in code as well as in tests (to be sure it's not malformed)
	- avoid double-writes to reflog, instead finish transformation in memory and write final result
    - assure the ref is present, independently of the reflog, catching more 'weird' states.
* minor improvements to `snapshot.rs` to avoid unnecessary clones
This commit is contained in:
Sebastian Thiel 2024-05-26 21:09:57 +02:00
parent d952db0920
commit e82950f177
No known key found for this signature in database
GPG Key ID: 9CB5EE7895E8268B
5 changed files with 245 additions and 141 deletions

View File

@ -10,6 +10,9 @@ use anyhow::Result;
use tracing::instrument;
use crate::git::diff::FileDiff;
use crate::virtual_branches::integration::{
GITBUTLER_INTEGRATION_COMMIT_AUTHOR_EMAIL, GITBUTLER_INTEGRATION_COMMIT_AUTHOR_NAME,
};
use crate::virtual_branches::Branch;
use crate::{git, git::diff::hunks_by_filepath, projects::Project};
@ -232,9 +235,11 @@ impl Project {
}
// Construct a new commit
let name = "GitButler";
let email = "gitbutler@gitbutler.com";
let signature = git2::Signature::now(name, email).unwrap();
let signature = git2::Signature::now(
GITBUTLER_INTEGRATION_COMMIT_AUTHOR_NAME,
GITBUTLER_INTEGRATION_COMMIT_AUTHOR_EMAIL,
)
.unwrap();
let parents = if let Some(ref oplog_head_commit) = oplog_head_commit {
vec![oplog_head_commit]
} else {
@ -255,7 +260,7 @@ impl Project {
// grab the target tree sha
let default_target_sha = vb_state.get_default_target()?.sha;
set_reference_to_oplog(self, default_target_sha, new_commit_oid.into())?;
set_reference_to_oplog(&self.path, default_target_sha, new_commit_oid.into())?;
Ok(Some(new_commit_oid.into()))
}

View File

@ -1,10 +1,12 @@
use crate::fs::write;
use crate::git;
use anyhow::Result;
use itertools::Itertools;
use std::path::PathBuf;
use anyhow::{Context, Result};
use gix::config::tree::Key;
use std::path::Path;
use crate::projects::Project;
use crate::virtual_branches::integration::{
GITBUTLER_INTEGRATION_COMMIT_AUTHOR_EMAIL, GITBUTLER_INTEGRATION_COMMIT_AUTHOR_NAME,
};
/// Sets a reference to the oplog head commit such that snapshots are reachable and will not be garbage collected.
/// We want to achieve 2 things:
@ -14,20 +16,21 @@ use crate::projects::Project;
/// This needs to be invoked whenever the target head or the oplog head change.
///
/// How it works:
/// First a reference gitbutler/target is created, pointing to the head of the target (trunk) branch. This is a fake branch that we don't need to care about. If it doesn't exist, it is created.
/// Then in the reflog entry logs/refs/heads/gitbutler/target we pretend that the the ref originally pointed to the oplog head commit like so:
/// First a reference gitbutler/target is created, pointing to the head of the target (trunk) branch.
/// This is a fake branch that we don't need to care about. If it doesn't exist, it is created.
/// Then in the reflog entry logs/refs/heads/gitbutler/target we pretend that the ref originally pointed to the
/// oplog head commit like so:
///
/// 0000000000000000000000000000000000000000 <target branch head sha>
/// <target branch head sha> <oplog head sha>
///
/// The reflog entry is continuously updated to refer to the current target and oplog head commits.
pub(crate) fn set_reference_to_oplog(
project: &Project,
pub(super) fn set_reference_to_oplog(
worktree_dir: &Path,
target_head_sha: git::Oid,
oplog_head_sha: git::Oid,
) -> Result<()> {
let repo_path = project.path.as_path();
let reflog_file_path = repo_path
let reflog_file_path = worktree_dir
.join(".git")
.join("logs")
.join("refs")
@ -35,145 +38,244 @@ pub(crate) fn set_reference_to_oplog(
.join("gitbutler")
.join("target");
if !reflog_file_path.exists() {
let repo = git2::Repository::init(repo_path)?;
let commit = repo.find_commit(target_head_sha.into())?;
repo.branch("gitbutler/target", &commit, false)?;
let mut repo = gix::open_opts(
worktree_dir,
// We may override the username as we only write a specific commit log, unrelated to the user.
gix::open::Options::isolated().config_overrides([
gix::config::tree::User::NAME
.validated_assignment(GITBUTLER_INTEGRATION_COMMIT_AUTHOR_NAME.into())?,
gix::config::tree::User::EMAIL
.validated_assignment(GITBUTLER_INTEGRATION_COMMIT_AUTHOR_EMAIL.into())?,
]),
)?;
// The check is here only to avoid unnecessary writes
if repo.try_find_reference("gitbutler/target")?.is_none() {
repo.refs.write_reflog = gix::refs::store::WriteReflog::Always;
repo.reference(
"refs/heads/gitbutler/target",
target_head_sha.to_string().parse::<gix::ObjectId>()?,
gix::refs::transaction::PreviousValue::Any,
format!("branch: Created from {target_head_sha}"),
)?;
}
if !reflog_file_path.exists() {
return Err(anyhow::anyhow!(
"Could not create gitbutler/target which is needed for undo snapshotting"
));
}
set_target_ref(&reflog_file_path, &target_head_sha.to_string())?;
set_oplog_ref(&reflog_file_path, &oplog_head_sha.to_string())?;
let mut content = std::fs::read_to_string(&reflog_file_path)
.context("A reflog for gitbutler/target which is needed for undo snapshotting")?;
content = set_target_ref(&content, &target_head_sha.to_string()).with_context(|| {
format!(
"Something was wrong with \"{}\"",
reflog_file_path.display()
)
})?;
content = set_oplog_ref(&content, &oplog_head_sha.to_string())?;
write(reflog_file_path, content)?;
Ok(())
}
fn set_target_ref(file_path: &PathBuf, sha: &str) -> Result<()> {
// 0000000000000000000000000000000000000000 82873b54925ab268e9949557f28d070d388e7774 Kiril Videlov <kiril@videlov.com> 1714037434 +0200 branch: Created from 82873b54925ab268e9949557f28d070d388e7774
let content = std::fs::read_to_string(file_path)?;
let mut lines = content.lines().collect::<Vec<_>>();
let mut first_line = lines[0].split_whitespace().collect_vec();
let len = first_line.len();
first_line[1] = sha;
first_line[len - 1] = sha;
let binding = first_line.join(" ");
lines[0] = &binding;
let content = format!("{}\n", lines.join("\n"));
write(file_path, content)
fn set_target_ref(content: &str, sha: &str) -> Result<String> {
// 0000000000000000000000000000000000000000 82873b54925ab268e9949557f28d070d388e7774 Kiril Videlov <kiril@videlov.com> 1714037434 +0200\tbranch: Created from 82873b54925ab268e9949557f28d070d388e7774
let mut lines = gix::refs::file::log::iter::forward(content.as_bytes());
let mut first_line = lines.next().context("need the creation-line in reflog")??;
first_line.new_oid = sha.into();
let message = format!("branch: Created from {sha}");
first_line.message = message.as_str().into();
Ok(serialize_line(first_line))
}
fn set_oplog_ref(file_path: &PathBuf, sha: &str) -> Result<()> {
// 82873b54925ab268e9949557f28d070d388e7774 7e8eab472636a26611214bebea7d6b79c971fb8b Kiril Videlov <kiril@videlov.com> 1714044124 +0200 reset: moving to 7e8eab472636a26611214bebea7d6b79c971fb8b
let content = std::fs::read_to_string(file_path)?;
let first_line = content.lines().collect::<Vec<_>>().remove(0);
fn set_oplog_ref(content: &str, sha: &str) -> Result<String> {
// 82873b54925ab268e9949557f28d070d388e7774 7e8eab472636a26611214bebea7d6b79c971fb8b Kiril Videlov <kiril@videlov.com> 1714044124 +0200\treset: moving to 7e8eab472636a26611214bebea7d6b79c971fb8b
let mut lines = gix::refs::file::log::iter::forward(content.as_bytes());
let first_line = lines.next().context("need the creation-line in reflog")??;
let target_ref = first_line.split_whitespace().collect_vec()[1];
let the_rest = first_line.split_whitespace().collect_vec()[2..].join(" ");
let the_rest = the_rest.replace("branch", " reset");
let mut the_rest_split = the_rest.split(':').collect_vec();
let new_msg = format!(" moving to {}", sha);
the_rest_split[1] = &new_msg;
let the_rest = the_rest_split.join(":");
let new_msg = format!("reset: moving to {}", sha);
let mut second_line = first_line.clone();
second_line.previous_oid = first_line.new_oid;
second_line.new_oid = sha.into();
second_line.message = new_msg.as_str().into();
let second_line = [target_ref, sha, &the_rest].join(" ");
Ok(format!(
"{}\n{}\n",
serialize_line(first_line),
serialize_line(second_line)
))
}
let content = format!("{}\n", [first_line, &second_line].join("\n"));
write(file_path, content)
fn serialize_line(line: gix::refs::file::log::LineRef<'_>) -> String {
let mut sig = Vec::new();
line.signature
.write_to(&mut sig)
.expect("write to memory succeeds");
format!(
"{} {} {}\t{}",
line.previous_oid,
line.new_oid,
std::str::from_utf8(&sig).expect("no illformed UTF8"),
line.message
)
}
#[cfg(test)]
mod tests {
use super::*;
mod set_target_ref {
use super::{
git, set_reference_to_oplog, GITBUTLER_INTEGRATION_COMMIT_AUTHOR_EMAIL,
GITBUTLER_INTEGRATION_COMMIT_AUTHOR_NAME,
};
use gix::refs::file::log::LineRef;
use pretty_assertions::assert_eq;
use std::path::PathBuf;
use std::str::FromStr;
use tempfile::tempdir;
#[test]
fn test_set_target_ref() {
let (dir, commit_id) = setup_repo();
let project = Project {
path: dir.path().to_path_buf(),
..Default::default()
};
fn reflog_present_but_branch_missing_recreates_branch() -> anyhow::Result<()> {
let (dir, commit_id) = setup_repo()?;
let worktree_dir = dir.path();
let log_file_path = dir
.path()
.join(".git")
.join("logs")
.join("refs")
.join("heads")
.join("gitbutler")
.join("target");
let oplog_sha = git::Oid::from_str("0123456789abcdef0123456789abcdef0123456")?;
set_reference_to_oplog(&worktree_dir, commit_id.into(), oplog_sha).expect("success");
let loose_ref_file = worktree_dir.join(".git/refs/heads/gitbutler/target");
std::fs::remove_file(&loose_ref_file)?;
set_reference_to_oplog(&worktree_dir, commit_id.into(), oplog_sha).expect("success");
assert!(
loose_ref_file.is_file(),
"the file was recreated, just in case there is only a reflog and no branch"
);
Ok(())
}
#[test]
fn new_and_update() -> anyhow::Result<()> {
let (dir, commit_id) = setup_repo()?;
let worktree_dir = dir.path();
let log_file_path = worktree_dir.join(".git/logs/refs/heads/gitbutler/target");
assert!(!log_file_path.exists());
// Set ref for the first time
let oplog_sha = git::Oid::from_str("0123456789abcdef0123456789abcdef0123456").unwrap();
assert!(set_reference_to_oplog(&project, commit_id.into(), oplog_sha).is_ok());
let oplog_sha = git::Oid::from_str("0123456789abcdef0123456789abcdef0123456")?;
set_reference_to_oplog(&worktree_dir, commit_id.into(), oplog_sha).expect("success");
assert!(log_file_path.exists());
let log_file = std::fs::read_to_string(&log_file_path).unwrap();
let log_lines = log_file.lines().collect::<Vec<_>>();
assert_eq!(log_lines.len(), 2);
assert!(log_lines[0].starts_with(&format!(
"0000000000000000000000000000000000000000 {}",
commit_id
)));
assert!(log_lines[0].ends_with(&format!("branch: Created from {}", commit_id)));
assert!(log_lines[1].starts_with(&format!("{} {}", commit_id, oplog_sha)));
assert!(log_lines[1].ends_with(&format!("reset: moving to {oplog_sha}")));
let contents = std::fs::read_to_string(&log_file_path)?;
let lines = reflog_lines(&contents);
assert_eq!(
lines.len(),
2,
"lines parse and it's exactly two, one for branch creation, another for oplog id"
);
let first_line = &lines[0];
assert_eq!(
first_line.previous_oid, "0000000000000000000000000000000000000000",
"start from nothing"
);
assert_eq!(
first_line.new_oid.to_string(),
commit_id.to_string(),
"the new hash is the target id"
);
let first_line_message = format!("branch: Created from {}", commit_id);
assert_eq!(first_line.message, first_line_message);
assert_signature(first_line.signature);
let second_line = &lines[1];
assert_eq!(
second_line.previous_oid.to_string(),
commit_id.to_string(),
"second entry starts where the first left off"
);
assert_eq!(second_line.new_oid.to_string(), oplog_sha.to_string());
let line2_message = format!("reset: moving to {oplog_sha}");
assert_eq!(second_line.message, line2_message);
assert_signature(second_line.signature);
// Update the oplog head only
let another_oplog_sha = git2::Oid::zero().into();
assert!(set_reference_to_oplog(&project, commit_id.into(), another_oplog_sha).is_ok());
let log_file = std::fs::read_to_string(&log_file_path).unwrap();
let log_lines = log_file.lines().collect::<Vec<_>>();
assert_eq!(log_lines.len(), 2);
assert!(log_lines[0].starts_with(&format!(
"0000000000000000000000000000000000000000 {}",
commit_id
)));
assert!(log_lines[0].ends_with(&format!("branch: Created from {}", commit_id)));
assert!(log_lines[1].starts_with(&format!("{} {}", commit_id, another_oplog_sha)));
assert!(log_lines[1].ends_with(&format!("reset: moving to {another_oplog_sha}")));
let another_oplog_sha = git::Oid::from_str("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")?;
set_reference_to_oplog(&worktree_dir, commit_id.into(), another_oplog_sha)
.expect("success");
let contents = std::fs::read_to_string(&log_file_path)?;
let lines: Vec<_> = reflog_lines(&contents);
assert_eq!(lines.len(), 2);
let first_line = &lines[0];
assert_eq!(
format!("{} {}", first_line.previous_oid, first_line.new_oid),
format!("0000000000000000000000000000000000000000 {}", commit_id)
);
assert_eq!(first_line.message, first_line_message);
assert_signature(first_line.signature);
let second_line = &lines[1];
assert_eq!(
format!("{} {}", second_line.previous_oid, second_line.new_oid),
format!("{} {}", commit_id, another_oplog_sha)
);
let second_line_message = format!("reset: moving to {another_oplog_sha}");
assert_eq!(second_line.message, second_line_message);
assert_signature(second_line.signature);
// Update the target head only
let new_target = git::Oid::from_str("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap();
assert!(set_reference_to_oplog(&project, new_target, another_oplog_sha).is_ok());
let log_file = std::fs::read_to_string(&log_file_path).unwrap();
let log_lines = log_file.lines().collect::<Vec<_>>();
assert_eq!(log_lines.len(), 2);
assert!(log_lines[0].starts_with(&format!(
"0000000000000000000000000000000000000000 {new_target}"
)));
assert!(log_lines[0].ends_with(&format!("branch: Created from {new_target}")));
assert!(log_lines[1].starts_with(&format!("{new_target} {another_oplog_sha}")));
assert!(log_lines[1].ends_with(&format!("reset: moving to {another_oplog_sha}")));
let new_target = git::Oid::from_str("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")?;
set_reference_to_oplog(&worktree_dir, new_target, another_oplog_sha).expect("success");
let contents = std::fs::read_to_string(&log_file_path)?;
let lines: Vec<_> = reflog_lines(&contents);
assert_eq!(lines.len(), 2);
let first_line = &lines[0];
assert_eq!(
format!("{} {}", first_line.previous_oid, first_line.new_oid),
format!("0000000000000000000000000000000000000000 {}", new_target)
);
let line1_message = format!("branch: Created from {new_target}");
assert_eq!(first_line.message, line1_message);
assert_signature(first_line.signature);
let second_line = &lines[1];
assert_eq!(
format!("{} {}", second_line.previous_oid, second_line.new_oid),
format!("{} {}", new_target, another_oplog_sha)
);
assert_eq!(second_line.message, second_line_message);
assert_signature(second_line.signature);
Ok(())
}
fn setup_repo() -> (tempfile::TempDir, git2::Oid) {
let dir = tempdir().unwrap();
let repo = git2::Repository::init(dir.path()).unwrap();
fn reflog_lines(contents: &str) -> Vec<LineRef<'_>> {
gix::refs::file::log::iter::forward(contents.as_bytes())
.map(Result::unwrap)
.collect::<Vec<_>>()
}
fn assert_signature(sig: gix::actor::SignatureRef<'_>) {
assert_eq!(sig.name, GITBUTLER_INTEGRATION_COMMIT_AUTHOR_NAME);
assert_eq!(sig.email, GITBUTLER_INTEGRATION_COMMIT_AUTHOR_EMAIL);
}
fn setup_repo() -> anyhow::Result<(tempfile::TempDir, git2::Oid)> {
let dir = tempdir()?;
let repo = git2::Repository::init(dir.path())?;
let file_path = dir.path().join("foo.txt");
std::fs::write(file_path, "test").unwrap();
let mut index = repo.index().unwrap();
index.add_path(&PathBuf::from("foo.txt")).unwrap();
let oid = index.write_tree().unwrap();
std::fs::write(file_path, "test")?;
let mut index = repo.index()?;
index.add_path(&PathBuf::from("foo.txt"))?;
let oid = index.write_tree()?;
let name = "Your Name";
let email = "your.email@example.com";
let signature = git2::Signature::now(name, email).unwrap();
let commit_id = repo
.commit(
Some("HEAD"),
&signature,
&signature,
"initial commit",
&repo.find_tree(oid).unwrap(),
&[],
)
.unwrap();
(dir, commit_id)
let signature = git2::Signature::now(name, email)?;
let commit_id = repo.commit(
Some("HEAD"),
&signature,
&signature,
"initial commit",
&repo.find_tree(oid)?,
&[],
)?;
Ok((dir, commit_id))
}
}

View File

@ -86,15 +86,15 @@ impl Project {
key: "name".to_string(),
value: old_branch.name.to_string(),
}])
} else if let Some(name) = update.name.clone() {
} else if let Some(name) = update.name.as_deref() {
SnapshotDetails::new(OperationKind::UpdateBranchName).with_trailers(vec![
Trailer {
key: "before".to_string(),
value: old_branch.name.to_string(),
value: old_branch.name.clone(),
},
Trailer {
key: "after".to_string(),
value: name,
value: name.to_owned(),
},
])
} else if update.notes.is_some() {
@ -124,19 +124,19 @@ impl Project {
value: old_branch.name.clone(),
},
])
} else if let Some(upstream) = update.upstream.clone() {
} else if let Some(upstream) = update.upstream.as_deref() {
SnapshotDetails::new(OperationKind::UpdateBranchRemoteName).with_trailers(vec![
Trailer {
key: "before".to_string(),
value: old_branch
.upstream
.clone()
.as_ref()
.map(|r| r.to_string())
.unwrap_or("".to_string()),
.unwrap_or_default(),
},
Trailer {
key: "after".to_string(),
value: upstream,
value: upstream.to_owned(),
},
])
} else {

View File

@ -17,8 +17,8 @@ lazy_static! {
}
const WORKSPACE_HEAD: &str = "Workspace Head";
const GITBUTLER_INTEGRATION_COMMIT_AUTHOR_NAME: &str = "GitButler";
const GITBUTLER_INTEGRATION_COMMIT_AUTHOR_EMAIL: &str = "gitbutler@gitbutler.com";
pub const GITBUTLER_INTEGRATION_COMMIT_AUTHOR_NAME: &str = "GitButler";
pub const GITBUTLER_INTEGRATION_COMMIT_AUTHOR_EMAIL: &str = "gitbutler@gitbutler.com";
fn get_committer<'a>() -> Result<git::Signature<'a>> {
Ok(git::Signature::now(

View File

@ -30,11 +30,7 @@ impl Default for TestProject {
let local_tmp = temp_dir();
let local_repository = git::Repository::init_opts(local_tmp.path(), &init_opts())
.expect("failed to init repository");
local_repository
.config()
.unwrap()
.set_local("commit.gpgsign", "false")
.unwrap();
setup_config(&local_repository.config().unwrap()).unwrap();
let mut index = local_repository.index().expect("failed to get index");
let oid = index.write_tree().expect("failed to write tree");
let signature = git::Signature::now("test", "test@email.com").unwrap();
@ -60,11 +56,7 @@ impl Default for TestProject {
.external_template(false),
)
.expect("failed to init repository");
remote_repository
.config()
.unwrap()
.set_local("commit.gpgsign", "false")
.unwrap();
setup_config(&remote_repository.config().unwrap()).unwrap();
{
let mut remote = local_repository
@ -357,3 +349,8 @@ impl TestProject {
submodule.add_finalize().unwrap();
}
}
fn setup_config(config: &git::Config) -> anyhow::Result<()> {
config.set_local("commit.gpgsign", "false")?;
Ok(())
}