From 068833059d6dbaad32b6aebe4d6863ad55333a38 Mon Sep 17 00:00:00 2001 From: Kiril Videlov Date: Tue, 22 Oct 2024 16:08:59 +0200 Subject: [PATCH] Remove stack extention trait - now it can be an impl block --- crates/gitbutler-branch-actions/src/base.rs | 1 - .../src/branch_manager/branch_creation.rs | 1 - .../src/branch_upstream_integration.rs | 2 +- .../src/integration.rs | 1 - .../src/move_commits.rs | 1 - .../src/reorder_commits.rs | 1 - crates/gitbutler-branch-actions/src/stack.rs | 2 +- crates/gitbutler-branch-actions/src/status.rs | 1 - .../src/undo_commit.rs | 1 - .../src/upstream_integration.rs | 1 - .../gitbutler-branch-actions/src/virtual.rs | 1 - .../tests/extra/mod.rs | 1 - crates/gitbutler-edit-mode/src/lib.rs | 1 - crates/gitbutler-stack/src/lib.rs | 5 +- crates/gitbutler-stack/src/stack.rs | 814 +++++++++++++++- crates/gitbutler-stack/src/stack_ext.rs | 879 ------------------ crates/gitbutler-stack/tests/mod.rs | 2 +- 17 files changed, 811 insertions(+), 904 deletions(-) delete mode 100644 crates/gitbutler-stack/src/stack_ext.rs diff --git a/crates/gitbutler-branch-actions/src/base.rs b/crates/gitbutler-branch-actions/src/base.rs index b04cc5034..671993f08 100644 --- a/crates/gitbutler-branch-actions/src/base.rs +++ b/crates/gitbutler-branch-actions/src/base.rs @@ -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; diff --git a/crates/gitbutler-branch-actions/src/branch_manager/branch_creation.rs b/crates/gitbutler-branch-actions/src/branch_manager/branch_creation.rs index 8581cb78c..ec2c3bc84 100644 --- a/crates/gitbutler-branch-actions/src/branch_manager/branch_creation.rs +++ b/crates/gitbutler-branch-actions/src/branch_manager/branch_creation.rs @@ -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; diff --git a/crates/gitbutler-branch-actions/src/branch_upstream_integration.rs b/crates/gitbutler-branch-actions/src/branch_upstream_integration.rs index 6e55f0ce6..dc8cd2c0f 100644 --- a/crates/gitbutler-branch-actions/src/branch_upstream_integration.rs +++ b/crates/gitbutler-branch-actions/src/branch_upstream_integration.rs @@ -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::{ diff --git a/crates/gitbutler-branch-actions/src/integration.rs b/crates/gitbutler-branch-actions/src/integration.rs index adaea62d8..1c99ec24c 100644 --- a/crates/gitbutler-branch-actions/src/integration.rs +++ b/crates/gitbutler-branch-actions/src/integration.rs @@ -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; diff --git a/crates/gitbutler-branch-actions/src/move_commits.rs b/crates/gitbutler-branch-actions/src/move_commits.rs index 3905c8883..33d17a431 100644 --- a/crates/gitbutler-branch-actions/src/move_commits.rs +++ b/crates/gitbutler-branch-actions/src/move_commits.rs @@ -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; diff --git a/crates/gitbutler-branch-actions/src/reorder_commits.rs b/crates/gitbutler-branch-actions/src/reorder_commits.rs index 4f2d9f7af..1eaafc294 100644 --- a/crates/gitbutler-branch-actions/src/reorder_commits.rs +++ b/crates/gitbutler-branch-actions/src/reorder_commits.rs @@ -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::{ diff --git a/crates/gitbutler-branch-actions/src/stack.rs b/crates/gitbutler-branch-actions/src/stack.rs index 0bb73fb5f..3c5b93247 100644 --- a/crates/gitbutler-branch-actions/src/stack.rs +++ b/crates/gitbutler-branch-actions/src/stack.rs @@ -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}; diff --git a/crates/gitbutler-branch-actions/src/status.rs b/crates/gitbutler-branch-actions/src/status.rs index fdd85a76b..8b6f6f4c1 100644 --- a/crates/gitbutler-branch-actions/src/status.rs +++ b/crates/gitbutler-branch-actions/src/status.rs @@ -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; diff --git a/crates/gitbutler-branch-actions/src/undo_commit.rs b/crates/gitbutler-branch-actions/src/undo_commit.rs index 93c20e732..ee891640d 100644 --- a/crates/gitbutler-branch-actions/src/undo_commit.rs +++ b/crates/gitbutler-branch-actions/src/undo_commit.rs @@ -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 _; diff --git a/crates/gitbutler-branch-actions/src/upstream_integration.rs b/crates/gitbutler-branch-actions/src/upstream_integration.rs index f277a38ae..280cc5325 100644 --- a/crates/gitbutler-branch-actions/src/upstream_integration.rs +++ b/crates/gitbutler-branch-actions/src/upstream_integration.rs @@ -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}; diff --git a/crates/gitbutler-branch-actions/src/virtual.rs b/crates/gitbutler-branch-actions/src/virtual.rs index 833467977..f2c119d23 100644 --- a/crates/gitbutler-branch-actions/src/virtual.rs +++ b/crates/gitbutler-branch-actions/src/virtual.rs @@ -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, }; diff --git a/crates/gitbutler-branch-actions/tests/extra/mod.rs b/crates/gitbutler-branch-actions/tests/extra/mod.rs index e7c2358e9..6cfbd1542 100644 --- a/crates/gitbutler-branch-actions/tests/extra/mod.rs +++ b/crates/gitbutler-branch-actions/tests/extra/mod.rs @@ -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; diff --git a/crates/gitbutler-edit-mode/src/lib.rs b/crates/gitbutler-edit-mode/src/lib.rs index 09e485c3f..bd02deb2c 100644 --- a/crates/gitbutler-edit-mode/src/lib.rs +++ b/crates/gitbutler-edit-mode/src/lib.rs @@ -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; diff --git a/crates/gitbutler-stack/src/lib.rs b/crates/gitbutler-stack/src/lib.rs index 17f904378..f4d80ea20 100644 --- a/crates/gitbutler-stack/src/lib.rs +++ b/crates/gitbutler-stack/src/lib.rs @@ -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}; diff --git a/crates/gitbutler-stack/src/stack.rs b/crates/gitbutler-stack/src/stack.rs index 0ede7514c..0bdec6900 100644 --- a/crates/gitbutler-stack/src/stack.rs +++ b/crates/gitbutler-stack/src/stack.rs @@ -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; @@ -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, + upstream: Option, + upstream_head: Option, + tree: git2::Oid, + head: git2::Oid, + order: usize, + selected_for_changes: Option, + 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::().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, + ) -> 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, + ) -> 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, + ) -> 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 { + 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> { + if !self.initialized() { + return Err(anyhow!("Stack has not been initialized")); + } + let state = branch_state(ctx); + let mut all_series: Vec = 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 = vec![]; + let mut remote_commit_ids_by_change_id: HashMap = 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 = 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, + pub name: Option, + /// 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>, +} + +/// 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, +} + +/// 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> { + 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, +) -> 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> { + 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> { + 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>, +} + +fn reference_exists(ctx: &CommandContext, name: &str) -> Result { + let gix_repo = ctx.gix_repository()?; + Ok(gix_repo.find_reference(name_partial(name.into())?).is_ok()) +} + +fn patch_reference_exists(state: &VirtualBranchesHandle, name: &str) -> Result { + 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 { + 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 { + let mut initials = decompose(author.name().unwrap_or_default().into()) + .chars() + .filter(|c| c.is_ascii_alphabetic() || c.is_whitespace()) + .collect::() + .split_whitespace() + .map(|word| word.chars().next().unwrap_or_default()) + .collect::() + .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(()) + } +} diff --git a/crates/gitbutler-stack/src/stack_ext.rs b/crates/gitbutler-stack/src/stack_ext.rs deleted file mode 100644 index c3698bc38..000000000 --- a/crates/gitbutler-stack/src/stack_ext.rs +++ /dev/null @@ -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, - upstream: Option, - upstream_head: Option, - tree: git2::Oid, - head: git2::Oid, - order: usize, - selected_for_changes: Option, - 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, - ) -> 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, - ) -> 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, - ) -> 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; - - /// 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>; - - /// 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, - pub name: Option, - /// 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>, -} - -/// 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, -} - -/// 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, - upstream: Option, - upstream_head: Option, - tree: git2::Oid, - head: git2::Oid, - order: usize, - selected_for_changes: Option, - 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::().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, - ) -> 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, - ) -> 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, - ) -> 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 { - 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> { - if !self.initialized() { - return Err(anyhow!("Stack has not been initialized")); - } - let state = branch_state(ctx); - let mut all_series: Vec = 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 = vec![]; - let mut remote_commit_ids_by_change_id: HashMap = 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 = 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> { - 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, -) -> 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> { - 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> { - 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>, -} - -fn reference_exists(ctx: &CommandContext, name: &str) -> Result { - let gix_repo = ctx.gix_repository()?; - Ok(gix_repo.find_reference(name_partial(name.into())?).is_ok()) -} - -fn patch_reference_exists(state: &VirtualBranchesHandle, name: &str) -> Result { - 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 { - 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 { - let mut initials = decompose(author.name().unwrap_or_default().into()) - .chars() - .filter(|c| c.is_ascii_alphabetic() || c.is_whitespace()) - .collect::() - .split_whitespace() - .map(|word| word.chars().next().unwrap_or_default()) - .collect::() - .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(()) - } -} diff --git a/crates/gitbutler-stack/tests/mod.rs b/crates/gitbutler-stack/tests/mod.rs index bbce397f1..ed3dee5bc 100644 --- a/crates/gitbutler-stack/tests/mod.rs +++ b/crates/gitbutler-stack/tests/mod.rs @@ -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;