gitbutler/crates/gitbutler-oplog/src/oplog.rs
Kiril Videlov 5b7109e8ee refactor(Branch): implement set_head() and make field private
This allows us to control the head setting and update the stack `heads` field accordingly
2024-10-08 12:21:57 +02:00

852 lines
34 KiB
Rust

use std::{
collections::{hash_map::Entry, HashMap},
fs,
path::PathBuf,
str::{from_utf8, FromStr},
time::Duration,
};
use anyhow::{anyhow, bail, Context, Result};
use git2::{DiffOptions, FileMode};
use gitbutler_branch::{Branch, SignaturePurpose, VirtualBranchesHandle, VirtualBranchesState};
use gitbutler_command_context::RepositoryExtLite;
use gitbutler_diff::{hunks_by_filepath, FileDiff};
use gitbutler_project::{
access::{WorktreeReadPermission, WorktreeWritePermission},
Project,
};
use gitbutler_repo::RepositoryExt;
use tracing::instrument;
use super::{
entry::{OperationKind, Snapshot, SnapshotDetails, Trailer},
reflog::set_reference_to_oplog,
state::OplogHandle,
};
const SNAPSHOT_FILE_LIMIT_BYTES: u64 = 32 * 1024 * 1024;
/// The Oplog allows for crating snapshots of the current state of the project as well as restoring to a previous snapshot.
/// Snapshots include the state of the working directory as well as all additional GitButler state (e.g. virtual branches, conflict state).
/// The data is stored as git trees in the following shape:
///
/// ```text
/// .
/// ├── conflicts/…
/// ├── index/
/// ├── target_tree/…
/// ├── virtual_branches
/// │ └── [branch-id]
/// │ ├── commit-message.txt
/// │ └── tree (subtree)
/// │ └── [branch-id]
/// │ ├── commit-message.txt
/// │ └── tree (subtree)
/// └── virtual_branches.toml
/// ```
pub trait OplogExt {
/// Prepares a snapshot of the current state of the working directory as well as GitButler data.
/// Returns a tree hash of the snapshot. The snapshot is not discoverable until it is committed with [`commit_snapshot`](Self::commit_snapshot())
/// If there are files that are untracked and larger than `SNAPSHOT_FILE_LIMIT_BYTES`, they are excluded from snapshot creation and restoring.
fn prepare_snapshot(&self, perm: &WorktreeReadPermission) -> Result<git2::Oid>;
/// Commits the snapshot tree that is created with the [`prepare_snapshot`](Self::prepare_snapshot) method,
/// which yielded the `snapshot_tree_id` for the entire snapshot state.
/// Use `details` to provide metadata about the snapshot.
///
/// Committing it makes the snapshot discoverable in [`list_snapshots`](Self::list_snapshots) as well as
/// restorable with [`restore_snapshot`](Self::restore_snapshot).
///
/// Returns `Some(snapshot_commit_id)` if it was created or `None` if nothing changed between the previous oplog
/// commit and the current one (after comparing trees).
fn commit_snapshot(
&self,
snapshot_tree_id: git2::Oid,
details: SnapshotDetails,
perm: &mut WorktreeWritePermission,
) -> Result<Option<git2::Oid>>;
/// Creates a snapshot of the current state of the working directory as well as GitButler data.
/// This is a convenience method that combines [`prepare_snapshot`](Self::prepare_snapshot) and
/// [`commit_snapshot`](Self::commit_snapshot).
///
/// Returns `Some(snapshot_commit_id)` if it was created or `None` if nothing changed between the previous oplog
/// commit and the current one (after comparing trees).
///
/// Note that errors in snapshot creation is typically ignored, so we want to learn about them.
fn create_snapshot(
&self,
details: SnapshotDetails,
perm: &mut WorktreeWritePermission,
) -> Result<Option<git2::Oid>>;
/// Lists the snapshots that have been created for the given repository, up to the given limit,
/// and with the most recent snapshot first, and at the end of the vec.
///
/// Use `oplog_commit_id` if the traversal root for snapshot discovery should be the specified commit, which
/// is usually obtained from a previous iteration. Useful along with `limit` to allow starting where the iteration
/// left off. Note that the `oplog_commit_id` is always returned as first item in the result vec.
///
/// An alternative way of retrieving the snapshots would be to manually the oplog head `git log <oplog_head>` available in `.git/gitbutler/operations-log.toml`.
///
/// If there are no snapshots, an empty list is returned.
fn list_snapshots(
&self,
limit: usize,
oplog_commit_id: Option<git2::Oid>,
) -> Result<Vec<Snapshot>>;
/// Reverts to a previous state of the working directory, virtual branches and commits.
/// The provided `snapshot_commit_id` must refer to a valid snapshot commit, as returned by [`create_snapshot`](Self::create_snapshot).
/// Upon success, a new snapshot is created representing the state right before this call.
///
/// This will restore the following:
/// - The state of the working directory is checked out from the subtree `workdir` in the snapshot.
/// - The state of virtual branches is restored from the blob `virtual_branches.toml` in the snapshot.
/// - The state of conflicts (.git/base_merge_parent and .git/conflicts) is restored from the subtree `conflicts` in the snapshot (if not present, existing files are deleted).
///
/// If there are files that are untracked and larger than `SNAPSHOT_FILE_LIMIT_BYTES`, they are excluded from snapshot creation and restoring.
/// Returns the sha of the created revert snapshot commit or None if snapshots are disabled.
fn restore_snapshot(&self, snapshot_commit_id: git2::Oid) -> Result<Option<git2::Oid>>;
/// Determines if a new snapshot should be created due to file changes being created since the last snapshot.
/// The needs for the automatic snapshotting are:
/// - It needs to facilitate backup of work in progress code
/// - The snapshots should not be too frequent or small - both for UX and performance reasons
/// - Checking if an automatic snapshot is needed should be fast and efficient since it is called on filesystem events
///
/// Use `check_if_last_snapshot_older_than` as a way to control if the check should be performed at all, i.e.
/// if this is 10s but the last snapshot was done 9s ago, no check if performed and the return value is `false`.
///
/// This implementation returns `true` on the following conditions:
/// - Head is pointing to the workspace branch.
/// - If it's been more than 5 minutes since the last snapshot,
/// check the sum of added and removed lines since the last snapshot, otherwise return `false`.
/// * If the sum of added and removed lines is greater than a configured threshold, return `true`, otherwise return `false`.
fn should_auto_snapshot(&self, check_if_last_snapshot_older_than: Duration) -> Result<bool>;
/// Returns the diff of the snapshot and it's parent. It only includes the workdir changes.
///
/// This is useful to show what has changed in this particular snapshot
fn snapshot_diff(&self, sha: git2::Oid) -> Result<HashMap<PathBuf, FileDiff>>;
/// Gets the sha of the last snapshot commit if present.
fn oplog_head(&self) -> Result<Option<git2::Oid>>;
}
impl OplogExt for Project {
fn prepare_snapshot(&self, perm: &WorktreeReadPermission) -> Result<git2::Oid> {
prepare_snapshot(self, perm)
}
fn commit_snapshot(
&self,
snapshot_tree_id: git2::Oid,
details: SnapshotDetails,
perm: &mut WorktreeWritePermission,
) -> Result<Option<git2::Oid>> {
commit_snapshot(self, snapshot_tree_id, details, perm)
}
#[instrument(skip(self, details, perm), err(Debug))]
fn create_snapshot(
&self,
details: SnapshotDetails,
perm: &mut WorktreeWritePermission,
) -> Result<Option<git2::Oid>> {
let tree_id = prepare_snapshot(self, perm.read_permission())?;
commit_snapshot(self, tree_id, details, perm)
}
#[instrument(skip(self), err(Debug))]
fn list_snapshots(
&self,
limit: usize,
oplog_commit_id: Option<git2::Oid>,
) -> Result<Vec<Snapshot>> {
let repo_path = self.path.as_path();
let repo = git2::Repository::open(repo_path)?;
let traversal_root_id = match oplog_commit_id {
Some(id) => id,
None => {
let oplog_state = OplogHandle::new(&self.gb_dir());
if let Some(id) = oplog_state.oplog_head()? {
id
} else {
return Ok(vec![]);
}
}
};
let oplog_head_commit = repo.find_commit(traversal_root_id)?;
let mut revwalk = repo.revwalk()?;
revwalk.push(oplog_head_commit.id())?;
let mut snapshots = Vec::new();
let mut wd_trees_cache: HashMap<git2::Oid, git2::Oid> = HashMap::new();
for commit_id in revwalk {
if snapshots.len() == limit {
break;
}
let commit_id = commit_id?;
let commit = repo.find_commit(commit_id)?;
if commit.parent_count() > 1 {
break;
}
let tree = commit.tree()?;
if tree.get_name("virtual_branches.toml").is_none() {
// We reached a tree that is not a snapshot
tracing::warn!("Commit {commit_id} didn't seem to be an oplog commit - skipping");
continue;
}
// Get tree id from cache or calculate it
let wd_tree = get_workdir_tree(&mut wd_trees_cache, commit_id, &repo)?;
let details = commit
.message()
.and_then(|msg| SnapshotDetails::from_str(msg).ok());
if let Ok(parent) = commit.parent(0) {
// Get tree id from cache or calculate it
let parent_tree = get_workdir_tree(&mut wd_trees_cache, parent.id(), &repo)?;
let mut opts = DiffOptions::new();
opts.include_untracked(true);
opts.ignore_submodules(true);
let diff =
repo.diff_tree_to_tree(Some(&parent_tree), Some(&wd_tree), Some(&mut opts))?;
let mut files_changed = Vec::new();
diff.print(git2::DiffFormat::NameOnly, |delta, _, _| {
if let Some(path) = delta.new_file().path() {
files_changed.push(path.to_path_buf());
}
true
})?;
let stats = diff.stats()?;
snapshots.push(Snapshot {
commit_id,
details,
lines_added: stats.insertions(),
lines_removed: stats.deletions(),
files_changed,
created_at: commit.time(),
});
} else {
// this is the very first snapshot
snapshots.push(Snapshot {
commit_id,
details,
lines_added: 0,
lines_removed: 0,
files_changed: Vec::new(),
created_at: commit.time(),
});
break;
}
}
Ok(snapshots)
}
fn restore_snapshot(&self, snapshot_commit_id: git2::Oid) -> Result<Option<git2::Oid>> {
let mut guard = self.exclusive_worktree_access();
restore_snapshot(self, snapshot_commit_id, guard.write_permission())
}
#[instrument(level = tracing::Level::DEBUG, skip(self), err(Debug))]
fn should_auto_snapshot(&self, check_if_last_snapshot_older_than: Duration) -> Result<bool> {
let last_snapshot_time = OplogHandle::new(&self.gb_dir()).modified_at()?;
if last_snapshot_time.elapsed()? <= check_if_last_snapshot_older_than {
return Ok(false);
}
let repo = git2::Repository::open(&self.path)?;
if repo.workspace_ref_from_head().is_err() {
return Ok(false);
}
Ok(lines_since_snapshot(self, &repo)? > self.snapshot_lines_threshold())
}
fn snapshot_diff(&self, sha: git2::Oid) -> Result<HashMap<PathBuf, FileDiff>> {
let worktree_dir = self.path.as_path();
let repo = git2::Repository::init(worktree_dir)?;
let commit = repo.find_commit(sha)?;
let wd_tree_id = tree_from_applied_vbranches(&repo, commit.id())?;
let wd_tree = repo.find_tree(wd_tree_id)?;
let old_wd_tree_id = tree_from_applied_vbranches(&repo, commit.parent(0)?.id())?;
let old_wd_tree = repo.find_tree(old_wd_tree_id)?;
repo.ignore_large_files_in_diffs(SNAPSHOT_FILE_LIMIT_BYTES)?;
let mut diff_opts = git2::DiffOptions::new();
diff_opts
.recurse_untracked_dirs(true)
.include_untracked(true)
.show_binary(true)
.ignore_submodules(true)
.show_untracked_content(true);
let diff =
repo.diff_tree_to_tree(Some(&old_wd_tree), Some(&wd_tree), Some(&mut diff_opts))?;
let hunks = hunks_by_filepath(None, &diff)?;
Ok(hunks)
}
/// Gets the sha of the last snapshot commit if present.
fn oplog_head(&self) -> Result<Option<git2::Oid>> {
let oplog_state = OplogHandle::new(&self.gb_dir());
oplog_state.oplog_head()
}
}
/// Get a tree of the working dir (applied branches merged)
fn get_workdir_tree<'a>(
wd_trees_cache: &mut HashMap<git2::Oid, git2::Oid>,
commit_id: git2::Oid,
repo: &'a git2::Repository,
) -> Result<git2::Tree<'a>, anyhow::Error> {
if let Entry::Vacant(e) = wd_trees_cache.entry(commit_id) {
if let Ok(wd_tree_id) = tree_from_applied_vbranches(repo, commit_id) {
e.insert(wd_tree_id);
}
}
let wd_tree_id = wd_trees_cache.get(&commit_id).ok_or(anyhow!(
"Could not get a tree of all applied virtual branches merged"
))?;
let wd_tree = repo.find_tree(wd_tree_id.to_owned())?;
Ok(wd_tree)
}
fn prepare_snapshot(ctx: &Project, _shared_access: &WorktreeReadPermission) -> Result<git2::Oid> {
let worktree_dir = ctx.path.as_path();
let repo = git2::Repository::open(worktree_dir)?;
let vb_state = VirtualBranchesHandle::new(ctx.gb_dir());
// grab the target commit
let default_target_commit = repo.find_commit(vb_state.get_default_target()?.sha)?;
let target_tree_id = default_target_commit.tree_id();
// Create a blob out of `.git/gitbutler/virtual_branches.toml`
let vb_path = repo.path().join("gitbutler").join("virtual_branches.toml");
let vb_content = fs::read(vb_path)?;
let vb_blob_id = repo.blob(&vb_content)?;
// Create a tree out of the conflicts state if present
let conflicts_tree_id = write_conflicts_tree(worktree_dir, &repo)?;
// write out the index as a tree to store
let mut index = repo.index()?;
let index_tree_oid = index.write_tree()?;
// start building our snapshot tree
let mut tree_builder = repo.treebuilder(None)?;
tree_builder.insert("index", index_tree_oid, FileMode::Tree.into())?;
tree_builder.insert("target_tree", target_tree_id, FileMode::Tree.into())?;
tree_builder.insert("conflicts", conflicts_tree_id, FileMode::Tree.into())?;
tree_builder.insert("virtual_branches.toml", vb_blob_id, FileMode::Blob.into())?;
// go through all virtual branches and create a subtree for each with the tree and any commits encoded
let mut branches_tree_builder = repo.treebuilder(None)?;
let mut head_tree_ids = Vec::new();
for branch in vb_state.list_branches_in_workspace()? {
head_tree_ids.push(branch.tree);
// commits in virtual branches (tree and commit data)
// calculate all the commits between branch.head and the target and codify them
let mut branch_tree_builder = repo.treebuilder(None)?;
branch_tree_builder.insert("tree", branch.tree, FileMode::Tree.into())?;
// let's get all the commits between the branch head and the target
let mut revwalk = repo.revwalk()?;
revwalk.push(branch.head())?;
revwalk.hide(default_target_commit.id())?;
let mut commits_tree_builder = repo.treebuilder(None)?;
for commit_id in revwalk {
let commit_id = commit_id?;
let commit = repo.find_commit(commit_id)?;
let commit_tree = commit.tree()?;
let mut commit_tree_builder = repo.treebuilder(None)?;
let commit_data_blob_id = repo.blob(&serialize_commit(&commit))?;
commit_tree_builder.insert("commit", commit_data_blob_id, FileMode::Blob.into())?;
commit_tree_builder.insert("tree", commit_tree.id(), FileMode::Tree.into())?;
let commit_tree_id = commit_tree_builder.write()?;
commits_tree_builder.insert(
commit_id.to_string(),
commit_tree_id,
FileMode::Tree.into(),
)?;
}
let commits_tree_id = commits_tree_builder.write()?;
branch_tree_builder.insert("commits", commits_tree_id, FileMode::Tree.into())?;
let branch_tree_id = branch_tree_builder.write()?;
branches_tree_builder.insert(
branch.id.to_string(),
branch_tree_id,
FileMode::Tree.into(),
)?;
}
// also add the gitbutler/workspace commit to the branches tree
let head = repo.head()?;
if head.name() == Some("refs/heads/gitbutler/workspace") {
let head_commit = head.peel_to_commit()?;
let head_tree = head_commit.tree()?;
let mut head_commit_tree_builder = repo.treebuilder(None)?;
// convert that data into a blob
let commit_data_blob = repo.blob(&serialize_commit(&head_commit))?;
head_commit_tree_builder.insert("commit", commit_data_blob, FileMode::Blob.into())?;
head_commit_tree_builder.insert("tree", head_tree.id(), FileMode::Tree.into())?;
let head_commit_tree_id = head_commit_tree_builder.write()?;
// have to make a subtree to match
let mut commits_tree_builder = repo.treebuilder(None)?;
commits_tree_builder.insert(
head_commit.id().to_string(),
head_commit_tree_id,
FileMode::Tree.into(),
)?;
let commits_tree_id = commits_tree_builder.write()?;
let mut branch_tree_builder = repo.treebuilder(None)?;
branch_tree_builder.insert("tree", head_tree.id(), FileMode::Tree.into())?;
branch_tree_builder.insert("commits", commits_tree_id, FileMode::Tree.into())?;
let branch_tree_id = branch_tree_builder.write()?;
branches_tree_builder.insert("workspace", branch_tree_id, FileMode::Tree.into())?;
}
let branch_tree_id = branches_tree_builder.write()?;
tree_builder.insert("virtual_branches", branch_tree_id, FileMode::Tree.into())?;
let tree_id = tree_builder.write()?;
Ok(tree_id)
}
fn commit_snapshot(
ctx: &Project,
snapshot_tree_id: git2::Oid,
details: SnapshotDetails,
_exclusive_access: &mut WorktreeWritePermission,
) -> Result<Option<git2::Oid>> {
let repo = git2::Repository::open(ctx.path.as_path())?;
let snapshot_tree = repo.find_tree(snapshot_tree_id)?;
let oplog_state = OplogHandle::new(&ctx.gb_dir());
let oplog_head_commit = oplog_state
.oplog_head()?
.and_then(|head_id| repo.find_commit(head_id).ok());
// Construct a new commit
let committer = gitbutler_branch::signature(SignaturePurpose::Committer)?;
let author = gitbutler_branch::signature(SignaturePurpose::Author)?;
let parents = oplog_head_commit
.as_ref()
.map(|head| vec![head])
.unwrap_or_default();
let snapshot_commit_id = repo.commit(
None,
&author,
&committer,
&details.to_string(),
&snapshot_tree,
parents.as_slice(),
)?;
oplog_state.set_oplog_head(snapshot_commit_id)?;
let vb_state = VirtualBranchesHandle::new(ctx.gb_dir());
let target_commit_id = vb_state.get_default_target()?.sha;
set_reference_to_oplog(&ctx.path, target_commit_id, snapshot_commit_id)?;
Ok(Some(snapshot_commit_id))
}
fn restore_snapshot(
ctx: &Project,
snapshot_commit_id: git2::Oid,
exclusive_access: &mut WorktreeWritePermission,
) -> Result<Option<git2::Oid>> {
let worktree_dir = ctx.path.as_path();
let repo = git2::Repository::open(worktree_dir)?;
let before_restore_snapshot_result = prepare_snapshot(ctx, exclusive_access.read_permission());
let snapshot_commit = repo.find_commit(snapshot_commit_id)?;
let snapshot_tree = snapshot_commit.tree()?;
let vb_toml_entry = snapshot_tree
.get_name("virtual_branches.toml")
.context("failed to get virtual_branches.toml blob")?;
// virtual_branches.toml blob
let vb_toml_blob = repo
.find_blob(vb_toml_entry.id())
.context("failed to convert virtual_branches tree entry to blob")?;
if let Err(err) = restore_conflicts_tree(&snapshot_tree, &repo) {
tracing::warn!("failed to restore conflicts tree - ignoring: {err}")
}
// make sure we reconstitute any commits that were in the snapshot that are not here for some reason
// for every entry in the virtual_branches subtree, reconsitute the commits
let vb_tree_entry = snapshot_tree
.get_name("virtual_branches")
.context("failed to get virtual_branches tree entry")?;
let vb_tree = repo
.find_tree(vb_tree_entry.id())
.context("failed to convert virtual_branches tree entry to tree")?;
// walk through all the entries (branches by id)
let walker = vb_tree.iter();
for branch_entry in walker {
let branch_tree = repo
.find_tree(branch_entry.id())
.context("failed to convert virtual_branches tree entry to tree")?;
let branch_name = branch_entry.name();
let commits_tree_entry = branch_tree
.get_name("commits")
.context("failed to get commits tree entry")?;
let commits_tree = repo
.find_tree(commits_tree_entry.id())
.context("failed to convert commits tree entry to tree")?;
// walk through all the commits in the branch
for commit_entry in commits_tree.iter() {
// for each commit, recreate the commit from the commit data if it doesn't exist
if let Some(commit_id) = commit_entry.name() {
// check for the oid in the repo
let commit_oid = git2::Oid::from_str(commit_id)?;
if repo.find_commit(commit_oid).is_err() {
// commit is not in the repo, let's build it from our data
let new_commit_oid = deserialize_commit(&repo, &commit_entry)?;
if new_commit_oid != commit_oid {
bail!("commit id mismatch: failed to recreate a commit from its parts");
}
}
// if branch_name is 'workspace', we need to create or update the gitbutler/workspace branch
if branch_name == Some("workspace") {
// TODO(ST): with `gitoxide`, just update the branch without this dance,
// similar to `git update-ref`.
// Then a missing workspace branch also doesn't have to be
// fatal, but we wouldn't want to `set_head()` if we are
// not already on the workspace branch.
let mut workspace_ref = repo.workspace_ref_from_head()?;
// reset the branch if it's there, otherwise bail as we don't meddle with other branches
// need to detach the head for just a moment.
repo.set_head_detached(commit_oid)?;
workspace_ref.delete()?;
// ok, now we set the branch to what it was and update HEAD
let workspace_commit = repo.find_commit(commit_oid)?;
repo.branch("gitbutler/workspace", &workspace_commit, true)?;
// make sure head is gitbutler/workspace
repo.set_head("refs/heads/gitbutler/workspace")?;
}
}
}
}
repo.workspace_ref_from_head().context(
"We will not change a worktree which for some reason isn't on the workspace branch",
)?;
let workdir_tree_id = tree_from_applied_vbranches(&repo, snapshot_commit_id)?;
let workdir_tree = repo.find_tree(workdir_tree_id)?;
repo.ignore_large_files_in_diffs(SNAPSHOT_FILE_LIMIT_BYTES)?;
// Define the checkout builder
let mut checkout_builder = git2::build::CheckoutBuilder::new();
checkout_builder.remove_untracked(true);
checkout_builder.force();
// Checkout the tree
repo.checkout_tree(workdir_tree.as_object(), Some(&mut checkout_builder))?;
// Update virtual_branches.toml with the state from the snapshot
fs::write(
repo.path().join("gitbutler").join("virtual_branches.toml"),
vb_toml_blob.content(),
)?;
// reset the repo index to our index tree
let index_tree_entry = snapshot_tree
.get_name("index")
.context("failed to get virtual_branches.toml blob")?;
let index_tree = repo
.find_tree(index_tree_entry.id())
.context("failed to convert index tree entry to tree")?;
let mut index = repo.index()?;
index.read_tree(&index_tree)?;
let restored_operation = snapshot_commit
.message()
.and_then(|msg| SnapshotDetails::from_str(msg).ok())
.map(|d| d.operation.to_string())
.unwrap_or_default();
// create new snapshot
let before_restore_snapshot_tree_id = before_restore_snapshot_result?;
let restored_date_ms = snapshot_commit.time().seconds() * 1000;
let details = SnapshotDetails {
version: Default::default(),
operation: OperationKind::RestoreFromSnapshot,
title: "Restored from snapshot".to_string(),
body: None,
trailers: vec![
Trailer {
key: "restored_from".to_string(),
value: snapshot_commit_id.to_string(),
},
Trailer {
key: "restored_operation".to_string(),
value: restored_operation,
},
Trailer {
key: "restored_date".to_string(),
value: restored_date_ms.to_string(),
},
],
};
commit_snapshot(
ctx,
before_restore_snapshot_tree_id,
details,
exclusive_access,
)
}
/// Restore the state of .git/base_merge_parent and .git/conflicts from the snapshot
/// Will remove those files if they are not present in the snapshot
fn restore_conflicts_tree(snapshot_tree: &git2::Tree, repo: &git2::Repository) -> Result<()> {
let conflicts_tree_entry = snapshot_tree
.get_name("conflicts")
.context("failed to get conflicts tree entry")?;
let conflicts_tree = repo.find_tree(conflicts_tree_entry.id())?;
let base_merge_parent_entry = conflicts_tree.get_name("base_merge_parent");
let base_merge_parent_path = repo.path().join("base_merge_parent");
if let Some(base_merge_parent_blob) = base_merge_parent_entry {
let base_merge_parent_blob = repo
.find_blob(base_merge_parent_blob.id())
.context("failed to convert base_merge_parent tree entry to blob")?;
fs::write(base_merge_parent_path, base_merge_parent_blob.content())?;
} else if base_merge_parent_path.exists() {
fs::remove_file(base_merge_parent_path)?;
}
let conflicts_entry = conflicts_tree.get_name("conflicts");
let conflicts_path = repo.path().join("conflicts");
if let Some(conflicts_entry) = conflicts_entry {
let conflicts_blob = repo
.find_blob(conflicts_entry.id())
.context("failed to convert conflicts tree entry to blob")?;
fs::write(conflicts_path, conflicts_blob.content())?;
} else if conflicts_path.exists() {
fs::remove_file(conflicts_path)?;
}
Ok(())
}
fn write_conflicts_tree(
worktree_dir: &std::path::Path,
repo: &git2::Repository,
) -> Result<git2::Oid> {
let git_dir = worktree_dir.join(".git");
let merge_parent_path = git_dir.join("base_merge_parent");
let merge_parent_blob = if merge_parent_path.exists() {
let merge_parent_content = fs::read(merge_parent_path)?;
Some(repo.blob(&merge_parent_content)?)
} else {
None
};
let conflicts_path = git_dir.join("conflicts");
let conflicts_blob = if conflicts_path.exists() {
let conflicts_content = fs::read(conflicts_path)?;
Some(repo.blob(&conflicts_content)?)
} else {
None
};
let mut tree_builder = repo.treebuilder(None)?;
if merge_parent_blob.is_some() {
tree_builder.insert(
"base_merge_parent",
merge_parent_blob.unwrap(),
FileMode::Blob.into(),
)?;
}
if conflicts_blob.is_some() {
tree_builder.insert("conflicts", conflicts_blob.unwrap(), FileMode::Blob.into())?;
}
let conflicts_tree = tree_builder.write()?;
Ok(conflicts_tree)
}
/// Returns the number of lines of code (added + removed) since the last snapshot in `project`.
/// Includes untracked files.
/// `repo` is an already opened project repository.
///
/// If there are no snapshots, 0 is returned.
fn lines_since_snapshot(project: &Project, repo: &git2::Repository) -> Result<usize> {
// This looks at the diff between the tree of the currently selected as 'default' branch (where new changes go)
// and that same tree in the last snapshot. For some reason, comparing workdir to the workdir subree from
// the snapshot simply does not give us what we need here, so instead using tree to tree comparison.
repo.ignore_large_files_in_diffs(SNAPSHOT_FILE_LIMIT_BYTES)?;
let oplog_state = OplogHandle::new(&project.gb_dir());
let Some(oplog_commit_id) = oplog_state.oplog_head()? else {
return Ok(0);
};
let vbranches = VirtualBranchesHandle::new(project.gb_dir()).list_branches_in_workspace()?;
let mut lines_changed = 0;
let dirty_branches = vbranches.iter().filter(|b| !b.ownership.claims.is_empty());
for branch in dirty_branches {
lines_changed += branch_lines_since_snapshot(branch, repo, oplog_commit_id)?;
}
Ok(lines_changed)
}
#[instrument(level = tracing::Level::DEBUG, skip(branch, repo), err(Debug))]
fn branch_lines_since_snapshot(
branch: &Branch,
repo: &git2::Repository,
head_sha: git2::Oid,
) -> Result<usize> {
let active_branch_tree = repo.find_tree(branch.tree)?;
let commit = repo.find_commit(head_sha)?;
let head_tree = commit.tree()?;
let virtual_branches = head_tree
.get_name("virtual_branches")
.ok_or_else(|| anyhow!("failed to get virtual_branches tree entry"))?;
let virtual_branches = repo.find_tree(virtual_branches.id())?;
let old_active_branch = virtual_branches
.get_name(branch.id.to_string().as_str())
.ok_or_else(|| anyhow!("failed to get active branch from tree entry"))?;
let old_active_branch = repo.find_tree(old_active_branch.id())?;
let old_active_branch_tree = old_active_branch
.get_name("tree")
.ok_or_else(|| anyhow!("failed to get workspace tree entry"))?;
let old_active_branch_tree = repo.find_tree(old_active_branch_tree.id())?;
let mut opts = git2::DiffOptions::new();
opts.include_untracked(true);
opts.ignore_submodules(true);
let diff = repo.diff_tree_to_tree(
Some(&active_branch_tree),
Some(&old_active_branch_tree),
Some(&mut opts),
);
let stats = diff?.stats()?;
Ok(stats.deletions() + stats.insertions())
}
fn serialize_commit(commit: &git2::Commit<'_>) -> Vec<u8> {
let commit_header = commit.raw_header_bytes();
let commit_message = commit.message_raw_bytes();
[commit_header, b"\n", commit_message].concat()
}
/// we get the data from the blob entry and re-create a commit object from it,
/// whose returned id should match the one we stored.
fn deserialize_commit(
repo: &git2::Repository,
commit_entry: &git2::TreeEntry,
) -> Result<git2::Oid> {
let commit_tree = repo
.find_tree(commit_entry.id())
.context("failed to convert commit tree entry to tree")?;
let commit_blob_entry = commit_tree
.get_name("commit")
.context("failed to get workdir tree entry")?;
let commit_blob = repo
.find_blob(commit_blob_entry.id())
.context("failed to convert commit tree entry to blob")?;
let new_commit_oid = repo
.odb()?
.write(git2::ObjectType::Commit, commit_blob.content())?;
Ok(new_commit_oid)
}
/// Creates a tree that is the merge of all applied branches from a given snapshot and returns the tree id.
fn tree_from_applied_vbranches(
repo: &git2::Repository,
snapshot_commit_id: git2::Oid,
) -> Result<git2::Oid> {
let snapshot_commit = repo.find_commit(snapshot_commit_id)?;
let snapshot_tree = snapshot_commit.tree()?;
let target_tree_entry = snapshot_tree
.get_name("target_tree")
.context("failed to get target tree entry")?;
let target_tree = repo
.find_tree(target_tree_entry.id())
.context("failed to convert target tree entry to tree")?;
let vb_toml_entry = snapshot_tree
.get_name("virtual_branches.toml")
.context("failed to get virtual_branches.toml blob")?;
// virtual_branches.toml blob
let vb_toml_blob = repo
.find_blob(vb_toml_entry.id())
.context("failed to convert virtual_branches tree entry to blob")?;
let vbs_from_toml: VirtualBranchesState = toml::from_str(from_utf8(vb_toml_blob.content())?)?;
let applied_branch_trees: Vec<git2::Oid> = vbs_from_toml
.list_branches_in_workspace()?
.iter()
.map(|b| b.tree)
.collect();
let mut workdir_tree_id = target_tree.id();
let base_tree = target_tree;
let mut current_ours = base_tree.clone();
for branch in applied_branch_trees {
let branch_tree = repo.find_tree(branch)?;
let mut merge_options: git2::MergeOptions = git2::MergeOptions::new();
merge_options.fail_on_conflict(false);
let mut workdir_temp_index = repo.merge_trees(
&base_tree,
&current_ours,
&branch_tree,
Some(&merge_options),
)?;
match workdir_temp_index.write_tree_to(repo) {
Ok(id) => {
workdir_tree_id = id;
current_ours = repo.find_tree(workdir_tree_id)?;
}
Err(_err) => {
tracing::warn!("Failed to merge tree {branch} - this branch is probably applied at a time when it should not be");
}
}
}
Ok(workdir_tree_id)
}