Remove stack extention trait - now it can be an impl block

This commit is contained in:
Kiril Videlov 2024-10-22 16:08:59 +02:00
parent 7a4bd35d19
commit 068833059d
17 changed files with 811 additions and 904 deletions

View File

@ -8,7 +8,6 @@ use gitbutler_project::FetchResult;
use gitbutler_reference::{Refname, RemoteRefname};
use gitbutler_repo::{LogUntil, RepositoryExt};
use gitbutler_repo_actions::RepoActionsExt;
use gitbutler_stack::StackExt;
use gitbutler_stack::{BranchOwnershipClaims, Stack, Target, VirtualBranchesHandle};
use serde::Serialize;

View File

@ -13,7 +13,6 @@ use gitbutler_repo::{
LogUntil, RepositoryExt,
};
use gitbutler_repo_actions::RepoActionsExt;
use gitbutler_stack::StackExt;
use gitbutler_stack::{BranchOwnershipClaims, Stack, StackId};
use gitbutler_time::time::now_since_unix_epoch_ms;
use tracing::instrument;

View File

@ -5,8 +5,8 @@ use gitbutler_repo::{
rebase::{cherry_rebase_group, gitbutler_merge_commits},
LogUntil, RepositoryExt as _,
};
use gitbutler_stack::commit_by_oid_or_change_id;
use gitbutler_stack::StackId;
use gitbutler_stack::{commit_by_oid_or_change_id, StackExt};
use crate::{
branch_trees::{

View File

@ -12,7 +12,6 @@ use gitbutler_operating_modes::OPEN_WORKSPACE_REFS;
use gitbutler_project::access::WorktreeWritePermission;
use gitbutler_repo::SignaturePurpose;
use gitbutler_repo::{LogUntil, RepositoryExt};
use gitbutler_stack::StackExt;
use gitbutler_stack::{Stack, VirtualBranchesHandle};
use tracing::instrument;

View File

@ -7,7 +7,6 @@ use gitbutler_command_context::CommandContext;
use gitbutler_commit::commit_ext::CommitExt;
use gitbutler_project::access::WorktreeWritePermission;
use gitbutler_repo::{rebase::cherry_rebase_group, LogUntil, RepositoryExt};
use gitbutler_stack::StackExt;
use gitbutler_stack::{OwnershipClaim, StackId};
use std::collections::HashMap;

View File

@ -2,7 +2,6 @@ use anyhow::{bail, Context as _, Result};
use gitbutler_command_context::CommandContext;
use gitbutler_project::access::WorktreeWritePermission;
use gitbutler_repo::{rebase::cherry_rebase_group, LogUntil, RepositoryExt as _};
use gitbutler_stack::StackExt;
use gitbutler_stack::StackId;
use crate::{

View File

@ -7,7 +7,7 @@ use gitbutler_commit::commit_ext::CommitExt;
use gitbutler_patch_reference::{CommitOrChangeId, PatchReference};
use gitbutler_project::Project;
use gitbutler_repo_actions::RepoActionsExt;
use gitbutler_stack::{commit_by_oid_or_change_id, CommitsForId, PatchReferenceUpdate, StackExt};
use gitbutler_stack::{commit_by_oid_or_change_id, CommitsForId, PatchReferenceUpdate};
use gitbutler_stack::{Stack, StackId, Target};
use serde::{Deserialize, Serialize};

View File

@ -15,7 +15,6 @@ use gitbutler_command_context::CommandContext;
use gitbutler_diff::{diff_files_into_hunks, GitHunk, Hunk, HunkHash};
use gitbutler_operating_modes::assure_open_workspace_mode;
use gitbutler_project::access::WorktreeWritePermission;
use gitbutler_stack::StackExt;
use gitbutler_stack::{BranchOwnershipClaims, OwnershipClaim, Stack, StackId};
use tracing::instrument;

View File

@ -2,7 +2,6 @@ use anyhow::{bail, Context as _, Result};
use gitbutler_command_context::CommandContext;
use gitbutler_commit::commit_ext::CommitExt as _;
use gitbutler_repo::{rebase::cherry_rebase_group, LogUntil, RepositoryExt as _};
use gitbutler_stack::StackExt;
use gitbutler_stack::{Stack, StackId};
use crate::VirtualBranchesExt as _;

View File

@ -7,7 +7,6 @@ use gitbutler_repo::{
LogUntil, RepositoryExt as _,
};
use gitbutler_repo_actions::RepoActionsExt as _;
use gitbutler_stack::StackExt;
use gitbutler_stack::{Stack, StackId, Target, VirtualBranchesHandle};
use serde::{Deserialize, Serialize};

View File

@ -28,7 +28,6 @@ use gitbutler_repo::{
LogUntil, RepositoryExt,
};
use gitbutler_repo_actions::RepoActionsExt;
use gitbutler_stack::StackExt;
use gitbutler_stack::{
reconcile_claims, BranchOwnershipClaims, Stack, StackId, Target, VirtualBranchesHandle,
};

View File

@ -20,7 +20,6 @@ use gitbutler_branch_actions::{
use gitbutler_commit::{commit_ext::CommitExt, commit_headers::CommitHeadersV2};
use gitbutler_reference::{Refname, RemoteRefname};
use gitbutler_repo::RepositoryExt;
use gitbutler_stack::StackExt;
use gitbutler_stack::{BranchOwnershipClaims, Target, VirtualBranchesHandle};
use gitbutler_testsupport::{commit_all, virtual_branches::set_test_target, Case, Suite};
use pretty_assertions::assert_eq;

View File

@ -25,7 +25,6 @@ use gitbutler_project::access::{WorktreeReadPermission, WorktreeWritePermission}
use gitbutler_reference::{ReferenceName, Refname};
use gitbutler_repo::{rebase::cherry_rebase, RepositoryExt};
use gitbutler_repo::{signature, SignaturePurpose};
use gitbutler_stack::StackExt;
use gitbutler_stack::{Stack, VirtualBranchesHandle};
use serde::Serialize;

View File

@ -12,8 +12,5 @@ pub use target::Target;
mod heads;
mod series;
mod stack_ext;
pub use series::Series;
pub use stack_ext::{
commit_by_oid_or_change_id, CommitsForId, PatchReferenceUpdate, StackExt, TargetUpdate,
};
pub use stack::{commit_by_oid_or_change_id, CommitsForId, PatchReferenceUpdate, TargetUpdate};

View File

@ -1,10 +1,27 @@
use std::collections::HashMap;
use std::str::FromStr;
use anyhow::anyhow;
use anyhow::bail;
use anyhow::Context;
use anyhow::Result;
use git2::Commit;
use gitbutler_command_context::CommandContext;
use gitbutler_commit::commit_ext::CommitExt;
use gitbutler_id::id::Id;
use gitbutler_patch_reference::PatchReference;
use gitbutler_patch_reference::{CommitOrChangeId, PatchReference};
use gitbutler_reference::{normalize_branch_name, Refname, RemoteRefname, VirtualRefname};
use gitbutler_repo::{LogUntil, RepositoryExt};
use gix::validate::reference::name_partial;
use gix_utils::str::decompose;
use itertools::Itertools;
use serde::{Deserialize, Serialize};
use crate::ownership::BranchOwnershipClaims;
use crate::heads::add_head;
use crate::heads::get_head;
use crate::heads::remove_head;
use crate::Series;
use crate::{ownership::BranchOwnershipClaims, VirtualBranchesHandle};
pub type StackId = Id<Stack>;
@ -83,8 +100,24 @@ where
Ok(x)
}
/// A (series) Stack represents multiple "branches" that are dependent on each other in series.
///
/// An initialized Stack must:
/// - have at least one head (branch)
/// - include only references that are part of the stack
/// - always have its commits under a reference i.e. no orphaned commits
///
/// 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 the trait implementation.
///
/// The heads must always be sorted in accordance with the order of the patches in the stack.
/// The first patches are in the beginning of the list and the most recent patches are at the end of the list (top of the stack)
/// Similarly, heads that point to earlier commits are first in the order, and the last head always points to the most recent patch.
/// If there are multiple heads that point to the same patch, the `add` and `update` operations can specify the intended order.
impl Stack {
/// DO NOT USE THIS DIRECTLY, use `stack_ext::StackExt::create` instead.
/// Creates a new `Branch` with the given name. The `in_workspace` flag is set to `true`.
#[allow(clippy::too_many_arguments)]
#[deprecated(note = "DO NOT USE THIS DIRECTLY, use `stack_ext::StackExt::create` instead.")]
@ -129,12 +162,508 @@ impl Stack {
self.head
}
#[deprecated(
note = "DO NOT USE THIS DIRECTLY, use `stack_ext::StackExt::set_stack_head` instead."
)]
pub fn set_head(&mut self, head: git2::Oid) {
fn set_head(&mut self, head: git2::Oid) {
self.head = head;
}
// TODO: When this is stable, make it error out on initialization failure
/// Constructs and initializes a new Stack.
/// If initialization fails, a warning is logged and the stack is returned as is.
#[allow(clippy::too_many_arguments)]
pub fn create(
ctx: &CommandContext,
name: String,
source_refname: Option<Refname>,
upstream: Option<RemoteRefname>,
upstream_head: Option<git2::Oid>,
tree: git2::Oid,
head: git2::Oid,
order: usize,
selected_for_changes: Option<i64>,
allow_rebasing: bool,
) -> Self {
#[allow(deprecated)]
// this should be the only place (other than tests) where this is allowed
let mut branch = Stack::new(
name,
source_refname,
upstream,
upstream_head,
tree,
head,
order,
selected_for_changes,
allow_rebasing,
);
if let Err(e) = branch.initialize(ctx) {
// TODO: When this is stable, make it error out
tracing::warn!("failed to initialize stack: {:?}", e);
}
branch
}
/// An initialized stack has at least one head (branch).
pub fn initialized(&self) -> bool {
!self.heads.is_empty()
}
/// 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`
pub fn initialize(&mut self, ctx: &CommandContext) -> Result<()> {
if self.initialized() {
return Ok(());
}
let commit = ctx.repository().find_commit(self.head())?;
let mut reference = PatchReference {
target: commit.into(),
name: if let Some(refname) = self.upstream.as_ref() {
refname.branch().to_string()
} else {
let (author, _committer) = ctx.repository().signatures()?;
generate_branch_name(author)?
},
description: None,
};
let state = branch_state(ctx);
while reference_exists(ctx, &reference.name)?
|| patch_reference_exists(&state, &reference.name)?
|| remote_reference_exists(ctx, &state, &reference)?
{
// keep incrementing the suffix until the name is unique
let split = reference.name.split('-');
let left = split.clone().take(split.clone().count() - 1).join("-");
reference.name = split
.last()
.and_then(|last| last.parse::<u32>().ok())
.map(|last| format!("{}-{}", left, last + 1)) //take everything except last, and append last + 1
.unwrap_or_else(|| format!("{}-1", reference.name));
}
validate_name(&reference, ctx, &state, self.upstream.clone())?;
self.heads = vec![reference];
state.set_branch(self.clone())
}
/// 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.)
///
/// When creating heads, it is possible to have multiple heads that point to the same patch/commit.
/// If this is the case, the order can be disambiguated by specifying the `preceding_head`.
/// If there are multiple heads pointing to the same patch and `preceding_head` is not specified,
/// that means the new head will be first in order for that patch.
/// The argument `preceding_head` is only used if there are multiple heads that point to the same patch, otherwise it is ignored.
///
/// This operation mutates the gitbutler::Branch.heads list and updates the state in `virtual_branches.toml`
pub fn add_series(
&mut self,
ctx: &CommandContext,
new_head: PatchReference,
preceding_head_name: Option<String>,
) -> Result<()> {
if !self.initialized() {
return Err(anyhow!("Stack has not been initialized"));
}
let preceding_head = if let Some(preceding_head_name) = preceding_head_name {
let (_, preceding_head) = get_head(&self.heads, &preceding_head_name)
.context("The specified preceding_head could not be found")?;
Some(preceding_head)
} else {
None
};
let state = branch_state(ctx);
let patches = stack_patches(ctx, &state, self.head(), true)?;
validate_name(&new_head, ctx, &state, None)?;
validate_target(&new_head, ctx.repository(), self.head(), &state)?;
let updated_heads = add_head(self.heads.clone(), new_head, preceding_head, patches)?;
self.heads = updated_heads;
state.set_branch(self.clone())
}
/// A convenience method just like `add_series`, but adds a new branch on top of the stack.
pub fn add_series_top_of_stack(
&mut self,
ctx: &CommandContext,
name: String,
description: Option<String>,
) -> Result<()> {
if !self.initialized() {
return Err(anyhow!("Stack has not been initialized"));
}
let current_top_head = self.heads.last().ok_or(anyhow!(
"Stack is in an invalid state - heads list is empty"
))?;
let new_head = PatchReference {
target: current_top_head.target.clone(),
name,
description,
};
self.add_series(ctx, new_head, Some(current_top_head.name.clone()))
}
/// Removes a branch from the Stack.
/// The very last branch (reference) cannot be removed (A Stack must always contain 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 preceding it)
///
/// This operation mutates the gitbutler::Branch.heads list and updates the state in `virtual_branches.toml`
pub fn remove_series(&mut self, ctx: &CommandContext, branch_name: String) -> Result<()> {
if !self.initialized() {
return Err(anyhow!("Stack has not been initialized"));
}
(self.heads, _) = remove_head(self.heads.clone(), branch_name)?;
let state = branch_state(ctx);
state.set_branch(self.clone())
}
/// 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`
pub fn update_series(
&mut self,
ctx: &CommandContext,
branch_name: String,
update: &PatchReferenceUpdate,
) -> Result<()> {
if !self.initialized() {
return Err(anyhow!("Stack has not been initialized"));
}
if update == &PatchReferenceUpdate::default() {
return Ok(()); // noop
}
let state = branch_state(ctx);
let patches = stack_patches(ctx, &state, self.head(), true)?;
let mut updated_heads = self.heads.clone();
// Handle target updates
if let Some(target_update) = &update.target_update {
let mut new_head = updated_heads
.clone()
.into_iter()
.find(|h| h.name == branch_name)
.ok_or_else(|| anyhow!("Series with name {} not found", branch_name))?;
new_head.target = target_update.target.clone();
validate_target(&new_head, ctx.repository(), self.head(), &state)?;
let preceding_head = update
.target_update
.clone()
.and_then(|update| update.preceding_head);
// drop the old head and add the new one
let (idx, _) = get_head(&updated_heads, &branch_name)?;
updated_heads.remove(idx);
if patches.last() != updated_heads.last().map(|h| &h.target) {
bail!("This update would cause orphaned patches, which is disallowed");
}
updated_heads = add_head(
updated_heads,
new_head.clone(),
preceding_head,
patches.clone(),
)?;
}
// Handle name updates
if let Some(name) = update.name.clone() {
let head = updated_heads
.iter_mut()
.find(|h: &&mut PatchReference| h.name == branch_name);
if let Some(head) = head {
head.name = name;
validate_name(head, ctx, &state, self.upstream.clone())?;
}
}
// Handle description updates
if let Some(description) = update.description.clone() {
let head = updated_heads.iter_mut().find(|h| h.name == branch_name);
if let Some(head) = head {
head.description = description;
}
}
self.heads = updated_heads;
state.set_branch(self.clone())
}
/// Updates the most recent series of the stack to point to a new patch (commit or change ID).
/// This will set the
/// - `head` of the stack to the new commit
/// - the target of the most recent series to the new commit
/// - the timestamp of the stack to the current time
/// - the tree of the stack to the new tree (if provided)
pub fn set_stack_head(
&mut self,
ctx: &CommandContext,
commit_id: git2::Oid,
tree: Option<git2::Oid>,
) -> Result<()> {
if !self.initialized() {
return Err(anyhow!("Stack has not been initialized"));
}
self.updated_timestamp_ms = gitbutler_time::time::now_ms();
#[allow(deprecated)] // this is the only place where this is allowed
self.set_head(commit_id);
if let Some(tree) = tree {
self.tree = tree;
}
let commit = ctx.repository().find_commit(commit_id)?;
// let patch: CommitOrChangeId = commit.into();
let state = branch_state(ctx);
let stack_head = self.head();
let head = self
.heads
.last_mut()
.ok_or_else(|| anyhow!("Invalid state: no heads found"))?;
head.target = commit.into();
validate_target(head, ctx.repository(), stack_head, &state)?;
state.set_branch(self.clone())
}
/// Prepares push details according to the series to be pushed (picking out the correct sha and remote refname)
/// This operation will error out if the target has no push remote configured.
pub fn push_details(&self, ctx: &CommandContext, branch_name: String) -> Result<PushDetails> {
if !self.initialized() {
return Err(anyhow!("Stack has not been initialized"));
}
let (_, reference) = get_head(&self.heads, &branch_name)?;
let default_target = branch_state(ctx).get_default_target()?;
let merge_base = ctx
.repository()
.merge_base(self.head(), default_target.sha)?;
let commit = commit_by_oid_or_change_id(
&reference.target,
ctx.repository(),
self.head(),
merge_base,
)?
.head;
let remote_name = branch_state(ctx).get_default_target()?.push_remote_name();
let upstream_refname =
RemoteRefname::from_str(&reference.remote_reference(remote_name.as_str())?)
.context("Failed to parse the remote reference for branch")?;
Ok(PushDetails {
head: commit.id(),
remote_refname: upstream_refname,
})
}
/// Returns a list of all branches/series in the stack.
/// This operation will compute the current list of local and remote commits that belong to each series.
/// The first entry is the newest in the Stack (i.e. the top of the stack).
pub fn list_series(&self, ctx: &CommandContext) -> Result<Vec<Series>> {
if !self.initialized() {
return Err(anyhow!("Stack has not been initialized"));
}
let state = branch_state(ctx);
let mut all_series: Vec<Series> = vec![];
let repo = ctx.repository();
let default_target = state.get_default_target()?;
let merge_base = repo.merge_base(self.head(), default_target.sha)?;
let mut previous_head = repo.merge_base(self.head(), default_target.sha)?;
for head in self.heads.clone() {
let head_commit =
commit_by_oid_or_change_id(&head.target, repo, self.head(), merge_base)?
.head
.id();
let mut local_patches = vec![];
for commit in repo
.log(head_commit, LogUntil::Commit(previous_head), false)?
.iter()
.rev()
{
let id: CommitOrChangeId = commit.clone().into();
if local_patches.contains(&id) {
// Duplication - use the commit id instead
local_patches.push(CommitOrChangeId::CommitId(commit.id().to_string()));
} else {
local_patches.push(id);
}
}
dbg!(&local_patches);
let mut remote_patches: Vec<CommitOrChangeId> = vec![];
let mut remote_commit_ids_by_change_id: HashMap<String, git2::Oid> = HashMap::new();
let remote_name = default_target.push_remote_name();
if head.pushed(&remote_name, ctx).unwrap_or_default() {
let head_commit = repo
.find_reference(&head.remote_reference(&remote_name)?)?
.peel_to_commit()?;
let merge_base = repo.merge_base(head_commit.id(), default_target.sha)?;
repo.log(head_commit.id(), LogUntil::Commit(merge_base), false)?
.into_iter()
.rev()
.for_each(|c| {
if let Some(change_id) = c.change_id() {
remote_commit_ids_by_change_id.insert(change_id.to_string(), c.id());
}
let commit_or_change_id: CommitOrChangeId = c.into();
remote_patches.push(commit_or_change_id);
});
}
// compute the commits that are only in the upstream
let local_patches_including_merge = repo
.log(head_commit, LogUntil::Commit(merge_base), true)?
.into_iter()
.rev() // oldest commit first
.map(|c| c.into())
.collect_vec();
let mut upstream_only = vec![];
for patch in remote_patches.iter() {
if !local_patches_including_merge.contains(patch) {
upstream_only.push(patch.clone());
}
}
all_series.push(Series {
head: head.clone(),
local_commits: local_patches,
remote_commits: remote_patches,
upstream_only_commits: upstream_only,
remote_commit_ids_by_change_id,
});
previous_head = head_commit;
}
Ok(all_series)
}
/// Updates all heads in the stack that point to the `from` commit to point to the `to` commit.
/// If there is nothing pointing to the `from` commit, this operation is a no-op.
/// If the `from` and `to` commits have the same change_id, this operation is also a no-op.
///
/// In the case that the `from` commit is the head of the stack, this operation delegates to `set_stack_head`.
///
/// Every time a commit/patch is moved / removed / updated, this method needs to be invoked to maintain the integrity of the stack.
/// Typically, in this case the `to` Commit would be `from`'s parent.
///
/// The `to` commit must be between the Stack head, and it's merge base otherwise this operation will error out.
pub fn replace_head(
&mut self,
ctx: &CommandContext,
from: &Commit<'_>,
to: &Commit<'_>,
) -> Result<()> {
if !self.initialized() {
return Err(anyhow!("Stack has not been initialized"));
}
// find all heads matching the 'from' target (there can be multiple heads pointing to the same commit)
let matching_heads = self
.heads
.iter()
.filter(|h| match from.change_id() {
Some(change_id) => h.target == CommitOrChangeId::ChangeId(change_id.clone()),
None => h.target == CommitOrChangeId::CommitId(from.id().to_string()),
})
.cloned()
.collect_vec();
if from.change_id() == to.change_id() {
// there is nothing to do
return Ok(());
}
let state = branch_state(ctx);
let mut updated_heads: Vec<PatchReference> = vec![];
for head in matching_heads {
if self.heads.last().cloned() == Some(head.clone()) {
// the head is the stack head - update it accordingly
self.set_stack_head(ctx, to.id(), None)?;
} else {
// new head target from the 'to' commit
let mut new_head = head.clone();
new_head.target = to.clone().into();
// validate the updated head
validate_target(&new_head, ctx.repository(), self.head(), &state)?;
// add it to the list of updated heads
updated_heads.push(new_head);
}
}
if !updated_heads.is_empty() {
for updated_head in updated_heads {
if let Some(head) = self.heads.iter_mut().find(|h| h.name == updated_head.name) {
// find set the corresponding head in the mutable self
*head = updated_head;
}
}
self.updated_timestamp_ms = gitbutler_time::time::now_ms();
// update the persistent state
state.set_branch(self.clone())?;
}
Ok(())
}
pub fn set_legacy_compatible_stack_reference(&mut self, ctx: &CommandContext) -> Result<()> {
// self.upstream is only set if this is a branch that was created & manipulated by the legacy flow
let legacy_refname = match self.upstream.clone().map(|r| r.branch().to_owned()) {
Some(legacy_refname) => legacy_refname,
None => return Ok(()), // noop
};
// update the reference only if there is exactly one series in the stack
if self.heads.len() != 1 {
return Ok(()); // noop
}
let head = match self.heads.first() {
Some(head) => head,
None => return Ok(()), // noop
};
if legacy_refname == head.name {
return Ok(()); // noop
}
let default_target = branch_state(ctx).get_default_target()?;
let update = PatchReferenceUpdate {
name: Some(legacy_refname),
..Default::default()
};
// modify the stack reference only if it has not been pushed yet
if !head
.pushed(&default_target.push_remote_name(), ctx)
.unwrap_or_default()
{
// set the stack reference to the legacy refname
self.update_series(ctx, head.name.clone(), &update)?;
}
Ok(())
}
}
/// Request to update a PatchReference.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
pub struct PatchReferenceUpdate {
pub target_update: Option<TargetUpdate>,
pub name: Option<String>,
/// If present, this sets the value of the description field.
/// It is possible to set this to Some(None) which will remove an existing description.
pub description: Option<Option<String>>,
}
/// Request to update the target of a PatchReference.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct TargetUpdate {
/// The new patch (commit or change ID) that the reference should point to.
pub target: CommitOrChangeId,
/// If there are multiple heads that point to the same patch, the order can be disambiguated by specifying the `preceding_head`.
/// Leaving this field empty will make the new head first in relation to other references pointing to this commit.
pub preceding_head: Option<PatchReference>,
}
/// Push details to be supplied to `RepoActionsExt`'s `push` method.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct PushDetails {
/// The commit that is being pushed.
pub head: git2::Oid,
/// A remote refname to push to.
pub remote_refname: RemoteRefname,
}
impl TryFrom<&Stack> for VirtualRefname {
@ -146,3 +675,274 @@ impl TryFrom<&Stack> for VirtualRefname {
})
}
}
/// Validates that the commit in the reference target
/// - exists
/// - is between the stack (formerly vbranch) head (inclusive) and base (inclusive)
///
/// If the patch reference is a commit ID, it must be the case that the commit has no change ID associated with it.
/// In other words, change IDs are enforced to be preferred over commit IDs when available.
fn validate_target(
reference: &PatchReference,
repo: &git2::Repository,
stack_head: git2::Oid,
state: &VirtualBranchesHandle,
) -> Result<()> {
let default_target = state.get_default_target()?;
let merge_base = repo.merge_base(stack_head, default_target.sha)?;
let commit = commit_by_oid_or_change_id(&reference.target, repo, stack_head, merge_base)?.head;
let merge_base = repo.merge_base(stack_head, default_target.sha)?;
let mut stack_commits = repo
.log(stack_head, LogUntil::Commit(merge_base), false)?
.iter()
.map(|c| c.id())
.collect_vec();
stack_commits.insert(0, merge_base);
if !stack_commits.contains(&commit.id()) {
return Err(anyhow!(
"The commit {} is not between the stack head and the stack base",
commit.id()
));
}
// Enforce that change ids are used when available
if let CommitOrChangeId::CommitId(_) = reference.target {
if commit.change_id().is_some() {
return Err(anyhow!(
"The commit {} has a change id associated with it. Use the change id instead",
commit.id()
));
}
}
Ok(())
}
/// Returns the list of patches between the stack head and the merge base.
/// The most recent patch is at the top of the 'stack' (i.e. the last element in the vector)
fn stack_patches(
ctx: &CommandContext,
state: &VirtualBranchesHandle,
stack_head: git2::Oid,
include_merge_base: bool,
) -> Result<Vec<CommitOrChangeId>> {
let default_target = state.get_default_target()?;
let merge_base = ctx
.repository()
.merge_base(stack_head, default_target.sha)?;
let mut patches = ctx
.repository()
.log(stack_head, LogUntil::Commit(merge_base), false)?
.into_iter()
.map(|c| c.into())
.collect_vec();
if include_merge_base {
patches.push(CommitOrChangeId::CommitId(merge_base.to_string()));
}
patches.reverse();
Ok(patches)
}
/// 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,
legacy_branch_ref: Option<RemoteRefname>,
) -> Result<()> {
let legacy_branch_ref = legacy_branch_ref.map(|r| r.branch().to_string());
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)? {
// Allow the reference overlap if it is the same as the legacy branch ref
if legacy_branch_ref != Some(reference.name.clone()) {
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 reference_exists(
ctx,
&reference.remote_reference(&default_target.push_remote_name())?,
)? {
// Allow the reference overlap if it is the same as the legacy branch ref
if legacy_branch_ref != Some(reference.name.clone()) {
return Err(anyhow!(
"A git reference with the name {} exists",
&reference.name
));
}
}
// assert that there are no existing patch references with this name
if patch_reference_exists(state, &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.
// NB: There can be multiple commits with the same change id on the same branch id.
// This is an error condition but we must handle it.
// If there are multiple commits, they are ordered newest to oldest.
fn commit_by_branch_id_and_change_id<'a>(
repo: &'a git2::Repository,
stack_head: git2::Oid, // branch.head
merge_base: git2::Oid,
change_id: &str,
) -> Result<CommitsForId<'a>> {
let commits = if stack_head == merge_base {
vec![repo.find_commit(stack_head)?]
} else {
repo.log(stack_head, LogUntil::Commit(merge_base), false)?
};
let commits = commits
.into_iter()
.filter(|c| c.change_id().as_deref() == Some(change_id))
.collect_vec();
if let Some(head) = commits.first() {
let commits_for_id = CommitsForId {
head: head.clone(),
tail: commits.iter().skip(1).cloned().collect_vec(),
};
Ok(commits_for_id)
} else {
Err(anyhow!("No commit with change id {} found", change_id))
}
}
fn branch_state(ctx: &CommandContext) -> VirtualBranchesHandle {
VirtualBranchesHandle::new(ctx.project().gb_dir())
}
// NB: There can be multiple commits with the same change id on the same branch id.
// This is an error condition but we must handle it.
// If there are multiple commits, they are ordered newest to oldest.
pub fn commit_by_oid_or_change_id<'a>(
reference_target: &'a CommitOrChangeId,
repo: &'a git2::Repository,
stack_head: git2::Oid,
merge_base: git2::Oid,
) -> Result<CommitsForId<'a>> {
Ok(match reference_target {
CommitOrChangeId::CommitId(commit_id) => CommitsForId {
head: repo.find_commit(commit_id.parse()?)?,
tail: vec![],
},
CommitOrChangeId::ChangeId(change_id) => {
commit_by_branch_id_and_change_id(repo, stack_head, merge_base, change_id)?
}
})
}
/// Returns the commits associated with a id.
/// In most cases this is exactly one commit. Hoever there is an error state where it is possible to have
/// multiple commits with the same change id on the same stack.
#[derive(Debug, Clone)]
pub struct CommitsForId<'a> {
/// The newest commit with the change id.
pub head: Commit<'a>,
/// There may be multiple commits with the same change id - if so they are ordered newest to oldest.
/// The tails does not include the head.
pub tail: Vec<Commit<'a>>,
}
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())
}
fn patch_reference_exists(state: &VirtualBranchesHandle, name: &str) -> Result<bool> {
Ok(state
.list_all_branches()?
.iter()
.flat_map(|b| b.heads.iter())
.any(|r| r.name == name))
}
fn remote_reference_exists(
ctx: &CommandContext,
state: &VirtualBranchesHandle,
reference: &PatchReference,
) -> Result<bool> {
Ok(reference
.remote_reference(state.get_default_target()?.push_remote_name().as_str())
.and_then(|reference| reference_exists(ctx, &reference))
.ok()
.unwrap_or(false))
}
fn generate_branch_name(author: git2::Signature) -> Result<String> {
let mut initials = decompose(author.name().unwrap_or_default().into())
.chars()
.filter(|c| c.is_ascii_alphabetic() || c.is_whitespace())
.collect::<String>()
.split_whitespace()
.map(|word| word.chars().next().unwrap_or_default())
.collect::<String>()
.to_lowercase();
if !initials.is_empty() {
initials.push('-');
}
let branch_name = format!("{}{}-1", initials, "branch");
normalize_branch_name(&branch_name)
}
#[cfg(test)]
mod test {
use super::*;
use git2::{Signature, Time};
#[test]
fn gen_name() -> Result<()> {
let author = Signature::new("Foo Bar", "fb@example.com", &Time::new(0, 0))?;
assert_eq!(generate_branch_name(author)?, "fb-branch-1");
Ok(())
}
#[test]
fn gen_name_with_some_umlauts_and_accents() -> Result<()> {
// handles accents
let author = Signature::new("Äx Öx Åx Üx Éx Áx", "fb@example.com", &Time::new(0, 0))?;
assert_eq!(generate_branch_name(author)?, "aoauea-branch-1");
// bails on norwegian characters
let author = Signature::new("Æx Øx", "fb@example.com", &Time::new(0, 0))?;
assert_eq!(generate_branch_name(author)?, "xx-branch-1");
Ok(())
}
#[test]
fn gen_name_emojis() -> Result<()> {
// only emoji gets ignored
let author = Signature::new("🍑", "fb@example.com", &Time::new(0, 0))?;
assert_eq!(generate_branch_name(author)?, "branch-1");
// if there is a latin character, it gets included
let author = Signature::new("🍑x", "fb@example.com", &Time::new(0, 0))?;
assert_eq!(generate_branch_name(author)?, "x-branch-1");
let author = Signature::new("🍑 Foo", "fb@example.com", &Time::new(0, 0))?;
assert_eq!(generate_branch_name(author)?, "f-branch-1");
Ok(())
}
#[test]
fn gen_name_chinese_character() -> Result<()> {
// igrnore all
let author = Signature::new("吉特·巴特勒", "fb@example.com", &Time::new(0, 0))?;
assert_eq!(generate_branch_name(author)?, "branch-1");
Ok(())
}
}

