mirror of
https://github.com/gitbutlerapp/gitbutler.git
synced 2024-12-28 20:15:20 +03:00
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).
This commit is contained in:
parent
a2aafd919b
commit
f6ec80d8ce
17
Cargo.lock
generated
17
Cargo.lock
generated
@ -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"
|
||||
|
@ -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]
|
||||
|
19
crates/gitbutler-stack/Cargo.toml
Normal file
19
crates/gitbutler-stack/Cargo.toml
Normal file
@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "gitbutler-stack"
|
||||
version = "0.0.0"
|
||||
edition = "2021"
|
||||
authors = ["GitButler <gitbutler@gitbutler.com>"]
|
||||
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
|
2
crates/gitbutler-stack/src/lib.rs
Normal file
2
crates/gitbutler-stack/src/lib.rs
Normal file
@ -0,0 +1,2 @@
|
||||
mod stack;
|
||||
pub use stack::{PatchReferenceUpdate, Stack};
|
332
crates/gitbutler-stack/src/stack.rs
Normal file
332
crates/gitbutler-stack/src/stack.rs
Normal file
@ -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<ReferenceTarget>,
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<git2::Commit<'a>> {
|
||||
// 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<git2::Commit<'a>> {
|
||||
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<bool> {
|
||||
let gix_repo = ctx.gix_repository()?;
|
||||
Ok(gix_repo.find_reference(name_partial(name.into())?).is_ok())
|
||||
}
|
Loading…
Reference in New Issue
Block a user