From f6ec80d8ce4377a21f5878b22fe25e14a9eed765 Mon Sep 17 00:00:00 2001 From: Kiril Videlov Date: Sun, 29 Sep 2024 19:26:39 +0200 Subject: [PATCH] Adds a trait `Stack` with an implementation for `Branch` This provides a well defined interface for interacting with Stacks. It is implemented for gitbutler_branch::Branch, and it is specifically meant to operate on and update the `heads` field of Branch This facilitates creating, updating, removing, pushing and listing of "stacked branches" within the Stack (formerly the virtual branch). --- Cargo.lock | 17 ++ Cargo.toml | 2 + crates/gitbutler-stack/Cargo.toml | 19 ++ crates/gitbutler-stack/src/lib.rs | 2 + crates/gitbutler-stack/src/stack.rs | 332 ++++++++++++++++++++++++++++ 5 files changed, 372 insertions(+) create mode 100644 crates/gitbutler-stack/Cargo.toml create mode 100644 crates/gitbutler-stack/src/lib.rs create mode 100644 crates/gitbutler-stack/src/stack.rs diff --git a/Cargo.lock b/Cargo.lock index 115a20af5..7325ade64 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2536,6 +2536,23 @@ dependencies = [ "serde", ] +[[package]] +name = "gitbutler-stack" +version = "0.0.0" +dependencies = [ + "anyhow", + "git2", + "gitbutler-branch", + "gitbutler-command-context", + "gitbutler-commit", + "gitbutler-patch-reference", + "gitbutler-reference", + "gitbutler-repo", + "gix", + "itertools 0.13.0", + "serde", +] + [[package]] name = "gitbutler-storage" version = "0.0.0" diff --git a/Cargo.toml b/Cargo.toml index ef65664d9..532767694 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,7 @@ members = [ "crates/gitbutler-edit-mode", "crates/gitbutler-cherry-pick", "crates/gitbutler-oxidize", + "crates/gitbutler-stack", "crates/gitbutler-patch-reference", ] resolver = "2" @@ -87,6 +88,7 @@ gitbutler-operating-modes = { path = "crates/gitbutler-operating-modes" } gitbutler-edit-mode = { path = "crates/gitbutler-edit-mode" } gitbutler-cherry-pick = { path = "crates/gitbutler-cherry-pick" } gitbutler-oxidize = { path = "crates/gitbutler-oxidize" } +gitbutler-stack = { path = "crates/gitbutler-stack" } gitbutler-patch-reference = { path = "crates/gitbutler-patch-reference" } [profile.release] diff --git a/crates/gitbutler-stack/Cargo.toml b/crates/gitbutler-stack/Cargo.toml new file mode 100644 index 000000000..46b130862 --- /dev/null +++ b/crates/gitbutler-stack/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "gitbutler-stack" +version = "0.0.0" +edition = "2021" +authors = ["GitButler "] +publish = false + +[dependencies] +anyhow = "1.0.86" +itertools = "0.13" +serde = { workspace = true, features = ["std"] } +git2.workspace = true +gix.workspace = true +gitbutler-command-context.workspace = true +gitbutler-branch.workspace = true +gitbutler-patch-reference.workspace = true +gitbutler-reference.workspace = true +gitbutler-repo.workspace = true +gitbutler-commit.workspace = true diff --git a/crates/gitbutler-stack/src/lib.rs b/crates/gitbutler-stack/src/lib.rs new file mode 100644 index 000000000..3ca66fc52 --- /dev/null +++ b/crates/gitbutler-stack/src/lib.rs @@ -0,0 +1,2 @@ +mod stack; +pub use stack::{PatchReferenceUpdate, Stack}; diff --git a/crates/gitbutler-stack/src/stack.rs b/crates/gitbutler-stack/src/stack.rs new file mode 100644 index 000000000..eaef83e82 --- /dev/null +++ b/crates/gitbutler-stack/src/stack.rs @@ -0,0 +1,332 @@ +use std::str::FromStr; + +use anyhow::anyhow; +use anyhow::Context; +use anyhow::Result; +use gitbutler_branch::Branch; +use gitbutler_branch::Target; +use gitbutler_branch::VirtualBranchesHandle; +use gitbutler_command_context::CommandContext; +use gitbutler_commit::commit_ext::CommitExt; +use gitbutler_patch_reference::{PatchReference, ReferenceTarget}; +use gitbutler_reference::normalize_branch_name; +use gitbutler_reference::RemoteRefname; +use gitbutler_repo::LogUntil; +use gitbutler_repo::RepoActionsExt; +use gitbutler_repo::RepositoryExt; +use gix::validate::reference::name_partial; +use itertools::Itertools; +use serde::{Deserialize, Serialize}; + +/// A Stack represents multiple "branches" that are dependent on each other in series. +/// +/// An initialized Stack must: +/// - have at least one head (branch) +/// - include only referecences that are part of the stack +/// - always have it's commits under a reference i.e. no orphaned commits +pub trait Stack { + /// An initialized stack has at least one head (branch). + fn initialized(&self) -> bool; + + /// Initializes a new stack. + /// An initialized stack means that the heads will always have at least one entry. + /// When initialized, first stack head will point to the "Branch" head. + /// Errors out if the stack has already been initialized. + /// + /// This operation mutates the gitbutler::Branch.heads list and updates the state in `virtual_branches.toml` + fn init(&mut self, ctx: &CommandContext) -> Result<()>; + + /// Adds a new "Branch" to the Stack. + /// This is in fact just creating a new GitButler patch reference (head) and associates it with the stack. + /// The name cannot be the same as existing git references or existing patch references. + /// The target must reference a commit (or change) that is part of the stack. + /// The branch name must be a valid reference name (i.e. can not contain spaces, special characters etc.) + /// + /// This operation mutates the gitbutler::Branch.heads list and updates the state in `virtual_branches.toml` + fn add_branch(&mut self, ctx: &CommandContext, head: PatchReference) -> Result<()>; + + /// Removes a branch from the Stack. + /// The very last branch (reference) cannot be removed (A Stack must always contains at least one reference) + /// If there were commits/changes that were *only* referenced by the removed branch, + /// those commits are moved to the branch underneath it (or more accurately, the precee) + /// + /// This operation mutates the gitbutler::Branch.heads list and updates the state in `virtual_branches.toml` + fn remove_branch(&mut self, ctx: &CommandContext, branch_name: String) -> Result<()>; + + /// Updates an existing branch in the stack. + /// The same invariants as `add_branch` apply. + /// + /// This operation mutates the gitbutler::Branch.heads list and updates the state in `virtual_branches.toml` + fn update_branch( + &mut self, + ctx: &CommandContext, + branch_name: String, + update: PatchReferenceUpdate, + ) -> Result<()>; + + /// Pushes the reference (branch) to the Stack remote as derived from the default target. + /// This operation will error out if the target has no push remote configured. + fn push_branch( + &self, + ctx: &CommandContext, + branch_name: String, + with_force: bool, + ) -> Result<()>; +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct PatchReferenceUpdate { + pub target: Option, + pub name: Option, +} + +/// A Stack implementation for gitbutler_branch::Branch +/// This operates via a list of PatchReferences (heads) that are an attribute of gitbutler_branch::Branch. +/// In this context a (virtual) "Branch" is a stack of PatchReferences, each pointing to a commit (or change) within the stack. +/// +/// This trait provides a defined interface for interacting with a Stack, the field `heads` on `Branch` should never be modified directly +/// outside of the trait implementation. +impl Stack for Branch { + fn initialized(&self) -> bool { + !self.heads.is_empty() + } + fn init(&mut self, ctx: &CommandContext) -> Result<()> { + if self.initialized() { + return Err(anyhow!("Stack already initialized")); + } + let reference = PatchReference { + target: ReferenceTarget::CommitId(self.head.to_string()), + name: normalize_branch_name(&self.name)?, + }; + let state = branch_state(ctx); + validate_name(&reference, ctx, &state)?; + self.heads = vec![reference]; + state.set_branch(self.clone()) + } + + fn add_branch(&mut self, ctx: &CommandContext, head: PatchReference) -> Result<()> { + if !self.initialized() { + return Err(anyhow!("Stack has not been initialized")); + } + let state = branch_state(ctx); + validate_name(&head, ctx, &state)?; + validate_target(&head, ctx, self.head, &state)?; + self.heads.push(head); + state.set_branch(self.clone()) + } + + fn remove_branch(&mut self, ctx: &CommandContext, branch_name: String) -> Result<()> { + if !self.initialized() { + return Err(anyhow!("Stack has not been initialized")); + } + // find the head that corresponds to the supplied name, together with its index + let (idx, head) = get_branch(self, &branch_name)?; + if self.heads.len() == 1 { + return Err(anyhow!("Cannot remove the last branch from the stack")); + } + // The branch that is being removed is the top (last) one. + // This means that if there are commits, they need to be moved to the branch underneath. + if self.heads.len() - 1 == idx { + // Getting the preceeding head and setting it's target to the target of the head being removed + let prior_head = self + .heads + .get_mut(idx - 1) + .ok_or_else(|| anyhow!("Cannot get the head before the head being removed"))?; + prior_head.target = head.target.clone(); + } + self.heads.remove(idx); + let state = branch_state(ctx); + state.set_branch(self.clone()) + } + + fn update_branch( + &mut self, + ctx: &CommandContext, + branch_name: String, + update: PatchReferenceUpdate, + ) -> Result<()> { + if !self.initialized() { + return Err(anyhow!("Stack has not been initialized")); + } + let (idx, head) = get_branch(self, &branch_name)?; + let mut updated_head = head.clone(); + let state = branch_state(ctx); + if let Some(target) = update.target { + updated_head.target = target; + validate_target(&updated_head, ctx, self.head, &state)?; + } + if let Some(name) = update.name { + updated_head.name = name; + validate_name(&updated_head, ctx, &state)?; + } + // replace the value in self.heads at index idx with updated_head + if let Some(entry) = self.heads.get_mut(idx) { + *entry = updated_head; + } else { + return Err(anyhow!("Could not find the head to update")); + } + state.set_branch(self.clone()) + } + + fn push_branch( + &self, + ctx: &CommandContext, + branch_name: String, + with_force: bool, + ) -> Result<()> { + if !self.initialized() { + return Err(anyhow!("Stack has not been initialized")); + } + let (_, reference) = get_branch(self, &branch_name)?; + let default_target = branch_state(ctx).get_default_target()?; + let commit = get_target_commit(&reference.target, ctx, self.head, &default_target)?; + let remote_name = branch_state(ctx) + .get_default_target()? + .push_remote_name + .ok_or(anyhow!( + "No remote has been configured for the target branch" + ))?; + let upstream_refname = RemoteRefname::from_str(&reference.remote_reference(remote_name)?) + .context("Failed to parse the remote reference for branch")?; + ctx.push( + commit.id(), + &upstream_refname, + with_force, + None, + Some(Some(self.id)), + ) + } +} + +/// Validates that the commit in the reference target +/// - exists +/// - is between the stack (formerly vbranch) head (inclusive) and base (inclusive) +fn validate_target( + reference: &PatchReference, + ctx: &CommandContext, + stack_head: git2::Oid, + state: &VirtualBranchesHandle, +) -> Result<()> { + let default_target = state.get_default_target()?; + let commit = get_target_commit(&reference.target, ctx, stack_head, &default_target)?; + let stack_commits = ctx + .repository() + // TODO: seems like the range that is actually needed is from head to the merge base + .log(stack_head, LogUntil::Commit(default_target.sha))? + .iter() + .map(|c| c.id()) + .collect_vec(); + if !stack_commits.contains(&commit.id()) { + return Err(anyhow!( + "The commit {} is not between the stack head and the stack base", + commit.id() + )); + } + Ok(()) +} + +/// Validates the name of the stack head. +/// The name must be: +/// - unique within all stacks +/// - not the same as any existing git reference +/// - not including the `refs/heads/` prefix +fn validate_name( + reference: &PatchReference, + ctx: &CommandContext, + state: &VirtualBranchesHandle, +) -> Result<()> { + if reference.name.starts_with("refs/heads") { + return Err(anyhow!("Stack head name cannot start with 'refs/heads'")); + } + // assert that the name is a valid branch name + name_partial(reference.name.as_str().into()).context("Invalid branch name")?; + // assert that there is no local git reference with this name + if reference_exists(ctx, &reference.name)? { + return Err(anyhow!( + "A git reference with the name {} exists", + &reference.name + )); + } + let default_target = state.get_default_target()?; + // assert that there is no remote git reference with this name + if let Some(remote_name) = default_target.push_remote_name { + if reference_exists(ctx, &reference.remote_reference(remote_name)?)? { + return Err(anyhow!( + "A git reference with the name {} exists", + &reference.name + )); + } + } + // assert that there are no existing patch references with this name + if state + .list_all_branches()? + .iter() + .flat_map(|b| b.heads.iter()) + .any(|r| r.name == reference.name) + { + return Err(anyhow!( + "A patch reference with the name {} exists", + &reference.name + )); + } + + Ok(()) +} + +/// Given a branch id and a change id, returns the commit associated with the change id. +// TODO: We need a more efficient way of getting a commit by change id. +fn commit_by_branch_id_and_change_id<'a>( + ctx: &'a CommandContext, + stack_head: git2::Oid, // branch.head + target_sha: git2::Oid, // default_target.sha + change_id: &str, +) -> Result> { + // Find the commit with the change id + let commit = ctx + .repository() + // TODO: seems like the range that is actually needed is from head to the merge base + .log(stack_head, LogUntil::Commit(target_sha))? + .iter() + .map(|c| c.id()) + .find(|c| { + let commit = ctx.repository().find_commit(*c).expect("Commit not found"); + commit.change_id().as_deref() == Some(change_id) + }) + .and_then(|c| ctx.repository().find_commit(c).ok()) + .ok_or_else(|| anyhow!("Commit with change id {} not found", change_id))?; + Ok(commit) +} + +fn branch_state(ctx: &CommandContext) -> VirtualBranchesHandle { + VirtualBranchesHandle::new(ctx.project().gb_dir()) +} + +fn get_branch(stack: &Branch, name: &str) -> Result<(usize, PatchReference)> { + let (idx, head) = stack + .heads + .clone() + .into_iter() + .enumerate() + .find(|(_, h)| h.name == name) + .ok_or_else(|| anyhow!("Branch {} not found", name))?; + Ok((idx, head)) +} + +fn get_target_commit<'a>( + reference_target: &'a ReferenceTarget, + ctx: &'a CommandContext, + stack_head: git2::Oid, + default_target: &Target, +) -> Result> { + Ok(match reference_target { + ReferenceTarget::CommitId(commit_id) => ctx.repository().find_commit(commit_id.parse()?)?, + ReferenceTarget::ChangeId(change_id) => { + commit_by_branch_id_and_change_id(ctx, stack_head, default_target.sha, change_id)? + } + }) +} + +fn reference_exists(ctx: &CommandContext, name: &str) -> Result { + let gix_repo = ctx.gix_repository()?; + Ok(gix_repo.find_reference(name_partial(name.into())?).is_ok()) +}