mirror of
https://github.com/gitbutlerapp/gitbutler.git
synced 2024-11-22 02:34:33 +03:00
Merge pull request #5270 from gitbutlerapp/kv-branch-1
Remove stack extention trait - now it can be an impl block
This commit is contained in:
commit
521d3994a4
@ -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;
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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::{
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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::{
|
||||
|
@ -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};
|
||||
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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 _;
|
||||
|
@ -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};
|
||||
|
||||
|
@ -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,
|
||||
};
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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};
|
||||
|
@ -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(())
|
||||
}
|
||||
}
|
||||
|
@ -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(())
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user