View File

@ -1,879 +0,0 @@
use std::str::FromStr;
use crate::Stack;
use crate::VirtualBranchesHandle;
use anyhow::anyhow;
use anyhow::bail;
use anyhow::Context;
use anyhow::Result;
use git2::Commit;
use gitbutler_command_context::CommandContext;
use gitbutler_commit::commit_ext::CommitExt;
use gitbutler_patch_reference::{CommitOrChangeId, PatchReference};
use gitbutler_reference::normalize_branch_name;
use gitbutler_reference::Refname;
use gitbutler_reference::RemoteRefname;
use gitbutler_repo::LogUntil;
use gitbutler_repo::RepositoryExt;
use gix::validate::reference::name_partial;
use gix_utils::str::decompose;
use itertools::Itertools;
use serde::{Deserialize, Serialize};
use crate::heads::add_head;
use crate::heads::get_head;
use crate::heads::remove_head;
use crate::series::Series;
use std::collections::HashMap;
/// A (series) Stack represents multiple "branches" that are dependent on each other in series.
///
/// An initialized Stack must:
/// - have at least one head (branch)
/// - include only references that are part of the stack
/// - always have its commits under a reference i.e. no orphaned commits
pub trait StackExt {
// TODO: When this is stable, make it error out on initialization failure
/// Constructs and initializes a new Stack.
/// If initialization fails, a warning is logged and the stack is returned as is.
#[allow(clippy::too_many_arguments)]
fn create(
ctx: &CommandContext,
name: String,
source_refname: Option<Refname>,
upstream: Option<RemoteRefname>,
upstream_head: Option<git2::Oid>,
tree: git2::Oid,
head: git2::Oid,
order: usize,
selected_for_changes: Option<i64>,
allow_rebasing: bool,
) -> Self;
/// 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 initialize(&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.)
///
/// When creating heads, it is possible to have multiple heads that point to the same patch/commit.
/// If this is the case, the order can be disambiguated by specifying the `preceding_head`.
/// If there are multiple heads pointing to the same patch and `preceding_head` is not specified,
/// that means the new head will be first in order for that patch.
/// The argument `preceding_head` is only used if there are multiple heads that point to the same patch, otherwise it is ignored.
///
/// This operation mutates the gitbutler::Branch.heads list and updates the state in `virtual_branches.toml`
fn add_series(
&mut self,
ctx: &CommandContext,
head: PatchReference,
preceding_head_name: Option<String>,
) -> Result<()>;
/// A convenience method just like `add_series`, but adds a new branch on top of the stack.
fn add_series_top_of_stack(
&mut self,
ctx: &CommandContext,
name: String,
description: Option<String>,
) -> Result<()>;
/// Removes a branch from the Stack.
/// The very last branch (reference) cannot be removed (A Stack must always contain 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 preceding it)
///
/// This operation mutates the gitbutler::Branch.heads list and updates the state in `virtual_branches.toml`
fn remove_series(&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_series(
&mut self,
ctx: &CommandContext,
branch_name: String,
update: &PatchReferenceUpdate,
) -> Result<()>;
/// Updates the most recent series of the stack to point to a new patch (commit or change ID).
/// This will set the
/// - `head` of the stack to the new commit
/// - the target of the most recent series to the new commit
/// - the timestamp of the stack to the current time
/// - the tree of the stack to the new tree (if provided)
fn set_stack_head(
&mut self,
ctx: &CommandContext,
commit_id: git2::Oid,
tree: Option<git2::Oid>,
) -> Result<()>;
/// Prepares push details according to the series to be pushed (picking out the correct sha and remote refname)
/// This operation will error out if the target has no push remote configured.
fn push_details(&self, ctx: &CommandContext, branch_name: String) -> Result<PushDetails>;
/// Returns a list of all branches/series in the stack.
/// This operation will compute the current list of local and remote commits that belong to each series.
/// The first entry is the newest in the Stack (i.e. the top of the stack).
fn list_series(&self, ctx: &CommandContext) -> Result<Vec<Series>>;
/// Updates all heads in the stack that point to the `from` commit to point to the `to` commit.
/// If there is nothing pointing to the `from` commit, this operation is a no-op.
/// If the `from` and `to` commits have the same change_id, this operation is also a no-op.
///
/// In the case that the `from` commit is the head of the stack, this operation delegates to `set_stack_head`.
///
/// Every time a commit/patch is moved / removed / updated, this method needs to be invoked to maintain the integrity of the stack.
/// Typically, in this case the `to` Commit would be `from`'s parent.
///
/// The `to` commit must be between the Stack head, and it's merge base otherwise this operation will error out.
fn replace_head(
&mut self,
ctx: &CommandContext,
from: &Commit<'_>,
to: &Commit<'_>,
) -> Result<()>;
fn set_legacy_compatible_stack_reference(&mut self, ctx: &CommandContext) -> Result<()>;
}
/// Request to update a PatchReference.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
pub struct PatchReferenceUpdate {
pub target_update: Option<TargetUpdate>,
pub name: Option<String>,
/// If present, this sets the value of the description field.
/// It is possible to set this to Some(None) which will remove an existing description.
pub description: Option<Option<String>>,
}
/// Request to update the target of a PatchReference.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct TargetUpdate {
/// The new patch (commit or change ID) that the reference should point to.
pub target: CommitOrChangeId,
/// If there are multiple heads that point to the same patch, the order can be disambiguated by specifying the `preceding_head`.
/// Leaving this field empty will make the new head first in relation to other references pointing to this commit.
pub preceding_head: Option<PatchReference>,
}
/// Push details to be supplied to `RepoActionsExt`'s `push` method.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct PushDetails {
/// The commit that is being pushed.
pub head: git2::Oid,
/// A remote refname to push to.
pub remote_refname: RemoteRefname,
}
/// 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 the trait implementation.
///
/// The heads must always be sorted in accordance with the order of the patches in the stack.
/// The first patches are in the beginning of the list and the most recent patches are at the end of the list (top of the stack)
/// Similarly, heads that point to earlier commits are first in the order, and the last head always points to the most recent patch.
/// If there are multiple heads that point to the same patch, the `add` and `update` operations can specify the intended order.
impl StackExt for Stack {
#[allow(clippy::too_many_arguments)]
fn create(
ctx: &CommandContext,
name: String,
source_refname: Option<Refname>,
upstream: Option<RemoteRefname>,
upstream_head: Option<git2::Oid>,
tree: git2::Oid,
head: git2::Oid,
order: usize,
selected_for_changes: Option<i64>,
allow_rebasing: bool,
) -> Self {
#[allow(deprecated)]
// this should be the only place (other than tests) where this is allowed
let mut branch = Stack::new(
name,
source_refname,
upstream,
upstream_head,
tree,
head,
order,
selected_for_changes,
allow_rebasing,
);
if let Err(e) = branch.initialize(ctx) {
// TODO: When this is stable, make it error out
tracing::warn!("failed to initialize stack: {:?}", e);
}
branch
}
fn initialized(&self) -> bool {
!self.heads.is_empty()
}
fn initialize(&mut self, ctx: &CommandContext) -> Result<()> {
if self.initialized() {
return Ok(());
}
let commit = ctx.repository().find_commit(self.head())?;
let mut reference = PatchReference {
target: commit.into(),
name: if let Some(refname) = self.upstream.as_ref() {
refname.branch().to_string()
} else {
let (author, _committer) = ctx.repository().signatures()?;
generate_branch_name(author)?
},
description: None,
};
let state = branch_state(ctx);
while reference_exists(ctx, &reference.name)?
|| patch_reference_exists(&state, &reference.name)?
|| remote_reference_exists(ctx, &state, &reference)?
{
// keep incrementing the suffix until the name is unique
let split = reference.name.split('-');
let left = split.clone().take(split.clone().count() - 1).join("-");
reference.name = split
.last()
.and_then(|last| last.parse::<u32>().ok())
.map(|last| format!("{}-{}", left, last + 1)) //take everything except last, and append last + 1
.unwrap_or_else(|| format!("{}-1", reference.name));
}
validate_name(&reference, ctx, &state, self.upstream.clone())?;
self.heads = vec![reference];
state.set_branch(self.clone())
}
fn add_series(
&mut self,
ctx: &CommandContext,
new_head: PatchReference,
preceding_head_name: Option<String>,
) -> Result<()> {
if !self.initialized() {
return Err(anyhow!("Stack has not been initialized"));
}
let preceding_head = if let Some(preceding_head_name) = preceding_head_name {
let (_, preceding_head) = get_head(&self.heads, &preceding_head_name)
.context("The specified preceding_head could not be found")?;
Some(preceding_head)
} else {
None
};
let state = branch_state(ctx);
let patches = stack_patches(ctx, &state, self.head(), true)?;
validate_name(&new_head, ctx, &state, None)?;
validate_target(&new_head, ctx.repository(), self.head(), &state)?;
let updated_heads = add_head(self.heads.clone(), new_head, preceding_head, patches)?;
self.heads = updated_heads;
state.set_branch(self.clone())
}
fn add_series_top_of_stack(
&mut self,
ctx: &CommandContext,
name: String,
description: Option<String>,
) -> Result<()> {
if !self.initialized() {
return Err(anyhow!("Stack has not been initialized"));
}
let current_top_head = self.heads.last().ok_or(anyhow!(
"Stack is in an invalid state - heads list is empty"
))?;
let new_head = PatchReference {
target: current_top_head.target.clone(),
name,
description,
};
self.add_series(ctx, new_head, Some(current_top_head.name.clone()))
}
fn remove_series(&mut self, ctx: &CommandContext, branch_name: String) -> Result<()> {
if !self.initialized() {
return Err(anyhow!("Stack has not been initialized"));
}
(self.heads, _) = remove_head(self.heads.clone(), branch_name)?;
let state = branch_state(ctx);
state.set_branch(self.clone())
}
fn update_series(
&mut self,
ctx: &CommandContext,
branch_name: String,
update: &PatchReferenceUpdate,
) -> Result<()> {
if !self.initialized() {
return Err(anyhow!("Stack has not been initialized"));
}
if update == &PatchReferenceUpdate::default() {
return Ok(()); // noop
}
let state = branch_state(ctx);
let patches = stack_patches(ctx, &state, self.head(), true)?;
let mut updated_heads = self.heads.clone();
// Handle target updates
if let Some(target_update) = &update.target_update {
let mut new_head = updated_heads
.clone()
.into_iter()
.find(|h| h.name == branch_name)
.ok_or_else(|| anyhow!("Series with name {} not found", branch_name))?;
new_head.target = target_update.target.clone();
validate_target(&new_head, ctx.repository(), self.head(), &state)?;
let preceding_head = update
.target_update
.clone()
.and_then(|update| update.preceding_head);
// drop the old head and add the new one
let (idx, _) = get_head(&updated_heads, &branch_name)?;
updated_heads.remove(idx);
if patches.last() != updated_heads.last().map(|h| &h.target) {
bail!("This update would cause orphaned patches, which is disallowed");
}
updated_heads = add_head(
updated_heads,
new_head.clone(),
preceding_head,
patches.clone(),
)?;
}
// Handle name updates
if let Some(name) = update.name.clone() {
let head = updated_heads
.iter_mut()
.find(|h: &&mut PatchReference| h.name == branch_name);
if let Some(head) = head {
head.name = name;
validate_name(head, ctx, &state, self.upstream.clone())?;
}
}
// Handle description updates
if let Some(description) = update.description.clone() {
let head = updated_heads.iter_mut().find(|h| h.name == branch_name);
if let Some(head) = head {
head.description = description;
}
}
self.heads = updated_heads;
state.set_branch(self.clone())
}
fn set_stack_head(
&mut self,
ctx: &CommandContext,
commit_id: git2::Oid,
tree: Option<git2::Oid>,
) -> Result<()> {
if !self.initialized() {
return Err(anyhow!("Stack has not been initialized"));
}
self.updated_timestamp_ms = gitbutler_time::time::now_ms();
#[allow(deprecated)] // this is the only place where this is allowed
self.set_head(commit_id);
if let Some(tree) = tree {
self.tree = tree;
}
let commit = ctx.repository().find_commit(commit_id)?;
// let patch: CommitOrChangeId = commit.into();
let state = branch_state(ctx);
let stack_head = self.head();
let head = self
.heads
.last_mut()
.ok_or_else(|| anyhow!("Invalid state: no heads found"))?;
head.target = commit.into();
validate_target(head, ctx.repository(), stack_head, &state)?;
state.set_branch(self.clone())
}
fn push_details(&self, ctx: &CommandContext, branch_name: String) -> Result<PushDetails> {
if !self.initialized() {
return Err(anyhow!("Stack has not been initialized"));
}
let (_, reference) = get_head(&self.heads, &branch_name)?;
let default_target = branch_state(ctx).get_default_target()?;
let merge_base = ctx
.repository()
.merge_base(self.head(), default_target.sha)?;
let commit = commit_by_oid_or_change_id(
&reference.target,
ctx.repository(),
self.head(),
merge_base,
)?
.head;
let remote_name = branch_state(ctx).get_default_target()?.push_remote_name();
let upstream_refname =
RemoteRefname::from_str(&reference.remote_reference(remote_name.as_str())?)
.context("Failed to parse the remote reference for branch")?;
Ok(PushDetails {
head: commit.id(),
remote_refname: upstream_refname,
})
}
fn list_series(&self, ctx: &CommandContext) -> Result<Vec<Series>> {
if !self.initialized() {
return Err(anyhow!("Stack has not been initialized"));
}
let state = branch_state(ctx);
let mut all_series: Vec<Series> = vec![];
let repo = ctx.repository();
let default_target = state.get_default_target()?;
let merge_base = repo.merge_base(self.head(), default_target.sha)?;
let mut previous_head = repo.merge_base(self.head(), default_target.sha)?;
for head in self.heads.clone() {
let head_commit =
commit_by_oid_or_change_id(&head.target, repo, self.head(), merge_base)?
.head
.id();
let mut local_patches = vec![];
for commit in repo
.log(head_commit, LogUntil::Commit(previous_head), false)?
.iter()
.rev()
{
let id: CommitOrChangeId = commit.clone().into();
if local_patches.contains(&id) {
// Duplication - use the commit id instead
local_patches.push(CommitOrChangeId::CommitId(commit.id().to_string()));
} else {
local_patches.push(id);
}
}
dbg!(&local_patches);
let mut remote_patches: Vec<CommitOrChangeId> = vec![];
let mut remote_commit_ids_by_change_id: HashMap<String, git2::Oid> = HashMap::new();
let remote_name = default_target.push_remote_name();
if head.pushed(&remote_name, ctx).unwrap_or_default() {
let head_commit = repo
.find_reference(&head.remote_reference(&remote_name)?)?
.peel_to_commit()?;
let merge_base = repo.merge_base(head_commit.id(), default_target.sha)?;
repo.log(head_commit.id(), LogUntil::Commit(merge_base), false)?
.into_iter()
.rev()
.for_each(|c| {
if let Some(change_id) = c.change_id() {
remote_commit_ids_by_change_id.insert(change_id.to_string(), c.id());
}
let commit_or_change_id: CommitOrChangeId = c.into();
remote_patches.push(commit_or_change_id);
});
}
// compute the commits that are only in the upstream
let local_patches_including_merge = repo
.log(head_commit, LogUntil::Commit(merge_base), true)?
.into_iter()
.rev() // oldest commit first
.map(|c| c.into())
.collect_vec();
let mut upstream_only = vec![];
for patch in remote_patches.iter() {
if !local_patches_including_merge.contains(patch) {
upstream_only.push(patch.clone());
}
}
all_series.push(Series {
head: head.clone(),
local_commits: local_patches,
remote_commits: remote_patches,
upstream_only_commits: upstream_only,
remote_commit_ids_by_change_id,
});
previous_head = head_commit;
}
Ok(all_series)
}
fn replace_head(
&mut self,
ctx: &CommandContext,
from: &Commit<'_>,
to: &Commit<'_>,
) -> Result<()> {
if !self.initialized() {
return Err(anyhow!("Stack has not been initialized"));
}
// find all heads matching the 'from' target (there can be multiple heads pointing to the same commit)
let matching_heads = self
.heads
.iter()
.filter(|h| match from.change_id() {
Some(change_id) => h.target == CommitOrChangeId::ChangeId(change_id.clone()),
None => h.target == CommitOrChangeId::CommitId(from.id().to_string()),
})
.cloned()
.collect_vec();
if from.change_id() == to.change_id() {
// there is nothing to do
return Ok(());
}
let state = branch_state(ctx);
let mut updated_heads: Vec<PatchReference> = vec![];
for head in matching_heads {
if self.heads.last().cloned() == Some(head.clone()) {
// the head is the stack head - update it accordingly
self.set_stack_head(ctx, to.id(), None)?;
} else {
// new head target from the 'to' commit
let mut new_head = head.clone();
new_head.target = to.clone().into();
// validate the updated head
validate_target(&new_head, ctx.repository(), self.head(), &state)?;
// add it to the list of updated heads
updated_heads.push(new_head);
}
}
if !updated_heads.is_empty() {
for updated_head in updated_heads {
if let Some(head) = self.heads.iter_mut().find(|h| h.name == updated_head.name) {
// find set the corresponding head in the mutable self
*head = updated_head;
}
}
self.updated_timestamp_ms = gitbutler_time::time::now_ms();
// update the persistent state
state.set_branch(self.clone())?;
}
Ok(())
}
fn set_legacy_compatible_stack_reference(&mut self, ctx: &CommandContext) -> Result<()> {
// self.upstream is only set if this is a branch that was created & manipulated by the legacy flow
let legacy_refname = match self.upstream.clone().map(|r| r.branch().to_owned()) {
Some(legacy_refname) => legacy_refname,
None => return Ok(()), // noop
};
// update the reference only if there is exactly one series in the stack
if self.heads.len() != 1 {
return Ok(()); // noop
}
let head = match self.heads.first() {
Some(head) => head,
None => return Ok(()), // noop
};
if legacy_refname == head.name {
return Ok(()); // noop
}
let default_target = branch_state(ctx).get_default_target()?;
let update = PatchReferenceUpdate {
name: Some(legacy_refname),
..Default::default()
};
// modify the stack reference only if it has not been pushed yet
if !head
.pushed(&default_target.push_remote_name(), ctx)
.unwrap_or_default()
{
// set the stack reference to the legacy refname
self.update_series(ctx, head.name.clone(), &update)?;
}
Ok(())
}
}
/// Validates that the commit in the reference target
/// - exists
/// - is between the stack (formerly vbranch) head (inclusive) and base (inclusive)
///
/// If the patch reference is a commit ID, it must be the case that the commit has no change ID associated with it.
/// In other words, change IDs are enforced to be preferred over commit IDs when available.
fn validate_target(
reference: &PatchReference,
repo: &git2::Repository,
stack_head: git2::Oid,
state: &VirtualBranchesHandle,
) -> Result<()> {
let default_target = state.get_default_target()?;
let merge_base = repo.merge_base(stack_head, default_target.sha)?;
let commit = commit_by_oid_or_change_id(&reference.target, repo, stack_head, merge_base)?.head;
let merge_base = repo.merge_base(stack_head, default_target.sha)?;
let mut stack_commits = repo
.log(stack_head, LogUntil::Commit(merge_base), false)?
.iter()
.map(|c| c.id())
.collect_vec();
stack_commits.insert(0, merge_base);
if !stack_commits.contains(&commit.id()) {
return Err(anyhow!(
"The commit {} is not between the stack head and the stack base",
commit.id()
));
}
// Enforce that change ids are used when available
if let CommitOrChangeId::CommitId(_) = reference.target {
if commit.change_id().is_some() {
return Err(anyhow!(
"The commit {} has a change id associated with it. Use the change id instead",
commit.id()
));
}
}
Ok(())
}
/// Returns the list of patches between the stack head and the merge base.
/// The most recent patch is at the top of the 'stack' (i.e. the last element in the vector)
fn stack_patches(
ctx: &CommandContext,
state: &VirtualBranchesHandle,
stack_head: git2::Oid,
include_merge_base: bool,
) -> Result<Vec<CommitOrChangeId>> {
let default_target = state.get_default_target()?;
let merge_base = ctx
.repository()
.merge_base(stack_head, default_target.sha)?;
let mut patches = ctx
.repository()
.log(stack_head, LogUntil::Commit(merge_base), false)?
.into_iter()
.map(|c| c.into())
.collect_vec();
if include_merge_base {
patches.push(CommitOrChangeId::CommitId(merge_base.to_string()));
}
patches.reverse();
Ok(patches)
}
/// 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,
legacy_branch_ref: Option<RemoteRefname>,
) -> Result<()> {
let legacy_branch_ref = legacy_branch_ref.map(|r| r.branch().to_string());
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)? {
// Allow the reference overlap if it is the same as the legacy branch ref
if legacy_branch_ref != Some(reference.name.clone()) {
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 reference_exists(
ctx,
&reference.remote_reference(&default_target.push_remote_name())?,
)? {
// Allow the reference overlap if it is the same as the legacy branch ref
if legacy_branch_ref != Some(reference.name.clone()) {
return Err(anyhow!(
"A git reference with the name {} exists",
&reference.name
));
}
}
// assert that there are no existing patch references with this name
if patch_reference_exists(state, &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.
// NB: There can be multiple commits with the same change id on the same branch id.
// This is an error condition but we must handle it.
// If there are multiple commits, they are ordered newest to oldest.
fn commit_by_branch_id_and_change_id<'a>(
repo: &'a git2::Repository,
stack_head: git2::Oid, // branch.head
merge_base: git2::Oid,
change_id: &str,
) -> Result<CommitsForId<'a>> {
let commits = if stack_head == merge_base {
vec![repo.find_commit(stack_head)?]
} else {
repo.log(stack_head, LogUntil::Commit(merge_base), false)?
};
let commits = commits
.into_iter()
.filter(|c| c.change_id().as_deref() == Some(change_id))
.collect_vec();
if let Some(head) = commits.first() {
let commits_for_id = CommitsForId {
head: head.clone(),
tail: commits.iter().skip(1).cloned().collect_vec(),
};
Ok(commits_for_id)
} else {
Err(anyhow!("No commit with change id {} found", change_id))
}
}
fn branch_state(ctx: &CommandContext) -> VirtualBranchesHandle {
VirtualBranchesHandle::new(ctx.project().gb_dir())
}
// NB: There can be multiple commits with the same change id on the same branch id.
// This is an error condition but we must handle it.
// If there are multiple commits, they are ordered newest to oldest.
pub fn commit_by_oid_or_change_id<'a>(
reference_target: &'a CommitOrChangeId,
repo: &'a git2::Repository,
stack_head: git2::Oid,
merge_base: git2::Oid,
) -> Result<CommitsForId<'a>> {
Ok(match reference_target {
CommitOrChangeId::CommitId(commit_id) => CommitsForId {
head: repo.find_commit(commit_id.parse()?)?,
tail: vec![],
},
CommitOrChangeId::ChangeId(change_id) => {
commit_by_branch_id_and_change_id(repo, stack_head, merge_base, change_id)?
}
})
}
/// Returns the commits associated with a id.
/// In most cases this is exactly one commit. Hoever there is an error state where it is possible to have
/// multiple commits with the same change id on the same stack.
#[derive(Debug, Clone)]
pub struct CommitsForId<'a> {
/// The newest commit with the change id.
pub head: Commit<'a>,
/// There may be multiple commits with the same change id - if so they are ordered newest to oldest.
/// The tails does not include the head.
pub tail: Vec<Commit<'a>>,
}
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())
}
fn patch_reference_exists(state: &VirtualBranchesHandle, name: &str) -> Result<bool> {
Ok(state
.list_all_branches()?
.iter()
.flat_map(|b| b.heads.iter())
.any(|r| r.name == name))
}
fn remote_reference_exists(
ctx: &CommandContext,
state: &VirtualBranchesHandle,
reference: &PatchReference,
) -> Result<bool> {
Ok(reference
.remote_reference(state.get_default_target()?.push_remote_name().as_str())
.and_then(|reference| reference_exists(ctx, &reference))
.ok()
.unwrap_or(false))
}
fn generate_branch_name(author: git2::Signature) -> Result<String> {
let mut initials = decompose(author.name().unwrap_or_default().into())
.chars()
.filter(|c| c.is_ascii_alphabetic() || c.is_whitespace())
.collect::<String>()
.split_whitespace()
.map(|word| word.chars().next().unwrap_or_default())
.collect::<String>()
.to_lowercase();
if !initials.is_empty() {
initials.push('-');
}
let branch_name = format!("{}{}-1", initials, "branch");
normalize_branch_name(&branch_name)
}
#[cfg(test)]
mod test {
use super::*;
use git2::{Signature, Time};
#[test]
fn gen_name() -> Result<()> {
let author = Signature::new("Foo Bar", "fb@example.com", &Time::new(0, 0))?;
assert_eq!(generate_branch_name(author)?, "fb-branch-1");
Ok(())
}
#[test]
fn gen_name_with_some_umlauts_and_accents() -> Result<()> {
// handles accents
let author = Signature::new("Äx Öx Åx Üx Éx Áx", "fb@example.com", &Time::new(0, 0))?;
assert_eq!(generate_branch_name(author)?, "aoauea-branch-1");
// bails on norwegian characters
let author = Signature::new("Æx Øx", "fb@example.com", &Time::new(0, 0))?;
assert_eq!(generate_branch_name(author)?, "xx-branch-1");
Ok(())
}
#[test]
fn gen_name_emojis() -> Result<()> {
// only emoji gets ignored
let author = Signature::new("🍑", "fb@example.com", &Time::new(0, 0))?;
assert_eq!(generate_branch_name(author)?, "branch-1");
// if there is a latin character, it gets included
let author = Signature::new("🍑x", "fb@example.com", &Time::new(0, 0))?;
assert_eq!(generate_branch_name(author)?, "x-branch-1");
let author = Signature::new("🍑 Foo", "fb@example.com", &Time::new(0, 0))?;
assert_eq!(generate_branch_name(author)?, "f-branch-1");
Ok(())
}
#[test]
fn gen_name_chinese_character() -> Result<()> {
// igrnore all
let author = Signature::new("吉特·巴特勒", "fb@example.com", &Time::new(0, 0))?;
assert_eq!(generate_branch_name(author)?, "branch-1");
Ok(())
}
}

View File

@ -9,7 +9,7 @@ use gitbutler_reference::RemoteRefname;
use gitbutler_repo::{LogUntil, RepositoryExt as _};
use gitbutler_repo_actions::RepoActionsExt;
use gitbutler_stack::VirtualBranchesHandle;
use gitbutler_stack::{PatchReferenceUpdate, StackExt, TargetUpdate};
use gitbutler_stack::{PatchReferenceUpdate, TargetUpdate};
use itertools::Itertools;
use tempfile::TempDir;