From 73fd23877235ab37e80ab2e9fd3a78c8b03dffdc Mon Sep 17 00:00:00 2001 From: estib Date: Tue, 5 May 2026 13:47:45 +0200 Subject: [PATCH 1/3] branch upstream: Get initial steps Get the initial steps, based on the commits in the local branch and on the upstream. branch upstream: integrate using the given steps Given a set of steps, integrate the upstream changes to the local branch. branch upstream integration: merge commit Add the ability to integrate changes by merge commit WIP wip: Merge by creating a full new commit merge correctly integration: Intial steps and display information --- .../src/branch/integrate_branch_upstream.rs | 1194 +++++++++++++ crates/but-workspace/src/branch/mod.rs | 11 + ...erged-with-workspace-conflicting-squash.sh | 33 + .../remote-diverged-with-workspace.sh | 28 + .../branch/integrate_branch_upstream.rs | 1511 +++++++++++++++++ .../tests/workspace/branch/mod.rs | 1 + 6 files changed, 2778 insertions(+) create mode 100644 crates/but-workspace/src/branch/integrate_branch_upstream.rs create mode 100644 crates/but-workspace/tests/fixtures/scenario/remote-diverged-with-workspace-conflicting-squash.sh create mode 100644 crates/but-workspace/tests/fixtures/scenario/remote-diverged-with-workspace.sh create mode 100644 crates/but-workspace/tests/workspace/branch/integrate_branch_upstream.rs diff --git a/crates/but-workspace/src/branch/integrate_branch_upstream.rs b/crates/but-workspace/src/branch/integrate_branch_upstream.rs new file mode 100644 index 00000000000..d96bddefe3e --- /dev/null +++ b/crates/but-workspace/src/branch/integrate_branch_upstream.rs @@ -0,0 +1,1194 @@ +use std::{ + collections::{HashMap, HashSet}, + fmt, + str::FromStr, +}; + +use anyhow::{Context as _, Result, bail}; +use bstr::{BStr, ByteSlice}; +use but_core::{ + RefMetadata, RepositoryExt, + commit::{add_conflict_markers, write_conflicted_tree}, +}; +use but_rebase::graph_rebase::{ + Editor, ExtraRef, GraphEditorOptions, LookupStep, Selector, Step, SuccessfulRebase, ToSelector, + mutate::{SegmentDelimiter, SelectorSet}, +}; +use but_rebase::{commit::DateMode, graph_rebase::merge_commit_changes::MergeCommitChangesOutcome}; +use gix::{prelude::ObjectIdExt as _, remote::Direction}; + +use crate::branch::segment_disconnect::determine_parent_selector; + +/// The steps to be followed when integrating upstream changes into the local one. +#[derive(Debug)] +pub enum InteractiveIntegrationStep { + /// Pick a commit, keeping it in the branch. + Pick { + /// The SHA of the commit being picked. + commit_id: gix::ObjectId, + }, + /// Squash the commits into one. + Squash { + /// The SHAs of the commits to squash. + commits: Vec, + /// Optionally, the message to use for the squash commit. + message: Option, + }, + /// Merge a commit into the previous one. + Merge { + /// The SHA of the commit to squash. + commit_id: gix::ObjectId, + }, +} + +impl fmt::Display for InteractiveIntegrationStep { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Pick { commit_id } => write!(f, "pick {commit_id}"), + Self::Merge { commit_id } => write!(f, "merge {commit_id}"), + Self::Squash { commits, message } => { + write!(f, "squash")?; + for commit_id in commits { + write!(f, " {commit_id}")?; + } + if let Some(message) = message { + write!(f, " | message={message:?}")?; + } + Ok(()) + } + } + } +} + +/// The necessay information about the integration to be performed. +#[derive(Debug)] +pub struct InteractiveIntegration { + /// The list of steps to follow in order to integrate the upstream changes into the local. + pub steps: Vec, + /// Merge base between the upstream and the local reference. + pub merge_base: gix::ObjectId, +} + +impl fmt::Display for InteractiveIntegration { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + writeln!(f, "merge-base {}", self.merge_base)?; + for step in &self.steps { + writeln!(f, "{step}")?; + } + Ok(()) + } +} + +/// A single commit row in the divergence display. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IntegrationDivergenceCommit { + /// The commit shown in the graph row. + pub id: gix::ObjectId, + /// The first-line subject shown for the commit. + pub subject: String, + /// Human-facing ref labels rendered inline on the commit row. + pub refs: Vec, +} + +/// Current branch/upstream divergence information for display purposes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IntegrationDivergenceDisplay { + /// The local branch being integrated. + pub branch_ref_name: gix::refs::FullName, + /// The upstream branch this local branch integrates with. + pub upstream_ref_name: gix::refs::FullName, + /// Commits only reachable from the local branch tip down to the shared section. + pub local_only: Vec, + /// Commits only reachable from the upstream branch tip down to the shared section. + pub upstream_only: Vec, + /// Commits shared or matched between local and upstream above the merge-base. + pub matched: Vec, + /// The merge-base row shown once at the bottom. + pub merge_base: IntegrationDivergenceCommit, +} + +impl fmt::Display for IntegrationDivergenceDisplay { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for commit in &self.local_only { + writeln!(f, "{}", graph_commit_string("* ", commit))?; + } + for commit in &self.upstream_only { + let prefix = if self.local_only.is_empty() { + "* " + } else { + "| * " + }; + writeln!(f, "{}", graph_commit_string(prefix, commit))?; + } + if !self.local_only.is_empty() && !self.upstream_only.is_empty() { + writeln!(f, "|/")?; + } + for commit in &self.matched { + writeln!(f, "{}", graph_commit_string("* ", commit))?; + } + write!(f, "{}", graph_commit_string("* ", &self.merge_base)) + } +} + +/// The initial integration proposal for a branch. +#[derive(Debug)] +pub struct InitialBranchIntegration { + /// The editable execution plan for integrating the branch upstream. + pub integration: InteractiveIntegration, + /// The current divergence between local branch and upstream for display. + pub divergence: IntegrationDivergenceDisplay, +} + +/// Commit ancestry information for a branch and its configured upstream. +#[derive(Debug)] +struct BranchMergeBaseCommits<'a> { + /// Local branch first-parent commits from tip down to, but excluding, the merge base. + local_commits: Vec, + /// Upstream branch first-parent commits from tip down to, but excluding, the merge base. + upstream_commits: Vec, + /// Shared merge base between the local branch and its upstream. + merge_base: gix::ObjectId, + /// Tracking branch reference name associated with the local branch. + upstream_ref_name: std::borrow::Cow<'a, gix::refs::FullNameRef>, +} + +impl InteractiveIntegration { + /// Parse a textual integration script into an [`InteractiveIntegration`]. + /// + /// Blank lines and comment lines starting with `#` are ignored. + pub fn parse(input: &str) -> Result { + let mut merge_base = None; + let mut steps = Vec::new(); + + for (line_idx, raw_line) in input.lines().enumerate() { + let line_no = line_idx + 1; + let line = raw_line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + + if merge_base.is_none() { + let Some(rest) = line.strip_prefix("merge-base ") else { + bail!( + "Line {line_no}: expected first non-comment line to be 'merge-base '" + ); + }; + merge_base = Some(parse_object_id(rest.trim(), line_no)?); + continue; + } + + steps.push(parse_integration_step(line, line_no)?); + } + + let Some(merge_base) = merge_base else { + bail!("Missing required 'merge-base ' line"); + }; + if steps.is_empty() { + bail!("Integration steps cannot be empty"); + } + + Ok(Self { merge_base, steps }) + } +} + +impl FromStr for InteractiveIntegration { + type Err = anyhow::Error; + + fn from_str(s: &str) -> std::result::Result { + Self::parse(s) + } +} + +fn parse_integration_step(line: &str, line_no: usize) -> Result { + let mut parts = line.splitn(2, ' '); + let command = parts + .next() + .expect("splitn always yields at least one part for non-empty input"); + let rest = parts + .next() + .ok_or_else(|| anyhow::anyhow!("Line {line_no}: missing arguments for '{command}'"))? + .trim(); + + match command { + "pick" => Ok(InteractiveIntegrationStep::Pick { + commit_id: parse_object_id(rest, line_no)?, + }), + "merge" => Ok(InteractiveIntegrationStep::Merge { + commit_id: parse_object_id(rest, line_no)?, + }), + "squash" => parse_squash_step(rest, line_no), + _ => bail!("Line {line_no}: unsupported integration command '{command}'"), + } +} + +fn parse_squash_step(rest: &str, line_no: usize) -> Result { + let (commit_part, message) = if let Some((commits, suffix)) = rest.split_once('|') { + let suffix = suffix.trim(); + let Some(message) = suffix.strip_prefix("message=") else { + bail!("Line {line_no}: expected squash metadata suffix 'message=\"...\"'"); + }; + let message = parse_quoted_string(message, line_no)?; + (commits.trim(), Some(message)) + } else { + (rest, None) + }; + + let commits = commit_part + .split_whitespace() + .map(|token| parse_object_id(token, line_no)) + .collect::>>()?; + if commits.len() < 2 { + bail!("Line {line_no}: squash step must list at least two commits"); + } + + Ok(InteractiveIntegrationStep::Squash { commits, message }) +} + +fn parse_object_id(input: &str, line_no: usize) -> Result { + gix::ObjectId::from_hex(input.as_bytes()) + .with_context(|| format!("Line {line_no}: '{input}' is not a valid full object ID")) +} + +fn parse_quoted_string(input: &str, line_no: usize) -> Result { + let Some(content) = input + .strip_prefix('"') + .and_then(|value| value.strip_suffix('"')) + else { + bail!("Line {line_no}: invalid squash message string"); + }; + + let mut out = String::new(); + let mut chars = content.chars(); + while let Some(ch) = chars.next() { + if ch != '\\' { + out.push(ch); + continue; + } + + let Some(escaped) = chars.next() else { + bail!("Line {line_no}: invalid squash message string"); + }; + match escaped { + '\\' => out.push('\\'), + '"' => out.push('"'), + 'n' => out.push('\n'), + 'r' => out.push('\r'), + 't' => out.push('\t'), + _ => bail!("Line {line_no}: invalid squash message string"), + } + } + Ok(out) +} + +/// Integrate the upstream changes in the order of the provided steps. +/// +/// `ref_name` - The full reference name of the local branch we're integrating the upstream changes into. +/// +/// `steps` - The vector of steps in the application order (parent to child) that describe the actions to perform +/// for the integration of the changes. +pub fn integrate_branch_with_steps<'ws, 'meta, M: RefMetadata>( + ref_name: &gix::refs::FullNameRef, + integration: InteractiveIntegration, + workspace: &'ws mut but_graph::Workspace, + meta: &'meta mut M, + repo: &gix::Repository, +) -> Result> { + if integration.steps.is_empty() { + bail!("Integration steps cannot be empty") + } + let (_, upstream_ref_name, _) = get_branch_tips_and_upstream(ref_name, repo)?; + + let upstream_ref_name = upstream_ref_name.as_ref(); + let editor_options = GraphEditorOptions { + extra_refs: vec![ExtraRef::immutable(upstream_ref_name)], + ..GraphEditorOptions::default() + }; + let mut editor = Editor::create_with_opts(workspace, meta, repo, &editor_options)?; + let prepared_steps = prepare_integration_steps_for_editor(&editor, &integration.steps)?; + let delimiter_child = editor.select_reference(ref_name)?; + let delimiter_parent = editor.select_commit(integration.merge_base)?; + let segment_delimiter = SegmentDelimiter { + child: delimiter_child, + parent: delimiter_parent, + }; + let children_to_disconnect = SelectorSet::All; + let parents_to_disconnect = determine_parent_selector(&editor, delimiter_parent)?; + + let children_to_reconnect = selected_edges_from_set( + &editor, + segment_delimiter.child, + &children_to_disconnect, + EdgeSelection::Children, + )?; + let parents_to_reconnect = selected_edges_from_set( + &editor, + segment_delimiter.parent, + &parents_to_disconnect, + EdgeSelection::Parents, + )?; + + editor.disconnect_segment_from( + segment_delimiter, + children_to_disconnect, + parents_to_disconnect, + true, + )?; + + let new_segment_delimiter = integration_steps_into_segment_nodes( + &mut editor, + ref_name, + integration.merge_base, + &prepared_steps, + )?; + + connect_segment_to_edges( + &mut editor, + new_segment_delimiter, + &children_to_reconnect, + &parents_to_reconnect, + )?; + + editor.rebase() +} + +#[derive(Debug, Clone)] +enum PreparedIntegrationStep { + Pick { commit_id: gix::ObjectId }, + Merge { commit_id: gix::ObjectId }, +} + +fn prepare_integration_steps_for_editor( + editor: &Editor<'_, '_, M>, + steps: &[InteractiveIntegrationStep], +) -> Result> { + steps + .iter() + .map(|step| match step { + InteractiveIntegrationStep::Pick { commit_id } => Ok(PreparedIntegrationStep::Pick { + commit_id: *commit_id, + }), + InteractiveIntegrationStep::Merge { commit_id } => Ok(PreparedIntegrationStep::Merge { + commit_id: *commit_id, + }), + InteractiveIntegrationStep::Squash { commits, message } => { + Ok(PreparedIntegrationStep::Pick { + commit_id: prepare_squash_step_for_editor(editor, commits, message.as_deref())?, + }) + } + }) + .collect() +} + +/// Builds and inserts the integrated commit chain under `ref_name` down to `merge_base`. +/// +/// Returns the delimiter spanning from the reference node to the deepest inserted parent. +fn integration_steps_into_segment_nodes( + editor: &mut Editor<'_, '_, M>, + ref_name: &gix::refs::FullNameRef, + merge_base: gix::ObjectId, + steps: &[PreparedIntegrationStep], +) -> Result> { + // Step 1: We interpret the integration steps and transform them into graph steps disconnected from their parents. + // We disconnect them in order to be able to allow for reordering. + let segment_steps = integration_steps_to_segment_steps_for_editor(editor, ref_name, steps)?; + + // Step 2. We build the new local branch out of the steps. + // We start by disconnecting all the parents of the local branch reference step, as we will connect it to the new + // set of commits. + let child_most = editor.select_reference(ref_name)?; + disconnect_selector_from_all_parents(editor, child_most)?; + let mut parent_most = child_most; + + for step in segment_steps.into_iter().skip(1) { + if let Some(existing_parent) = + already_connected_parent_for_step(editor, parent_most, &step)? + { + parent_most = existing_parent; + continue; + } + + parent_most = connect_parent_step(editor, parent_most, step)?; + } + + // Step 3: Append the merge base at the bottom + let merge_base_selector = editor.select_commit(merge_base)?; + let merge_base_step = editor.lookup_step(merge_base_selector)?; + parent_most = if let Some(existing_parent) = + already_connected_parent_for_step(editor, parent_most, &merge_base_step)? + { + existing_parent + } else { + connect_parent_step(editor, parent_most, merge_base_step)? + }; + + Ok(SegmentDelimiter { + child: child_most, + parent: parent_most, + }) +} + +/// Returns an already-connected parent selector for `child` when `step` points to an +/// existing pick node in the graph. +fn already_connected_parent_for_step( + editor: &Editor<'_, '_, M>, + child: Selector, + step: &Step, +) -> Result> { + let Step::Pick(pick) = step else { + return Ok(None); + }; + + let Some(existing_pick) = editor.try_select_commit(pick.id) else { + return Ok(None); + }; + + let direct_parents = editor.direct_parents(child)?; + Ok(direct_parents + .into_iter() + .find_map(|(parent, _)| (parent == existing_pick).then_some(parent))) +} + +/// Connects `child` to `parent_step`, choosing the smallest available edge order. +/// +/// Prefers order `0` when free; otherwise picks the next smallest unused order. +fn connect_parent_step( + editor: &mut Editor<'_, '_, M>, + child: Selector, + parent_step: Step, +) -> Result { + let parent = match parent_step { + Step::Pick(pick) => { + if let Some(existing_pick) = editor.try_select_commit(pick.id) { + existing_pick + } else { + editor.add_step(Step::Pick(pick))? + } + } + Step::Reference { refname } => editor.select_reference(refname.as_ref())?, + Step::None => bail!("BUG: trying to connect to none"), + }; + + let used_orders = editor + .direct_parents(child)? + .into_iter() + .map(|(_, order)| order) + .collect::>(); + let mut order = 0; + while used_orders.contains(&order) { + order += 1; + } + + editor.add_edge(child, parent, order)?; + Ok(parent) +} + +/// Converts user-provided integration steps into graph `Step`s in insertion order. +/// +/// While translating, it applies graph detachments and prepares picks for +/// insertion under the target reference. +fn integration_steps_to_segment_steps_for_editor( + editor: &mut Editor<'_, '_, M>, + ref_name: &gix::refs::FullNameRef, + steps: &[PreparedIntegrationStep], +) -> Result> { + let mut out = vec![Step::Reference { + refname: ref_name.to_owned(), + }]; + + // Interactive steps are parent->child for execution. For graph connectivity + // from reference(child-most) toward parents, we append in reverse. + for step in steps.iter().rev() { + match step { + PreparedIntegrationStep::Pick { commit_id, .. } => { + out.push(existing_or_new_pick_step(editor, *commit_id)?); + } + PreparedIntegrationStep::Merge { commit_id } => { + let mut merge_commit = editor.empty_commit()?; + merge_commit.message = format!("Merge {commit_id} into previous commit").into(); + let merge_commit = + editor.new_commit_untracked(merge_commit, DateMode::CommitterKeepAuthorKeep)?; + let preserved_parents = editor + .find_commit(*commit_id)? + .inner + .parents + .iter() + .copied() + .collect::>(); + let mut commit_to_merge = Step::new_untracked_pick(*commit_id); + let Step::Pick(pick) = &mut commit_to_merge else { + bail!("BUG: expected merge side parent to be a pick step"); + }; + pick.preserved_parents = Some(preserved_parents); + let commit_to_merge = editor.add_step(commit_to_merge)?; + let merge_commit = editor.add_step(Step::new_untracked_pick(merge_commit))?; + editor.add_edge(merge_commit, commit_to_merge, 1)?; + out.push(editor.lookup_step(merge_commit)?); + } + } + } + + Ok(out) +} + +/// Precompute the squash payload from the current editor/repository state, +/// before later integration graph mutations can rewire step-graph ancestry. +fn prepare_squash_step_for_editor( + editor: &Editor<'_, '_, M>, + commit_ids: &[gix::ObjectId], + message: Option<&str>, +) -> Result { + if commit_ids.len() < 2 { + bail!("Squash step must have at least two commits"); + } + + let maybe_selectors = commit_ids + .iter() + .map(|commit_id| editor.try_select_commit(*commit_id)) + .collect::>(); + let ordered_commit_ids = if maybe_selectors.iter().all(Option::is_some) { + let ordered_selectors = editor.order_commit_selectors_by_parentage( + maybe_selectors + .into_iter() + .map(|selector| selector.expect("checked all selectors are present")) + .collect::>(), + )?; + ordered_selectors + .iter() + .map(|selector| { + editor + .find_selectable_commit(*selector) + .map(|(_, commit)| commit.id) + }) + .collect::>>()? + } else { + commit_ids.to_vec() + }; + + let target_commit_id = *ordered_commit_ids + .first() + .expect("validated non-empty squash commit list"); + let merge_subject_ids = commit_ids + .iter() + .copied() + .filter(|commit_id| *commit_id != target_commit_id) + .collect::>(); + let merge_outcome = editor.merge_commit_changes_to_tree( + target_commit_id, + merge_subject_ids, + editor.repo().merge_options_force_ours()?, + )?; + let squashed_parent = editor + .repo() + .merge_base_octopus(ordered_commit_ids.iter().copied()) + .context("failed to compute squash merge-base")? + .detach(); + + let tip_commit_id = *ordered_commit_ids + .last() + .expect("validated non-empty squash commit list"); + let mut squashed_commit = editor.find_commit(tip_commit_id)?; + squashed_commit.inner.parents = vec![squashed_parent].into(); + let commit_message = message + .map(|message| message.as_bytes().to_vec()) + .unwrap_or_else(|| Vec::from(squashed_commit.message.clone())); + apply_merge_commit_changes_outcome( + editor.repo(), + &mut squashed_commit, + merge_outcome, + commit_message, + )?; + + editor.new_commit_untracked(squashed_commit, DateMode::CommitterUpdateAuthorKeep) +} + +fn apply_merge_commit_changes_outcome( + repo: &gix::Repository, + commit: &mut but_core::CommitOwned, + outcome: MergeCommitChangesOutcome, + message: Vec, +) -> Result<()> { + if let Some(conflict) = outcome.conflict { + commit.tree = write_conflicted_tree( + repo, + outcome.tree_id, + conflict.base_tree_id, + conflict.ours_tree_id, + conflict.theirs_tree_id, + &conflict.conflict_entries, + )?; + commit.message = add_conflict_markers(BStr::new(&message)); + } else { + commit.tree = outcome.tree_id; + commit.message = message.into(); + } + + Ok(()) +} + +/// Produces a pick step for `commit_id`, reusing an existing selectable commit when present. +/// +/// Existing commits are detached from selected parent edges first so they can be safely +/// reconnected into the new integration chain. +fn existing_or_new_pick_step( + editor: &mut Editor<'_, '_, M>, + commit_id: gix::ObjectId, +) -> Result { + if let Some(existing) = editor.try_select_commit(commit_id) { + let parents_to_disconnect = determine_parent_selector(editor, existing)?; + editor.disconnect_segment_from( + SegmentDelimiter { + child: existing, + parent: existing, + }, + SelectorSet::None, + parents_to_disconnect, + true, + )?; + + return editor.lookup_step(existing); + } + + Ok(Step::new_pick(commit_id)) +} + +/// Disconnects all parent edges from a single selector without reconnecting them. +/// +/// This is used to isolate reference nodes before rebuilding integration connectivity. +fn disconnect_selector_from_all_parents( + editor: &mut Editor<'_, '_, M>, + selector: Selector, +) -> Result<()> { + editor.disconnect_segment_from( + SegmentDelimiter { + child: selector, + parent: selector, + }, + SelectorSet::None, + SelectorSet::All, + true, + )?; + + Ok(()) +} + +#[derive(Clone, Copy)] +enum EdgeSelection { + Children, + Parents, +} + +/// Resolves concrete direct edges selected by a `SelectorSet` for either children or +/// parents of `target`, preserving edge order metadata. +fn selected_edges_from_set( + editor: &Editor<'_, '_, M>, + target: Selector, + selectors: &SelectorSet, + edge_selection: EdgeSelection, +) -> Result> { + let available = match edge_selection { + EdgeSelection::Children => editor.direct_children(target)?, + EdgeSelection::Parents => editor.direct_parents(target)?, + }; + + match selectors { + SelectorSet::All => Ok(available), + SelectorSet::None => Ok(Vec::new()), + SelectorSet::Some(some_selectors) => { + let mut selected = Vec::new(); + for selector in some_selectors.as_slice() { + let selector = selector.to_selector(editor)?; + let Some((_, order)) = available + .iter() + .find(|(candidate, _)| *candidate == selector) + else { + bail!("Selected edge endpoint wasn't found among direct neighbors") + }; + selected.push((selector, *order)); + } + Ok(selected) + } + } +} + +/// Reconnects a newly built segment delimiter to previously selected child and parent +/// edge endpoints, assigning fresh edge orders after current maxima. +fn connect_segment_to_edges( + editor: &mut Editor<'_, '_, M>, + delimiter: SegmentDelimiter, + children: &[(Selector, usize)], + parents: &[(Selector, usize)], +) -> Result<()> { + let max_child_weight = editor + .direct_children(delimiter.child)? + .into_iter() + .map(|(_, order)| order) + .max() + .unwrap_or(0); + + for (child, order) in children { + let next_order = max_child_weight + *order + 1; + editor.add_edge(*child, delimiter.child, next_order)?; + } + + let max_parent_weight = editor + .direct_parents(delimiter.parent)? + .into_iter() + .map(|(_, order)| order) + .max() + .unwrap_or(0); + + for (parent, order) in parents { + let next_order = max_parent_weight + *order + 1; + editor.add_edge(delimiter.parent, *parent, next_order)?; + } + + Ok(()) +} + +/// Get the initial integration steps for a branch. +/// +/// The returned steps are ordered for application from parent to child so they +/// can be passed directly to integration without reordering by the caller. +/// +/// `ref_name` - The full reference name of the local branch to get the integration steps for. +/// +/// `repo` - The repository handle. +/// +/// Returns the initial integration script and current divergence display state. +pub fn get_initial_integration_steps_for_branch( + ref_name: &gix::refs::FullNameRef, + repo: &gix::Repository, +) -> Result { + let BranchMergeBaseCommits { + local_commits, + upstream_commits, + merge_base, + upstream_ref_name, + } = get_commits_until_merge_base(ref_name, repo)?; + + let upstream_by_id = upstream_commits.iter().copied().collect::>(); + let mut upstream_by_change_id = HashMap::::new(); + for commit_id in &upstream_commits { + let change_id = effective_change_id(repo, *commit_id)?; + // Keep the first seen (closest to tip) upstream commit for stable matching. + upstream_by_change_id.entry(change_id).or_insert(*commit_id); + } + + let mut matched_upstream = HashSet::new(); + let mut local_result_order_commits = Vec::new(); + let mut divergence_local_only = Vec::new(); + let mut divergence_matched = Vec::new(); + for commit_id in local_commits { + if upstream_by_id.contains(&commit_id) { + matched_upstream.insert(commit_id); + local_result_order_commits.push(commit_id); + divergence_matched.push(divergence_commit(repo, commit_id)?); + continue; + } + + let change_id = effective_change_id(repo, commit_id)?; + if let Some(upstream_commit_id) = upstream_by_change_id.get(&change_id) { + matched_upstream.insert(*upstream_commit_id); + local_result_order_commits.push(commit_id); + divergence_matched.push(divergence_commit(repo, commit_id)?); + } else { + local_result_order_commits.push(commit_id); + divergence_local_only.push(divergence_commit(repo, commit_id)?); + } + } + + let remote_only_commits = upstream_commits + .into_iter() + .filter(|id| !matched_upstream.contains(id)); + let mut divergence_upstream_only = Vec::new(); + + let mut initial_steps = Vec::new(); + + // Build the branch in natural tip-to-base result order first, then reverse + // the whole sequence so the returned steps are ready to apply from the + // merge-base upward. + for commit in local_result_order_commits { + initial_steps.push(InteractiveIntegrationStep::Pick { commit_id: commit }); + } + + for upstream_commit in remote_only_commits { + divergence_upstream_only.push(divergence_commit(repo, upstream_commit)?); + initial_steps.push(InteractiveIntegrationStep::Pick { + commit_id: upstream_commit, + }); + } + + initial_steps.reverse(); + + let integration = InteractiveIntegration { + steps: initial_steps, + merge_base, + }; + let mut divergence = IntegrationDivergenceDisplay { + branch_ref_name: ref_name.to_owned(), + upstream_ref_name: upstream_ref_name.into_owned(), + local_only: divergence_local_only, + upstream_only: divergence_upstream_only, + matched: divergence_matched, + merge_base: divergence_commit(repo, merge_base)?, + }; + let local_tip = divergence + .local_only + .first() + .map(|commit| commit.id) + .or_else(|| divergence.matched.first().map(|commit| commit.id)); + let upstream_tip = divergence + .upstream_only + .first() + .map(|commit| commit.id) + .or_else(|| divergence.matched.first().map(|commit| commit.id)); + add_ref_label( + &mut divergence.local_only, + &mut divergence.matched, + local_tip, + divergence.branch_ref_name.shorten().to_string(), + ); + add_ref_label( + &mut divergence.upstream_only, + &mut divergence.matched, + upstream_tip, + divergence.upstream_ref_name.shorten().to_string(), + ); + + Ok(InitialBranchIntegration { + integration, + divergence, + }) +} + +/// Computes local and upstream commit lists (tip to merge-base, first-parent) together +/// with their merge base for a branch and its tracking branch. +fn get_commits_until_merge_base<'a>( + ref_name: &'a gix::refs::FullNameRef, + repo: &'a gix::Repository, +) -> Result, anyhow::Error> { + let (local_tip, upstream_ref_name, upstream_tip) = + get_branch_tips_and_upstream(ref_name, repo)?; + let cache = repo.commit_graph_if_enabled()?; + let mut graph = repo.revision_graph(cache.as_ref()); + let merge_base = repo + .merge_base_with_graph(local_tip.attach(repo), upstream_tip.attach(repo), &mut graph) + .map(|id| id.detach()) + .map_err(|_| { + anyhow::anyhow!( + "No merge-base found between '{ref_name}' and its tracking branch '{upstream_ref_name}'" + ) + })?; + let local_commits = branch_commits_until(repo, local_tip, merge_base)?; + let upstream_commits = branch_commits_until(repo, upstream_tip, merge_base)?; + Ok(BranchMergeBaseCommits { + local_commits, + upstream_commits, + merge_base, + upstream_ref_name, + }) +} + +/// Resolves local/upstream branch tips and tracking reference name for `ref_name`. +fn get_branch_tips_and_upstream<'a>( + ref_name: &'a gix::refs::FullNameRef, + repo: &'a gix::Repository, +) -> Result< + ( + gix::ObjectId, + std::borrow::Cow<'a, gix::refs::FullNameRef>, + gix::ObjectId, + ), + anyhow::Error, +> { + let mut local_branch = repo + .find_reference(ref_name) + .with_context(|| format!("Couldn't find local branch '{ref_name}'"))?; + let local_tip = local_branch.peel_to_id()?.detach(); + let upstream_ref_name = resolve_tracking_branch_ref_name(ref_name, repo)?; + let mut upstream_branch = repo + .find_reference(upstream_ref_name.as_ref()) + .with_context(|| { + format!( + "Couldn't find tracking branch '{upstream_ref_name}' for local branch '{ref_name}'" + ) + })?; + let upstream_tip = upstream_branch.peel_to_id()?.detach(); + Ok((local_tip, upstream_ref_name, upstream_tip)) +} + +/// Resolve the remote-tracking ref that corresponds to `ref_name`. +/// +/// This first honors the configured tracking branch. If there is no tracking +/// configuration, or it points at a missing ref, we fall back to a unique +/// `refs/remotes/*/` match, mirroring legacy `but` CLI behavior. +pub fn resolve_tracking_branch_ref_name<'a>( + ref_name: &'a gix::refs::FullNameRef, + repo: &'a gix::Repository, +) -> Result> { + if let Some(upstream_ref_name) = repo + .branch_remote_tracking_ref_name(ref_name, Direction::Fetch) + .transpose()? + && repo + .try_find_reference(upstream_ref_name.as_ref())? + .is_some() + { + return Ok(upstream_ref_name); + } + + let branch_name = ref_name.shorten(); + let mut remote_matches = repo + .remote_names() + .iter() + .filter_map(|remote_name| { + let full_name = format!("refs/remotes/{remote_name}/{branch_name}"); + repo.try_find_reference(&full_name) + .transpose() + .map(|reference| { + reference.map(|_| { + full_name + .try_into() + .expect("constructed remote-tracking refname must be valid") + }) + }) + }) + .collect::, _>>()?; + + if remote_matches.len() == 1 { + return Ok(std::borrow::Cow::Owned( + remote_matches + .pop() + .expect("exactly one remote match exists"), + )); + } + + bail!("Branch '{ref_name}' has no tracking branch") +} + +/// Returns first-parent commits reachable from `tip` until (excluding) `merge_base`. +fn branch_commits_until( + repo: &gix::Repository, + tip: gix::ObjectId, + merge_base: gix::ObjectId, +) -> Result> { + let traversal = tip + .attach(repo) + .ancestors() + .with_hidden(Some(merge_base)) + .first_parent_only() + .all()?; + + let mut out = Vec::new(); + for info in traversal { + out.push(info?.id); + } + Ok(out) +} + +/// Returns the effective change-id string for a commit, used for rewritten-commit matching. +fn effective_change_id(repo: &gix::Repository, commit_id: gix::ObjectId) -> Result { + Ok(but_core::Commit::from_id(commit_id.attach(repo))? + .change_id() + .to_string()) +} + +fn divergence_commit( + repo: &gix::Repository, + commit_id: gix::ObjectId, +) -> Result { + Ok(IntegrationDivergenceCommit { + id: commit_id, + subject: but_core::Commit::from_id(commit_id.attach(repo))? + .message + .lines() + .next() + .unwrap_or_default() + .to_str_lossy() + .into_owned(), + refs: Vec::new(), + }) +} + +fn add_ref_label( + primary: &mut [IntegrationDivergenceCommit], + secondary: &mut [IntegrationDivergenceCommit], + id: Option, + label: String, +) { + let Some(id) = id else { + return; + }; + if let Some(commit) = primary.iter_mut().find(|commit| commit.id == id) { + if !commit.refs.contains(&label) { + commit.refs.push(label); + } + return; + } + if let Some(commit) = secondary.iter_mut().find(|commit| commit.id == id) + && !commit.refs.contains(&label) + { + commit.refs.push(label); + } +} + +fn graph_commit_string(prefix: &str, commit: &IntegrationDivergenceCommit) -> String { + let refs = if commit.refs.is_empty() { + String::new() + } else { + format!(" ({})", commit.refs.join(", ")) + }; + format!( + "{prefix}{}{} {}", + commit.id.to_hex_with_len(7), + refs, + commit.subject + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn oid(hex: &str) -> gix::ObjectId { + gix::ObjectId::from_hex(hex.as_bytes()).expect("valid object id") + } + + #[test] + fn interactive_integration_step_display_is_stable() { + let parent = oid("1111111111111111111111111111111111111111"); + let squash_parent = oid("2222222222222222222222222222222222222222"); + let squash_child = oid("3333333333333333333333333333333333333333"); + let pick = InteractiveIntegrationStep::Pick { commit_id: parent }; + assert_eq!(pick.to_string(), format!("pick {parent}")); + + let squash_without_message = InteractiveIntegrationStep::Squash { + commits: vec![squash_parent, squash_child], + message: None, + }; + assert_eq!( + squash_without_message.to_string(), + format!("squash {squash_parent} {squash_child}") + ); + + let squash_with_message = InteractiveIntegrationStep::Squash { + commits: vec![squash_parent, squash_child], + message: Some("hello \"world\"".to_string()), + }; + assert_eq!( + squash_with_message.to_string(), + format!("squash {squash_parent} {squash_child} | message=\"hello \\\"world\\\"\"") + ); + } + + #[test] + fn interactive_integration_parse_round_trips_display_format() { + let merge_base = oid("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + let parent = oid("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"); + let upstream = oid("cccccccccccccccccccccccccccccccccccccccc"); + let squash_child = oid("dddddddddddddddddddddddddddddddddddddddd"); + + let integration = InteractiveIntegration { + merge_base, + steps: vec![ + InteractiveIntegrationStep::Pick { commit_id: parent }, + InteractiveIntegrationStep::Pick { + commit_id: upstream, + }, + InteractiveIntegrationStep::Squash { + commits: vec![parent, squash_child], + message: Some("hello".into()), + }, + ], + }; + + let parsed = InteractiveIntegration::parse(&integration.to_string()).expect( + "display format should remain parseable so the TUI can round-trip edited scripts", + ); + assert_eq!(parsed.merge_base, integration.merge_base); + assert_eq!(parsed.steps.len(), integration.steps.len()); + } + + #[test] + fn interactive_integration_parse_rejects_skip_command() { + let merge_base = oid("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + let parent = oid("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"); + + let err = + InteractiveIntegration::parse(&format!("merge-base {merge_base}\nskip {parent}\n")) + .expect_err("skip commands should be rejected in integration scripts"); + + assert!( + err.to_string() + .contains("unsupported integration command 'skip'"), + "skip command should be rejected explicitly: {err:#}" + ); + } + + #[test] + fn interactive_integration_parse_requires_merge_base() { + let err = InteractiveIntegration::parse("pick 1111111111111111111111111111111111111111\n") + .expect_err("script without merge-base must fail"); + + assert!( + err.to_string().contains("merge-base"), + "missing merge-base should be called out clearly" + ); + } + + #[test] + fn interactive_integration_parse_rejects_invalid_squash_message() { + let err = InteractiveIntegration::parse( + "merge-base 1111111111111111111111111111111111111111\nsquash 2222222222222222222222222222222222222222 3333333333333333333333333333333333333333 | message=hello\n", + ) + .expect_err("unquoted squash message must fail"); + + assert!( + err.to_string().contains("invalid squash message"), + "invalid squash message should produce a targeted error" + ); + } + + #[test] + fn divergence_display_renders_git_style_graph() { + let display = IntegrationDivergenceDisplay { + branch_ref_name: gix::refs::Category::LocalBranch + .to_full_name("feature") + .expect("valid local branch"), + upstream_ref_name: gix::refs::Category::RemoteBranch + .to_full_name("origin/feature") + .expect("valid remote branch"), + local_only: vec![IntegrationDivergenceCommit { + id: oid("1111111111111111111111111111111111111111"), + subject: "local tip".into(), + refs: vec!["feature".into()], + }], + upstream_only: vec![IntegrationDivergenceCommit { + id: oid("2222222222222222222222222222222222222222"), + subject: "remote tip".into(), + refs: vec!["origin/feature".into()], + }], + matched: vec![IntegrationDivergenceCommit { + id: oid("3333333333333333333333333333333333333333"), + subject: "shared".into(), + refs: Vec::new(), + }], + merge_base: IntegrationDivergenceCommit { + id: oid("4444444444444444444444444444444444444444"), + subject: "base".into(), + refs: Vec::new(), + }, + }; + + insta::assert_snapshot!( + display.to_string(), + "graph output should stay stable because the CLI and frontend consume it directly", + @r" + * 1111111 (feature) local tip + | * 2222222 (origin/feature) remote tip + |/ + * 3333333 shared + * 4444444 base + " + ); + } +} diff --git a/crates/but-workspace/src/branch/mod.rs b/crates/but-workspace/src/branch/mod.rs index 9b6831800d3..350125f7e47 100644 --- a/crates/but-workspace/src/branch/mod.rs +++ b/crates/but-workspace/src/branch/mod.rs @@ -491,3 +491,14 @@ pub use create_reference::function::create_reference; /// Functions and types related to moving branches across stacks. pub mod move_branch; pub use move_branch::function::{move_branch, tear_off_branch}; + +mod segment_disconnect; +pub(crate) use segment_disconnect::determine_parent_selector; + +/// Functions and types for integrating remote changes into local branches. +pub mod integrate_branch_upstream; +pub use integrate_branch_upstream::{ + InitialBranchIntegration, IntegrationDivergenceCommit, IntegrationDivergenceDisplay, + InteractiveIntegrationStep, get_initial_integration_steps_for_branch, + integrate_branch_with_steps, +}; diff --git a/crates/but-workspace/tests/fixtures/scenario/remote-diverged-with-workspace-conflicting-squash.sh b/crates/but-workspace/tests/fixtures/scenario/remote-diverged-with-workspace-conflicting-squash.sh new file mode 100644 index 00000000000..8356e6d4853 --- /dev/null +++ b/crates/but-workspace/tests/fixtures/scenario/remote-diverged-with-workspace-conflicting-squash.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash + +set -eu -o pipefail + +source "${BASH_SOURCE[0]%/*}/shared.sh" + +git init + +tick +echo base >shared.txt +git add shared.txt +git commit -m "init-integration" +setup_target_to_match_main + +git checkout -b A main +remote_tracking_caught_up A + +tick +echo local >shared.txt +git add shared.txt +git commit -m "local change in A 1" + +git checkout -b new-origin main + +tick +echo remote >shared.txt +git add shared.txt +git commit -m "remote change in A 1" +setup_remote_tracking new-origin A + +git checkout A + +create_workspace_commit_once A diff --git a/crates/but-workspace/tests/fixtures/scenario/remote-diverged-with-workspace.sh b/crates/but-workspace/tests/fixtures/scenario/remote-diverged-with-workspace.sh new file mode 100644 index 00000000000..5081f379339 --- /dev/null +++ b/crates/but-workspace/tests/fixtures/scenario/remote-diverged-with-workspace.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash + +set -eu -o pipefail + +source "${BASH_SOURCE[0]%/*}/shared.sh" + +git init + +commit "init-integration" +setup_target_to_match_main + +git checkout -b A main +commit "shared local/remote" +remote_tracking_caught_up A + +# Local branch diverges from origin/A. +commit "local change in A 1" +commit "local change in A 2" + +# Simulate a different remote tip for A. +git checkout -b new-origin A~2 +commit "remote change in A 1" +commit "remote change in A 2" +setup_remote_tracking new-origin A 'move' + +git checkout A + +create_workspace_commit_once A \ No newline at end of file diff --git a/crates/but-workspace/tests/workspace/branch/integrate_branch_upstream.rs b/crates/but-workspace/tests/workspace/branch/integrate_branch_upstream.rs new file mode 100644 index 00000000000..de1b947a7c4 --- /dev/null +++ b/crates/but-workspace/tests/workspace/branch/integrate_branch_upstream.rs @@ -0,0 +1,1511 @@ +use std::vec; + +use anyhow::{Result, bail}; +use but_core::Commit; +use but_testsupport::{visualize_commit_graph_all, visualize_tree}; +use but_workspace::branch::integrate_branch_upstream::{ + InitialBranchIntegration, IntegrationDivergenceCommit, InteractiveIntegration, + InteractiveIntegrationStep, get_initial_integration_steps_for_branch, + integrate_branch_with_steps, +}; +use gix::prelude::ObjectIdExt; + +use crate::{ + ref_info::with_workspace_commit::utils::{ + StackState, add_stack_with_segments, named_writable_scenario_with_description_and_graph, + }, + utils::{read_only_in_memory_scenario, read_only_in_memory_scenario_named}, +}; + +fn normalized_graph_snapshot(repo: &gix::Repository) -> Result { + let rendered = visualize_commit_graph_all(repo)?; + Ok(rendered + .lines() + .map(str::trim_end) + .collect::>() + .join("\n")) +} + +fn labeled_integration_snapshot( + integration: &InteractiveIntegration, + labels: &[(gix::ObjectId, &str)], +) -> String { + let mut out = String::new(); + out.push_str("merge-base "); + out.push_str(&label_for(integration.merge_base, labels)); + out.push('\n'); + + for step in &integration.steps { + match step { + InteractiveIntegrationStep::Pick { commit_id } => { + out.push_str("pick "); + out.push_str(&label_for(*commit_id, labels)); + } + InteractiveIntegrationStep::Merge { commit_id } => { + out.push_str("merge "); + out.push_str(&label_for(*commit_id, labels)); + } + InteractiveIntegrationStep::Squash { commits, message } => { + out.push_str("squash"); + for commit_id in commits { + out.push(' '); + out.push_str(&label_for(*commit_id, labels)); + } + if let Some(message) = message { + out.push_str(" | message="); + out.push_str(&format!("{message:?}")); + } + } + } + out.push('\n'); + } + + out.lines() + .map(str::trim_end) + .collect::>() + .join("\n") +} + +fn labeled_divergence_snapshot( + initial: &InitialBranchIntegration, + labels: &[(gix::ObjectId, &str)], +) -> String { + fn render_commit( + prefix: &str, + commit: &IntegrationDivergenceCommit, + labels: &[(gix::ObjectId, &str)], + ) -> String { + let refs = if commit.refs.is_empty() { + String::new() + } else { + format!(" ({})", commit.refs.join(", ")) + }; + format!( + "{prefix}{}{} {}", + label_for(commit.id, labels), + refs, + commit.subject + ) + } + + let mut out = Vec::new(); + for commit in &initial.divergence.local_only { + out.push(render_commit("* ", commit, labels)); + } + for commit in &initial.divergence.upstream_only { + let prefix = if initial.divergence.local_only.is_empty() { + "* " + } else { + "| * " + }; + out.push(render_commit(prefix, commit, labels)); + } + if !initial.divergence.local_only.is_empty() && !initial.divergence.upstream_only.is_empty() { + out.push("|/".into()); + } + for commit in &initial.divergence.matched { + out.push(render_commit("* ", commit, labels)); + } + out.push(render_commit("* ", &initial.divergence.merge_base, labels)); + out.join("\n") +} + +fn label_for(id: gix::ObjectId, labels: &[(gix::ObjectId, &str)]) -> String { + labels + .iter() + .find_map(|(candidate, label)| (*candidate == id).then_some(*label)) + .map(ToOwned::to_owned) + .unwrap_or_else(|| id.to_string()) +} + +#[test] +fn errors_when_branch_has_no_tracking_branch() -> Result<()> { + let repo = read_only_in_memory_scenario("merge-with-two-branches-line-offset") + .expect("fixture repo should be available"); + + insta::assert_snapshot!(normalized_graph_snapshot(&repo)?, @r" + * 2a6d103 (HEAD -> merge) Merge branch 'A' into merge + |\ + | * 7f389ed (A) add 10 to the beginning + * | 91ef6f6 (B) add 10 to the end + |/ + * ff045ef (main) init + "); + + let err = get_initial_integration_steps_for_branch(r("refs/heads/A"), &repo) + .expect_err("branch without tracking must fail"); + + assert!( + err.to_string().contains("has no tracking branch"), + "unexpected error: {err:#}" + ); + + Ok(()) +} + +#[test] +fn partitions_diverged_branch_into_application_order() -> Result<()> { + let mut repo = + read_only_in_memory_scenario_named("with-remotes-no-workspace", "remote-diverged")?; + + insta::assert_snapshot!(normalized_graph_snapshot(&repo)?, @" + * 1a265a4 (HEAD -> A) local change in A + | * 89cc2d3 (origin/A) change in A + |/ + * d79bba9 new file in A + * c166d42 (origin/main, origin/HEAD, main) init-integration + "); + + let local_tip = repo.rev_parse_single("A")?.detach(); + let upstream_tip = repo.rev_parse_single("origin/A")?.detach(); + let merge_base = repo.rev_parse_single("A~1")?.detach(); + configure_tracking_for_branch_a(&mut repo)?; + + let initial = get_initial_integration_steps_for_branch(r("refs/heads/A"), &repo)?; + + insta::assert_snapshot!( + labeled_integration_snapshot( + &initial.integration, + &[ + (merge_base, "merge-base"), + (local_tip, "local-tip"), + (upstream_tip, "upstream-tip"), + ] + ), + @" + merge-base merge-base + pick upstream-tip + pick local-tip + " + ); + + insta::assert_snapshot!( + labeled_divergence_snapshot( + &initial, + &[ + (merge_base, "merge-base"), + (local_tip, "local-tip"), + (upstream_tip, "upstream-tip"), + ] + ), + @" + * local-tip (A) local change in A + | * upstream-tip (origin/A) change in A + |/ + * merge-base new file in A + " + ); + + let step_ids = pick_step_ids(&initial.integration.steps); + + assert_eq!( + step_ids, + vec![upstream_tip, local_tip], + "expected application order to replay the upstream commit before the local tip" + ); + Ok(()) +} + +#[test] +fn falls_back_to_unique_remote_branch_without_tracking_config() -> Result<()> { + let repo = read_only_in_memory_scenario_named("with-remotes-no-workspace", "remote-diverged")?; + + let local_tip = repo.rev_parse_single("A")?.detach(); + let upstream_tip = repo.rev_parse_single("origin/A")?.detach(); + let merge_base = repo.rev_parse_single("A~1")?.detach(); + + let initial = get_initial_integration_steps_for_branch(r("refs/heads/A"), &repo)?; + + insta::assert_snapshot!( + labeled_integration_snapshot( + &initial.integration, + &[ + (merge_base, "merge-base"), + (local_tip, "local-tip"), + (upstream_tip, "upstream-tip"), + ] + ), + @" + merge-base merge-base + pick upstream-tip + pick local-tip + " + ); + + insta::assert_snapshot!( + labeled_divergence_snapshot( + &initial, + &[ + (merge_base, "merge-base"), + (local_tip, "local-tip"), + (upstream_tip, "upstream-tip"), + ] + ), + @" + * local-tip (A) local change in A + | * upstream-tip (origin/A) change in A + |/ + * merge-base new file in A + " + ); + + Ok(()) +} + +#[test] +fn matches_rewritten_commit_by_change_id_and_keeps_order() -> Result<()> { + let mut repo = read_only_in_memory_scenario_named( + "journey03", + "01-rewritten-local-commit-is-paired-with-remote", + )?; + configure_tracking_for_branch_a(&mut repo)?; + insta::assert_snapshot!(normalized_graph_snapshot(&repo)?, @" + * 0b1ed50 (HEAD -> gitbutler/workspace) GitButler Workspace Commit + * e9c9d74 (A) A2 + * 550b6ac A1 + | * ad92cce (origin/A) A2 + | * e1f216e A1 + |/ + * fafd9d0 (origin/main, main) init + "); + + let local_only = repo.rev_parse_single("A~1")?.detach(); + let remote_only = repo.rev_parse_single("origin/A~1")?.detach(); + let local_and_remote = repo.rev_parse_single("A")?.detach(); + let merge_base = repo.rev_parse_single("A~2")?.detach(); + configure_tracking_for_branch_a(&mut repo)?; + + let initial = get_initial_integration_steps_for_branch(r("refs/heads/A"), &repo)?; + + insta::assert_snapshot!( + labeled_integration_snapshot( + &initial.integration, + &[ + (merge_base, "merge-base"), + (local_only, "local-only"), + (remote_only, "remote-only"), + (local_and_remote, "local-and-remote"), + ] + ), + @" + merge-base merge-base + pick remote-only + pick local-only + pick local-and-remote + " + ); + + insta::assert_snapshot!( + labeled_divergence_snapshot( + &initial, + &[ + (merge_base, "merge-base"), + (local_only, "local-only"), + (remote_only, "remote-only"), + (local_and_remote, "local-and-remote"), + ] + ), + @" + * local-only (A) A1 + | * remote-only (origin/A) A1 + |/ + * local-and-remote A2 + * merge-base init + " + ); + + let step_ids = pick_step_ids(&initial.integration.steps); + + assert_eq!( + step_ids, + vec![remote_only, local_only, local_and_remote], + "expected application order to build from the merge-base up to the rewritten local tip" + ); + Ok(()) +} + +#[test] +fn integrate_branch_with_steps_empty_errors_early() -> Result<()> { + let (_tmp, graph, mut repo, mut meta, _description) = + named_writable_scenario_with_description_and_graph( + "ws-ref-ws-commit-single-stack-double-stack", + |meta| { + add_stack_with_segments(meta, 1, "A", StackState::InWorkspace, &[]); + add_stack_with_segments(meta, 2, "C", StackState::InWorkspace, &["B"]); + }, + )?; + + insta::assert_snapshot!(normalized_graph_snapshot(&repo)?, @r" + * f3e1bf2 (HEAD -> gitbutler/workspace) GitButler Workspace Commit + |\ + | * 09d8e52 (A) A + * | 09bc93e (C) C + * | c813d8d (B) B + |/ + * 85efbe4 (origin/main, main) M + "); + + let mut ws = graph.into_workspace()?; + let merge_base = repo.rev_parse_single("main")?.detach(); + let integration = InteractiveIntegration { + merge_base, + steps: vec![], + }; + + configure_tracking_for_branch_a(&mut repo)?; + + let err = + integrate_branch_with_steps(r("refs/heads/B"), integration, &mut ws, &mut meta, &repo) + .expect_err("expected early validation error for empty integration steps"); + assert!( + err.to_string() + .contains("Integration steps cannot be empty"), + "unexpected error: {err:#}" + ); + + Ok(()) +} + +#[test] +fn integrate_branch_with_merge_step_does_not_require_preceding_commit() -> Result<()> { + let (_tmp, graph, mut repo, mut meta, _description) = + named_writable_scenario_with_description_and_graph( + "remote-diverged-with-workspace", + |meta| { + add_stack_with_segments(meta, 1, "A", StackState::InWorkspace, &[]); + }, + )?; + + insta::assert_snapshot!(normalized_graph_snapshot(&repo)?, @" + * a7060f8 (HEAD -> gitbutler/workspace) GitButler Workspace Commit + * 8347946 (A) local change in A 2 + * 86838ae local change in A 1 + | * 6a17628 (origin/A) remote change in A 2 + | * 715d7b0 remote change in A 1 + |/ + * 621b98a shared local/remote + * cfbcc20 (origin/main, main) init-integration + "); + + let mut ws = graph.into_workspace()?; + + let remote_commit_1 = repo.rev_parse_single("origin/A~1")?.detach(); + let local_and_remote = repo.rev_parse_single("A~2")?.detach(); + let remote_tip_before = repo.rev_parse_single("origin/A")?.detach(); + let integration = InteractiveIntegration { + merge_base: local_and_remote, + steps: vec![InteractiveIntegrationStep::Merge { + commit_id: remote_commit_1, + }], + }; + + configure_tracking_for_branch_a(&mut repo)?; + + let rebase = + integrate_branch_with_steps(r("refs/heads/A"), integration, &mut ws, &mut meta, &repo)?; + rebase.materialize()?; + + insta::assert_snapshot!(normalized_graph_snapshot(&repo)?, @r" + * b74fc70 (HEAD -> gitbutler/workspace) GitButler Workspace Commit + * b595e67 (A) Merge 715d7b0b14844b459ef031a7332283932e99a6a5 into previous commit + |\ + | | * 6a17628 (origin/A) remote change in A 2 + | |/ + | * 715d7b0 remote change in A 1 + |/ + * 621b98a shared local/remote + * cfbcc20 (origin/main, main) init-integration + "); + + assert_eq!( + repo.rev_parse_single("origin/A")?.detach(), + remote_tip_before + ); + + Ok(()) +} + +#[test] +fn integrate_upstream_commits_into_local() -> Result<()> { + let (_tmp, graph, mut repo, mut meta, _description) = + named_writable_scenario_with_description_and_graph( + "remote-diverged-with-workspace", + |meta| { + add_stack_with_segments(meta, 1, "A", StackState::InWorkspace, &[]); + }, + )?; + + insta::assert_snapshot!(normalized_graph_snapshot(&repo)?, @" + * a7060f8 (HEAD -> gitbutler/workspace) GitButler Workspace Commit + * 8347946 (A) local change in A 2 + * 86838ae local change in A 1 + | * 6a17628 (origin/A) remote change in A 2 + | * 715d7b0 remote change in A 1 + |/ + * 621b98a shared local/remote + * cfbcc20 (origin/main, main) init-integration + "); + + let mut ws = graph.into_workspace()?; + + let local_commit_2 = repo.rev_parse_single("A")?.detach(); + let local_commit_1 = repo.rev_parse_single("A~1")?.detach(); + let remote_commit_2 = repo.rev_parse_single("origin/A")?.detach(); + let remote_commit_1 = repo.rev_parse_single("origin/A~1")?.detach(); + let remote_tip_before = remote_commit_2; + let local_and_remote = repo.rev_parse_single("A~2")?.detach(); + let steps = vec![ + InteractiveIntegrationStep::Pick { + commit_id: remote_commit_1, + }, + InteractiveIntegrationStep::Pick { + commit_id: remote_commit_2, + }, + InteractiveIntegrationStep::Pick { + commit_id: local_commit_1, + }, + InteractiveIntegrationStep::Pick { + commit_id: local_commit_2, + }, + ]; + + let integration = InteractiveIntegration { + merge_base: local_and_remote, + steps, + }; + + configure_tracking_for_branch_a(&mut repo)?; + + let rebase = + integrate_branch_with_steps(r("refs/heads/A"), integration, &mut ws, &mut meta, &repo)?; + rebase.materialize()?; + + insta::assert_snapshot!(visualize_commit_graph_all(&repo)?, @" + * 455d393 (HEAD -> gitbutler/workspace) GitButler Workspace Commit + * 298d472 (A) local change in A 2 + * 422a07d local change in A 1 + * 6a17628 (origin/A) remote change in A 2 + * 715d7b0 remote change in A 1 + * 621b98a shared local/remote + * cfbcc20 (origin/main, main) init-integration + "); + + assert_eq!( + repo.rev_parse_single("origin/A")?.detach(), + remote_tip_before + ); + + Ok(()) +} + +#[test] +fn integrate_upstream_commits_into_local_with_merge_step() -> Result<()> { + let (_tmp, graph, mut repo, mut meta, _description) = + named_writable_scenario_with_description_and_graph( + "remote-diverged-with-workspace", + |meta| { + add_stack_with_segments(meta, 1, "A", StackState::InWorkspace, &[]); + }, + )?; + + insta::assert_snapshot!(normalized_graph_snapshot(&repo)?, @" + * a7060f8 (HEAD -> gitbutler/workspace) GitButler Workspace Commit + * 8347946 (A) local change in A 2 + * 86838ae local change in A 1 + | * 6a17628 (origin/A) remote change in A 2 + | * 715d7b0 remote change in A 1 + |/ + * 621b98a shared local/remote + * cfbcc20 (origin/main, main) init-integration + "); + + let mut ws = graph.into_workspace()?; + + let local_commit_2 = repo.rev_parse_single("A")?.detach(); + let local_commit_1 = repo.rev_parse_single("A~1")?.detach(); + let remote_commit_1 = repo.rev_parse_single("origin/A~1")?.detach(); + let local_and_remote = repo.rev_parse_single("A~2")?.detach(); + let integration = InteractiveIntegration { + merge_base: local_and_remote, + steps: vec![ + InteractiveIntegrationStep::Pick { + commit_id: local_commit_1, + }, + InteractiveIntegrationStep::Merge { + commit_id: remote_commit_1, + }, + InteractiveIntegrationStep::Pick { + commit_id: local_commit_2, + }, + ], + }; + + configure_tracking_for_branch_a(&mut repo)?; + + let rebase = + integrate_branch_with_steps(r("refs/heads/A"), integration, &mut ws, &mut meta, &repo)?; + rebase.materialize()?; + + insta::assert_snapshot!(normalized_graph_snapshot(&repo)?, @r" + * a74b8e3 (HEAD -> gitbutler/workspace) GitButler Workspace Commit + * fdc285b (A) local change in A 2 + * 0d584c5 Merge 715d7b0b14844b459ef031a7332283932e99a6a5 into previous commit + |\ + * | 86838ae local change in A 1 + | | * 6a17628 (origin/A) remote change in A 2 + | |/ + | * 715d7b0 remote change in A 1 + |/ + * 621b98a shared local/remote + * cfbcc20 (origin/main, main) init-integration + "); + + let branch_tip = repo.find_commit(repo.rev_parse_single("A")?.detach())?; + let branch_tip_parents = branch_tip.parent_ids().collect::>(); + assert_eq!( + branch_tip_parents.len(), + 1, + "tip should remain a non-merge commit" + ); + + let merge_commit_id = branch_tip_parents[0].detach(); + let merge_commit = repo.find_commit(merge_commit_id)?; + assert_eq!( + merge_commit.message_raw()?, + format!("Merge {remote_commit_1} into previous commit") + ); + + let merge_parents = merge_commit.parent_ids().collect::>(); + assert_eq!( + merge_parents.len(), + 2, + "merge step should produce a merge commit" + ); + assert_eq!( + merge_parents[1].detach(), + remote_commit_1, + "merge step should retain the selected remote commit as the second parent" + ); + + let merged_previous_commit = merge_parents[0].detach(); + let merged_previous = repo.find_commit(merged_previous_commit)?; + assert_eq!(merged_previous.message_raw()?, "local change in A 1\n"); + + insta::assert_snapshot!(visualize_tree(merge_commit.tree_id()?), @"4b825dc"); + + Ok(()) +} + +#[test] +fn integrate_upstream_commits_into_local_with_all_locals_then_merge_second_remote() -> Result<()> { + let (_tmp, graph, mut repo, mut meta, _description) = + named_writable_scenario_with_description_and_graph( + "remote-diverged-with-workspace", + |meta| { + add_stack_with_segments(meta, 1, "A", StackState::InWorkspace, &[]); + }, + )?; + + let mut ws = graph.into_workspace()?; + + let local_commit_2 = repo.rev_parse_single("A")?.detach(); + let local_commit_1 = repo.rev_parse_single("A~1")?.detach(); + let remote_commit_2 = repo.rev_parse_single("origin/A")?.detach(); + let local_and_remote = repo.rev_parse_single("A~2")?.detach(); + let integration = InteractiveIntegration { + merge_base: local_and_remote, + steps: vec![ + InteractiveIntegrationStep::Pick { + commit_id: local_commit_1, + }, + InteractiveIntegrationStep::Pick { + commit_id: local_commit_2, + }, + InteractiveIntegrationStep::Merge { + commit_id: remote_commit_2, + }, + ], + }; + + configure_tracking_for_branch_a(&mut repo)?; + + let rebase = + integrate_branch_with_steps(r("refs/heads/A"), integration, &mut ws, &mut meta, &repo)?; + rebase.materialize()?; + + insta::assert_snapshot!(normalized_graph_snapshot(&repo)?, @r" + * a11c807 (HEAD -> gitbutler/workspace) GitButler Workspace Commit + * 93bbd52 (A) Merge 6a176285f918d0e4249373b102abe662d4eeeb29 into previous commit + |\ + | * 6a17628 (origin/A) remote change in A 2 + | * 715d7b0 remote change in A 1 + * | 8347946 local change in A 2 + * | 86838ae local change in A 1 + |/ + * 621b98a shared local/remote + * cfbcc20 (origin/main, main) init-integration + "); + + let branch_tip = repo.find_commit(repo.rev_parse_single("A")?.detach())?; + assert_eq!( + branch_tip.message_raw()?, + format!("Merge {remote_commit_2} into previous commit") + ); + + let merge_parents = branch_tip.parent_ids().collect::>(); + assert_eq!(merge_parents.len(), 2, "tip should be a merge commit"); + assert_eq!(merge_parents[1].detach(), remote_commit_2); + + let first_parent = repo.find_commit(merge_parents[0].detach())?; + assert_eq!(first_parent.message_raw()?, "local change in A 2\n"); + + Ok(()) +} + +#[test] +fn integrate_upstream_commits_into_local_with_two_merges_in_sequence() -> Result<()> { + let (_tmp, graph, mut repo, mut meta, _description) = + named_writable_scenario_with_description_and_graph( + "remote-diverged-with-workspace", + |meta| { + add_stack_with_segments(meta, 1, "A", StackState::InWorkspace, &[]); + }, + )?; + + let mut ws = graph.into_workspace()?; + + let local_commit_2 = repo.rev_parse_single("A")?.detach(); + let local_commit_1 = repo.rev_parse_single("A~1")?.detach(); + let remote_commit_1 = repo.rev_parse_single("origin/A~1")?.detach(); + let remote_commit_2 = repo.rev_parse_single("origin/A")?.detach(); + let local_and_remote = repo.rev_parse_single("A~2")?.detach(); + let integration = InteractiveIntegration { + merge_base: local_and_remote, + steps: vec![ + InteractiveIntegrationStep::Pick { + commit_id: local_commit_1, + }, + InteractiveIntegrationStep::Merge { + commit_id: remote_commit_1, + }, + InteractiveIntegrationStep::Pick { + commit_id: local_commit_2, + }, + InteractiveIntegrationStep::Merge { + commit_id: remote_commit_2, + }, + ], + }; + + configure_tracking_for_branch_a(&mut repo)?; + + let rebase = + integrate_branch_with_steps(r("refs/heads/A"), integration, &mut ws, &mut meta, &repo)?; + rebase.materialize()?; + + insta::assert_snapshot!(normalized_graph_snapshot(&repo)?, @r" + * d69c4de (HEAD -> gitbutler/workspace) GitButler Workspace Commit + * ab7f588 (A) Merge 6a176285f918d0e4249373b102abe662d4eeeb29 into previous commit + |\ + | * 6a17628 (origin/A) remote change in A 2 + * | fdc285b local change in A 2 + * | 0d584c5 Merge 715d7b0b14844b459ef031a7332283932e99a6a5 into previous commit + |\| + | * 715d7b0 remote change in A 1 + * | 86838ae local change in A 1 + |/ + * 621b98a shared local/remote + * cfbcc20 (origin/main, main) init-integration + "); + + let branch_tip = repo.find_commit(repo.rev_parse_single("A")?.detach())?; + assert_eq!( + branch_tip.message_raw()?, + format!("Merge {remote_commit_2} into previous commit") + ); + + let branch_tip_parents = branch_tip.parent_ids().collect::>(); + assert_eq!(branch_tip_parents.len(), 2, "tip should be a merge commit"); + assert_eq!( + branch_tip_parents[1].detach(), + remote_commit_2, + "second merge should keep the selected commit as second parent" + ); + + let first_parent = repo.find_commit(branch_tip_parents[0].detach())?; + assert_eq!(first_parent.message_raw()?, "local change in A 2\n"); + let first_parent_parents = first_parent.parent_ids().collect::>(); + assert_eq!( + first_parent_parents.len(), + 1, + "the picked local commit before the self-merge should remain linear" + ); + let remote_merge = repo.find_commit(first_parent_parents[0].detach())?; + assert_eq!( + remote_merge.message_raw()?, + format!("Merge {remote_commit_1} into previous commit"), + "the later merge should preserve the earlier remote merge in first-parent history" + ); + + Ok(()) +} + +#[test] +fn integrate_upstream_commits_into_local_with_remote_on_top() -> Result<()> { + let (_tmp, graph, mut repo, mut meta, _description) = + named_writable_scenario_with_description_and_graph( + "remote-diverged-with-workspace", + |meta| { + add_stack_with_segments(meta, 1, "A", StackState::InWorkspace, &[]); + }, + )?; + + insta::assert_snapshot!(normalized_graph_snapshot(&repo)?, @" + * a7060f8 (HEAD -> gitbutler/workspace) GitButler Workspace Commit + * 8347946 (A) local change in A 2 + * 86838ae local change in A 1 + | * 6a17628 (origin/A) remote change in A 2 + | * 715d7b0 remote change in A 1 + |/ + * 621b98a shared local/remote + * cfbcc20 (origin/main, main) init-integration + "); + + let mut ws = graph.into_workspace()?; + + let local_commit_2 = repo.rev_parse_single("A")?.detach(); + let local_commit_1 = repo.rev_parse_single("A~1")?.detach(); + let remote_commit_2 = repo.rev_parse_single("origin/A")?.detach(); + let remote_commit_1 = repo.rev_parse_single("origin/A~1")?.detach(); + let local_and_remote = repo.rev_parse_single("A~2")?.detach(); + let steps = vec![ + InteractiveIntegrationStep::Pick { + commit_id: local_commit_1, + }, + InteractiveIntegrationStep::Pick { + commit_id: local_commit_2, + }, + InteractiveIntegrationStep::Pick { + commit_id: remote_commit_1, + }, + InteractiveIntegrationStep::Pick { + commit_id: remote_commit_2, + }, + ]; + + let integration = InteractiveIntegration { + merge_base: local_and_remote, + steps, + }; + + configure_tracking_for_branch_a(&mut repo)?; + + let rebase = + integrate_branch_with_steps(r("refs/heads/A"), integration, &mut ws, &mut meta, &repo)?; + rebase.materialize()?; + + insta::assert_snapshot!(normalized_graph_snapshot(&repo)?, @" + * fb437fd (HEAD -> gitbutler/workspace) GitButler Workspace Commit + * 85ce57b (A) remote change in A 2 + * 01b7a91 remote change in A 1 + * 8347946 local change in A 2 + * 86838ae local change in A 1 + | * 6a17628 (origin/A) remote change in A 2 + | * 715d7b0 remote change in A 1 + |/ + * 621b98a shared local/remote + * cfbcc20 (origin/main, main) init-integration + "); + + Ok(()) +} + +#[test] +fn integrate_upstream_commits_into_local_with_remote_interlaced() -> Result<()> { + let (_tmp, graph, mut repo, mut meta, _description) = + named_writable_scenario_with_description_and_graph( + "remote-diverged-with-workspace", + |meta| { + add_stack_with_segments(meta, 1, "A", StackState::InWorkspace, &[]); + }, + )?; + + insta::assert_snapshot!(normalized_graph_snapshot(&repo)?, @" + * a7060f8 (HEAD -> gitbutler/workspace) GitButler Workspace Commit + * 8347946 (A) local change in A 2 + * 86838ae local change in A 1 + | * 6a17628 (origin/A) remote change in A 2 + | * 715d7b0 remote change in A 1 + |/ + * 621b98a shared local/remote + * cfbcc20 (origin/main, main) init-integration + "); + + let mut ws = graph.into_workspace()?; + + let local_commit_2 = repo.rev_parse_single("A")?.detach(); + let local_commit_1 = repo.rev_parse_single("A~1")?.detach(); + let remote_commit_2 = repo.rev_parse_single("origin/A")?.detach(); + let remote_commit_1 = repo.rev_parse_single("origin/A~1")?.detach(); + let local_and_remote = repo.rev_parse_single("A~2")?.detach(); + let steps = vec![ + InteractiveIntegrationStep::Pick { + commit_id: remote_commit_1, + }, + InteractiveIntegrationStep::Pick { + commit_id: local_commit_1, + }, + InteractiveIntegrationStep::Pick { + commit_id: remote_commit_2, + }, + InteractiveIntegrationStep::Pick { + commit_id: local_commit_2, + }, + ]; + + let integration = InteractiveIntegration { + merge_base: local_and_remote, + steps, + }; + + configure_tracking_for_branch_a(&mut repo)?; + + let rebase = + integrate_branch_with_steps(r("refs/heads/A"), integration, &mut ws, &mut meta, &repo)?; + rebase.materialize()?; + + insta::assert_snapshot!(normalized_graph_snapshot(&repo)?, @" + * 0ce7098 (HEAD -> gitbutler/workspace) GitButler Workspace Commit + * ad12639 (A) local change in A 2 + * a6a4994 remote change in A 2 + * 593d2d6 local change in A 1 + | * 6a17628 (origin/A) remote change in A 2 + |/ + * 715d7b0 remote change in A 1 + * 621b98a shared local/remote + * cfbcc20 (origin/main, main) init-integration + "); + + Ok(()) +} + +#[test] +fn integrate_upstream_commits_into_local_with_remote_one_local_one_remote() -> Result<()> { + let (_tmp, graph, mut repo, mut meta, _description) = + named_writable_scenario_with_description_and_graph( + "remote-diverged-with-workspace", + |meta| { + add_stack_with_segments(meta, 1, "A", StackState::InWorkspace, &[]); + }, + )?; + + insta::assert_snapshot!(normalized_graph_snapshot(&repo)?, @" + * a7060f8 (HEAD -> gitbutler/workspace) GitButler Workspace Commit + * 8347946 (A) local change in A 2 + * 86838ae local change in A 1 + | * 6a17628 (origin/A) remote change in A 2 + | * 715d7b0 remote change in A 1 + |/ + * 621b98a shared local/remote + * cfbcc20 (origin/main, main) init-integration + "); + + let mut ws = graph.into_workspace()?; + + let local_commit_1 = repo.rev_parse_single("A~1")?.detach(); + let remote_commit_2 = repo.rev_parse_single("origin/A")?.detach(); + let local_and_remote = repo.rev_parse_single("A~2")?.detach(); + let steps = vec![ + InteractiveIntegrationStep::Pick { + commit_id: local_commit_1, + }, + InteractiveIntegrationStep::Pick { + commit_id: remote_commit_2, + }, + ]; + + let integration = InteractiveIntegration { + merge_base: local_and_remote, + steps, + }; + + configure_tracking_for_branch_a(&mut repo)?; + + let rebase = + integrate_branch_with_steps(r("refs/heads/A"), integration, &mut ws, &mut meta, &repo)?; + rebase.materialize()?; + + insta::assert_snapshot!(normalized_graph_snapshot(&repo)?, @" + * ab8c010 (HEAD -> gitbutler/workspace) GitButler Workspace Commit + * 801c92f (A) remote change in A 2 + * 86838ae local change in A 1 + | * 6a17628 (origin/A) remote change in A 2 + | * 715d7b0 remote change in A 1 + |/ + * 621b98a shared local/remote + * cfbcc20 (origin/main, main) init-integration + "); + + Ok(()) +} + +#[test] +fn integrate_upstream_commits_into_local_with_remote_one_local_one_remote_and_extra_local_ref() +-> Result<()> { + let (_tmp, graph, mut repo, mut meta, _description) = + named_writable_scenario_with_description_and_graph( + "remote-diverged-with-workspace", + |meta| { + add_stack_with_segments(meta, 1, "A", StackState::InWorkspace, &[]); + }, + )?; + + add_local_ref_at_ref(&repo, "A-shadow", "A")?; + + insta::assert_snapshot!(normalized_graph_snapshot(&repo)?, @" + * a7060f8 (HEAD -> gitbutler/workspace) GitButler Workspace Commit + * 8347946 (A-shadow, A) local change in A 2 + * 86838ae local change in A 1 + | * 6a17628 (origin/A) remote change in A 2 + | * 715d7b0 remote change in A 1 + |/ + * 621b98a shared local/remote + * cfbcc20 (origin/main, main) init-integration + "); + + let mut ws = graph.into_workspace()?; + + let local_commit_1 = repo.rev_parse_single("A~1")?.detach(); + let remote_commit_2 = repo.rev_parse_single("origin/A")?.detach(); + let local_and_remote = repo.rev_parse_single("A~2")?.detach(); + let steps = vec![ + InteractiveIntegrationStep::Pick { + commit_id: local_commit_1, + }, + InteractiveIntegrationStep::Pick { + commit_id: remote_commit_2, + }, + ]; + + let integration = InteractiveIntegration { + merge_base: local_and_remote, + steps, + }; + + configure_tracking_for_branch_a(&mut repo)?; + + let rebase = + integrate_branch_with_steps(r("refs/heads/A"), integration, &mut ws, &mut meta, &repo)?; + rebase.materialize()?; + + insta::assert_snapshot!(normalized_graph_snapshot(&repo)?, @" + * 8347946 (A-shadow) local change in A 2 + | * ab8c010 (HEAD -> gitbutler/workspace) GitButler Workspace Commit + | * 801c92f (A) remote change in A 2 + |/ + * 86838ae local change in A 1 + | * 6a17628 (origin/A) remote change in A 2 + | * 715d7b0 remote change in A 1 + |/ + * 621b98a shared local/remote + * cfbcc20 (origin/main, main) init-integration + "); + + Ok(()) +} + +#[test] +fn integrate_upstream_commits_into_local_with_only_remote_commits() -> Result<()> { + let (_tmp, graph, mut repo, mut meta, _description) = + named_writable_scenario_with_description_and_graph( + "remote-diverged-with-workspace", + |meta| { + add_stack_with_segments(meta, 1, "A", StackState::InWorkspace, &[]); + }, + )?; + + insta::assert_snapshot!(normalized_graph_snapshot(&repo)?, @" + * a7060f8 (HEAD -> gitbutler/workspace) GitButler Workspace Commit + * 8347946 (A) local change in A 2 + * 86838ae local change in A 1 + | * 6a17628 (origin/A) remote change in A 2 + | * 715d7b0 remote change in A 1 + |/ + * 621b98a shared local/remote + * cfbcc20 (origin/main, main) init-integration + "); + + let mut ws = graph.into_workspace()?; + + let remote_commit_2 = repo.rev_parse_single("origin/A")?.detach(); + let remote_commit_1 = repo.rev_parse_single("origin/A~1")?.detach(); + let local_and_remote = repo.rev_parse_single("A~2")?.detach(); + let steps = vec![ + InteractiveIntegrationStep::Pick { + commit_id: remote_commit_1, + }, + InteractiveIntegrationStep::Pick { + commit_id: remote_commit_2, + }, + ]; + + let integration = InteractiveIntegration { + merge_base: local_and_remote, + steps, + }; + + configure_tracking_for_branch_a(&mut repo)?; + + let rebase = + integrate_branch_with_steps(r("refs/heads/A"), integration, &mut ws, &mut meta, &repo)?; + rebase.materialize()?; + + insta::assert_snapshot!(normalized_graph_snapshot(&repo)?, @" + * b3d4566 (HEAD -> gitbutler/workspace) GitButler Workspace Commit + * 6a17628 (origin/A, A) remote change in A 2 + * 715d7b0 remote change in A 1 + * 621b98a shared local/remote + * cfbcc20 (origin/main, main) init-integration + "); + + Ok(()) +} + +#[test] +fn integrate_upstream_commits_into_local_with_squashed_local_commits() -> Result<()> { + let (_tmp, graph, mut repo, mut meta, _description) = + named_writable_scenario_with_description_and_graph( + "remote-diverged-with-workspace", + |meta| { + add_stack_with_segments(meta, 1, "A", StackState::InWorkspace, &[]); + }, + )?; + + let mut ws = graph.into_workspace()?; + + let local_commit_2 = repo.rev_parse_single("A")?.detach(); + let local_commit_1 = repo.rev_parse_single("A~1")?.detach(); + let remote_commit_2 = repo.rev_parse_single("origin/A")?.detach(); + let remote_commit_1 = repo.rev_parse_single("origin/A~1")?.detach(); + let local_and_remote = repo.rev_parse_single("A~2")?.detach(); + let steps = vec![ + InteractiveIntegrationStep::Pick { + commit_id: remote_commit_1, + }, + InteractiveIntegrationStep::Pick { + commit_id: remote_commit_2, + }, + InteractiveIntegrationStep::Squash { + commits: vec![local_commit_1, local_commit_2], + message: Some("squashed local commits".to_string()), + }, + ]; + + let integration = InteractiveIntegration { + merge_base: local_and_remote, + steps, + }; + + configure_tracking_for_branch_a(&mut repo)?; + + let rebase = + integrate_branch_with_steps(r("refs/heads/A"), integration, &mut ws, &mut meta, &repo)?; + rebase.materialize()?; + + insta::assert_snapshot!(normalized_graph_snapshot(&repo)?, @" + * 5ef31c2 (HEAD -> gitbutler/workspace) GitButler Workspace Commit + * c297225 (A) squashed local commits + * 6a17628 (origin/A) remote change in A 2 + * 715d7b0 remote change in A 1 + * 621b98a shared local/remote + * cfbcc20 (origin/main, main) init-integration + "); + + let branch_tip = repo.find_commit(repo.rev_parse_single("A")?.detach())?; + assert_eq!(branch_tip.message_raw()?, "squashed local commits"); + + Ok(()) +} + +#[test] +fn integrate_upstream_commits_into_local_with_squashed_remote_commits() -> Result<()> { + let (_tmp, graph, mut repo, mut meta, _description) = + named_writable_scenario_with_description_and_graph( + "remote-diverged-with-workspace", + |meta| { + add_stack_with_segments(meta, 1, "A", StackState::InWorkspace, &[]); + }, + )?; + + let mut ws = graph.into_workspace()?; + + let local_commit_2 = repo.rev_parse_single("A")?.detach(); + let local_commit_1 = repo.rev_parse_single("A~1")?.detach(); + let remote_commit_2 = repo.rev_parse_single("origin/A")?.detach(); + let remote_commit_1 = repo.rev_parse_single("origin/A~1")?.detach(); + let local_and_remote = repo.rev_parse_single("A~2")?.detach(); + let steps = vec![ + InteractiveIntegrationStep::Pick { + commit_id: local_commit_1, + }, + InteractiveIntegrationStep::Pick { + commit_id: local_commit_2, + }, + InteractiveIntegrationStep::Squash { + commits: vec![remote_commit_1, remote_commit_2], + message: Some("squashed remote commits".to_string()), + }, + ]; + + let integration = InteractiveIntegration { + merge_base: local_and_remote, + steps, + }; + + configure_tracking_for_branch_a(&mut repo)?; + + let rebase = + integrate_branch_with_steps(r("refs/heads/A"), integration, &mut ws, &mut meta, &repo)?; + rebase.materialize()?; + + insta::assert_snapshot!(normalized_graph_snapshot(&repo)?, @" + * 3699070 (HEAD -> gitbutler/workspace) GitButler Workspace Commit + * 3838b79 (A) squashed remote commits + * 8347946 local change in A 2 + * 86838ae local change in A 1 + | * 6a17628 (origin/A) remote change in A 2 + | * 715d7b0 remote change in A 1 + |/ + * 621b98a shared local/remote + * cfbcc20 (origin/main, main) init-integration + "); + + let branch_tip = repo.find_commit(repo.rev_parse_single("A")?.detach())?; + assert_eq!(branch_tip.message_raw()?, "squashed remote commits"); + + Ok(()) +} + +#[test] +fn integrate_upstream_commits_into_local_with_squashed_remote_into_local_commits() -> Result<()> { + let (_tmp, graph, mut repo, mut meta, _description) = + named_writable_scenario_with_description_and_graph( + "remote-diverged-with-workspace", + |meta| { + add_stack_with_segments(meta, 1, "A", StackState::InWorkspace, &[]); + }, + )?; + + let mut ws = graph.into_workspace()?; + + let local_commit_2 = repo.rev_parse_single("A")?.detach(); + let local_commit_1 = repo.rev_parse_single("A~1")?.detach(); + let remote_commit_2 = repo.rev_parse_single("origin/A")?.detach(); + let remote_commit_1 = repo.rev_parse_single("origin/A~1")?.detach(); + let local_and_remote = repo.rev_parse_single("A~2")?.detach(); + let steps = vec![ + InteractiveIntegrationStep::Squash { + commits: vec![remote_commit_1, local_commit_1], + message: Some("squash commits 1".to_string()), + }, + InteractiveIntegrationStep::Squash { + commits: vec![remote_commit_2, local_commit_2], + message: Some("squash commits 2".to_string()), + }, + ]; + + let integration = InteractiveIntegration { + merge_base: local_and_remote, + steps, + }; + + configure_tracking_for_branch_a(&mut repo)?; + + let rebase = + integrate_branch_with_steps(r("refs/heads/A"), integration, &mut ws, &mut meta, &repo)?; + rebase.materialize()?; + + insta::assert_snapshot!(normalized_graph_snapshot(&repo)?, @" + * 8a9dd44 (HEAD -> gitbutler/workspace) GitButler Workspace Commit + * c6b942b (A) squash commits 2 + * a524f0a squash commits 1 + | * 6a17628 (origin/A) remote change in A 2 + | * 715d7b0 remote change in A 1 + |/ + * 621b98a shared local/remote + * cfbcc20 (origin/main, main) init-integration + "); + + Ok(()) +} + +#[test] +fn integrate_upstream_commits_into_local_with_squashed_remote_into_local_conflicts() -> Result<()> { + let (_tmp, graph, mut repo, mut meta, _description) = + named_writable_scenario_with_description_and_graph( + "remote-diverged-with-workspace-conflicting-squash", + |meta| { + add_stack_with_segments(meta, 1, "A", StackState::InWorkspace, &[]); + }, + )?; + + insta::assert_snapshot!(normalized_graph_snapshot(&repo)?, @" + * 8fd8fb6 (HEAD -> gitbutler/workspace) GitButler Workspace Commit + * 61c4a24 (A) local change in A 1 + | * f03fc2c (origin/A, new-origin) remote change in A 1 + |/ + * 2b73dee (origin/main, main) init-integration + "); + + let mut ws = graph.into_workspace()?; + + let local_commit_1 = repo.rev_parse_single("A")?.detach(); + let remote_commit_1 = repo.rev_parse_single("origin/A")?.detach(); + let local_and_remote = repo.rev_parse_single("main")?.detach(); + let steps = vec![InteractiveIntegrationStep::Squash { + commits: vec![remote_commit_1, local_commit_1], + message: Some("squashed conflicting commits".to_string()), + }]; + + let integration = InteractiveIntegration { + merge_base: local_and_remote, + steps, + }; + + configure_tracking_for_branch_a(&mut repo)?; + + let rebase = + integrate_branch_with_steps(r("refs/heads/A"), integration, &mut ws, &mut meta, &repo)?; + rebase.materialize()?; + + insta::assert_snapshot!(normalized_graph_snapshot(&repo)?, @" + * f03fc2c (origin/A, new-origin) remote change in A 1 + | * 1b052b4 (HEAD -> gitbutler/workspace) GitButler Workspace Commit + | * 20ebfcc (A) [conflict] squashed conflicting commits + |/ + * 2b73dee (origin/main, main) init-integration + "); + + let branch_tip = repo.find_commit(repo.rev_parse_single("A")?.detach())?; + assert!(Commit::from_id(branch_tip.id.attach(&repo))?.is_conflicted()); + insta::assert_snapshot!(branch_tip.message_raw()?, @r#" + [conflict] squashed conflicting commits + + GitButler-Conflict: This is a GitButler-managed conflicted commit. Files are auto-resolved + using the "ours" side. The commit tree contains additional directories: + .conflict-side-0 — our tree + .conflict-side-1 — their tree + .conflict-base-0 — the merge base tree + .auto-resolution — the auto-resolved tree + .conflict-files — metadata about conflicted files + To manually resolve, check out this commit, remove the directories + listed above, resolve the conflicts, and amend the commit. + "#); + insta::assert_snapshot!(visualize_tree(branch_tip.tree_id()?), @r#" + 450d676 + ├── .auto-resolution:276d2b4 + │ └── shared.txt:100644:4083037 "local\n" + ├── .conflict-base-0:48e531d + │ └── shared.txt:100644:df967b9 "base\n" + ├── .conflict-files:100644:d0a3da4 "ancestorEntries = [\"shared.txt\"]\nourEntries = [\"shared.txt\"]\ntheirEntries = [\"shared.txt\"]\n" + ├── .conflict-side-0:276d2b4 + │ └── shared.txt:100644:4083037 "local\n" + ├── .conflict-side-1:cd74779 + │ └── shared.txt:100644:9c998f7 "remote\n" + └── shared.txt:100644:4083037 "local\n" + "#); + + Ok(()) +} + +#[test] +fn integrate_upstream_precomputes_squash_before_later_step_graph_rewiring() -> Result<()> { + let (_tmp, graph, mut repo, mut meta, _description) = + named_writable_scenario_with_description_and_graph( + "remote-diverged-with-workspace", + |meta| { + add_stack_with_segments(meta, 1, "A", StackState::InWorkspace, &[]); + }, + )?; + + let mut ws = graph.into_workspace()?; + + let local_commit_2 = repo.rev_parse_single("A")?.detach(); + let local_commit_1 = repo.rev_parse_single("A~1")?.detach(); + let local_and_remote = repo.rev_parse_single("A~2")?.detach(); + let expected_squash_tree = repo.find_commit(local_commit_2)?.tree_id()?.detach(); + let steps = vec![ + InteractiveIntegrationStep::Squash { + commits: vec![local_commit_1, local_commit_2], + message: Some("squashed local commits".to_string()), + }, + InteractiveIntegrationStep::Pick { + commit_id: local_commit_2, + }, + ]; + + let integration = InteractiveIntegration { + merge_base: local_and_remote, + steps, + }; + + configure_tracking_for_branch_a(&mut repo)?; + + let rebase = + integrate_branch_with_steps(r("refs/heads/A"), integration, &mut ws, &mut meta, &repo)?; + rebase.materialize()?; + + let mut current_commit_id = repo.rev_parse_single("A")?.detach(); + let squashed_commit_id = loop { + let commit = repo.find_commit(current_commit_id)?; + if commit.message_raw()? == "squashed local commits" { + break current_commit_id; + } + let Some(parent_id) = commit.parent_ids().next() else { + panic!("prepared squash should still be materialized into first-parent history"); + }; + current_commit_id = parent_id.detach(); + }; + let squashed_commit = repo.find_commit(squashed_commit_id)?; + assert_eq!( + squashed_commit.tree_id()?.detach(), + expected_squash_tree, + "squash tree should be computed from the original repo topology before later graph rewiring", + ); + + Ok(()) +} + +#[test] +fn initial_steps_remote_diverged_with_workspace_are_in_application_order() -> Result<()> { + let (_tmp, _graph, mut repo, mut _meta, _description) = + named_writable_scenario_with_description_and_graph( + "remote-diverged-with-workspace", + |meta| { + add_stack_with_segments(meta, 1, "A", StackState::InWorkspace, &[]); + }, + )?; + configure_tracking_for_branch_a(&mut repo)?; + + insta::assert_snapshot!(normalized_graph_snapshot(&repo)?, @" + * a7060f8 (HEAD -> gitbutler/workspace) GitButler Workspace Commit + * 8347946 (A) local change in A 2 + * 86838ae local change in A 1 + | * 6a17628 (origin/A) remote change in A 2 + | * 715d7b0 remote change in A 1 + |/ + * 621b98a shared local/remote + * cfbcc20 (origin/main, main) init-integration + "); + + let local_commit_2 = repo.rev_parse_single("A")?.detach(); + let local_commit_1 = repo.rev_parse_single("A~1")?.detach(); + let remote_commit_2 = repo.rev_parse_single("origin/A")?.detach(); + let remote_commit_1 = repo.rev_parse_single("origin/A~1")?.detach(); + let merge_base = repo.rev_parse_single("A~2")?.detach(); + configure_tracking_for_branch_a(&mut repo)?; + + let initial = get_initial_integration_steps_for_branch(r("refs/heads/A"), &repo)?; + + insta::assert_snapshot!( + labeled_integration_snapshot( + &initial.integration, + &[ + (merge_base, "merge-base"), + (local_commit_2, "local-commit-2"), + (local_commit_1, "local-commit-1"), + (remote_commit_2, "remote-commit-2"), + (remote_commit_1, "remote-commit-1"), + ] + ), + @" + merge-base merge-base + pick remote-commit-1 + pick remote-commit-2 + pick local-commit-1 + pick local-commit-2 + " + ); + + insta::assert_snapshot!( + labeled_divergence_snapshot( + &initial, + &[ + (merge_base, "merge-base"), + (local_commit_2, "local-commit-2"), + (local_commit_1, "local-commit-1"), + (remote_commit_2, "remote-commit-2"), + (remote_commit_1, "remote-commit-1"), + ] + ), + @" + * local-commit-2 (A) local change in A 2 + * local-commit-1 local change in A 1 + | * remote-commit-2 (origin/A) remote change in A 2 + | * remote-commit-1 remote change in A 1 + |/ + * merge-base shared local/remote + " + ); + + assert_eq!( + pick_step_ids(&initial.integration.steps), + vec![ + remote_commit_1, + remote_commit_2, + local_commit_1, + local_commit_2 + ], + "expected parent-to-child application order for the integrated branch" + ); + + Ok(()) +} + +fn configure_tracking_for_branch_a(repo: &mut gix::Repository) -> Result<()> { + let mut cfg = repo.config_snapshot_mut(); + cfg.set_raw_value( + "remote.origin.fetch", + gix::bstr::BStr::new(b"+refs/heads/*:refs/remotes/origin/*"), + )?; + cfg.set_raw_value("remote.origin.url", gix::bstr::BStr::new(b"."))?; + cfg.set_raw_value("branch.A.remote", gix::bstr::BStr::new(b"origin"))?; + cfg.set_raw_value("branch.A.merge", gix::bstr::BStr::new(b"refs/heads/A"))?; + Ok(()) +} + +fn pick_step_ids(steps: &[InteractiveIntegrationStep]) -> Vec { + steps + .iter() + .map(|step| match step { + InteractiveIntegrationStep::Pick { commit_id, .. } + | InteractiveIntegrationStep::Merge { commit_id, .. } => *commit_id, + InteractiveIntegrationStep::Squash { commits, .. } => { + *commits.last().expect("squash step should contain commits") + } + }) + .collect() +} + +fn add_local_ref_at_ref(repo: &gix::Repository, new_branch: &str, target: &str) -> Result<()> { + let workdir = repo.workdir().expect("writable scenarios are non-bare"); + let target_id = repo.rev_parse_single(target)?.detach(); + + let status = std::process::Command::new("git") + .arg("-C") + .arg(workdir) + .arg("update-ref") + .arg(format!("refs/heads/{new_branch}")) + .arg(target_id.to_string()) + .status()?; + + if !status.success() { + bail!("failed to create local reference refs/heads/{new_branch}"); + } + + Ok(()) +} + +fn r(name: &str) -> &gix::refs::FullNameRef { + name.try_into().expect("statically known valid ref-name") +} diff --git a/crates/but-workspace/tests/workspace/branch/mod.rs b/crates/but-workspace/tests/workspace/branch/mod.rs index abb4428495a..f7cb8a0d8f9 100644 --- a/crates/but-workspace/tests/workspace/branch/mod.rs +++ b/crates/but-workspace/tests/workspace/branch/mod.rs @@ -1,6 +1,7 @@ /// Various journeys with apply, unapply and commit operations. mod apply_unapply_commit_uncommit; mod create_reference; +mod integrate_branch_upstream; mod move_branch; mod remove_reference; mod tear_off_branch; From 901dc80c7a7263e45ac093bff1b5c498da1dd4c4 Mon Sep 17 00:00:00 2001 From: estib Date: Tue, 5 May 2026 15:13:11 +0200 Subject: [PATCH 2/3] move commit: Factor out the disconnection params We can reuse the logic behind caculating the disconnect params. --- .../but-workspace/src/branch/move_branch.rs | 203 ++--------------- .../src/branch/segment_disconnect.rs | 205 ++++++++++++++++++ .../but-workspace/src/commit/move_commit.rs | 54 +---- 3 files changed, 233 insertions(+), 229 deletions(-) create mode 100644 crates/but-workspace/src/branch/segment_disconnect.rs diff --git a/crates/but-workspace/src/branch/move_branch.rs b/crates/but-workspace/src/branch/move_branch.rs index 7eeffd71d43..ac783c132c9 100644 --- a/crates/but-workspace/src/branch/move_branch.rs +++ b/crates/but-workspace/src/branch/move_branch.rs @@ -1,10 +1,5 @@ -use anyhow::{Context, bail}; use but_core::RefMetadata; -use but_graph::workspace::{Stack, StackSegment}; -use but_rebase::graph_rebase::{ - Editor, Selector, SuccessfulRebase, - mutate::{SegmentDelimiter, SelectorSet, SomeSelectors}, -}; +use but_rebase::graph_rebase::SuccessfulRebase; /// Outcome of moving branches between or out of stacks. /// @@ -20,12 +15,11 @@ pub struct Outcome<'ws, 'meta, M: RefMetadata> { pub(super) mod function { + use crate::branch::segment_disconnect::{DisconnectParameters, get_disconnect_parameters}; use but_core::RefMetadata; use but_core::ref_metadata::StackId; use but_rebase::graph_rebase::mutate::SomeSelectors; - use super::get_disconnect_parameters; - use super::Outcome; use anyhow::Context; use anyhow::bail; @@ -105,14 +99,17 @@ pub(super) mod function { .select_reference(lower_bound_ref) .context("Failed to find target reference in graph.")?; - let (subject_delimiter, children_to_disconnect, parents_to_disconnect) = - get_disconnect_parameters( - &editor, - &workspace, - source_stack, - subject_segment, - workspace_head, - )?; + let DisconnectParameters { + delimiter: subject_delimiter, + children_to_disconnect, + parents_to_disconnect, + } = get_disconnect_parameters( + &editor, + &workspace, + source_stack, + subject_segment, + workspace_head, + )?; editor.disconnect_segment_from( subject_delimiter.clone(), @@ -202,14 +199,17 @@ pub(super) mod function { .select_reference(target_segment_ref_name) .context("Failed to find target reference in graph.")?; - let (subject_delimiter, children_to_disconnect, parents_to_disconnect) = - get_disconnect_parameters( - &editor, - &workspace, - &source_stack, - &subject_segment, - workspace_head, - )?; + let DisconnectParameters { + delimiter: subject_delimiter, + children_to_disconnect, + parents_to_disconnect, + } = get_disconnect_parameters( + &editor, + &workspace, + &source_stack, + &subject_segment, + workspace_head, + )?; let skip_reconnect_step = source_stack.segments.len() == 1; editor.disconnect_segment_from( @@ -311,158 +311,3 @@ pub(super) mod function { Ok((own_context(source), own_context(destination))) } } - -/// Get the right disconnect parameters for the given subject segment and source stack. -/// -/// This function determines which are the right parents and children to disconnect, -/// as well as the right segment delimiter to move. -fn get_disconnect_parameters<'ws, 'meta, M: RefMetadata>( - editor: &Editor<'ws, 'meta, M>, - workspace: &but_graph::Workspace, - source_stack: &Stack, - subject_segment: &StackSegment, - workspace_head: gix::ObjectId, -) -> anyhow::Result<( - SegmentDelimiter, - SelectorSet, - SelectorSet, -)> { - let index_of_segment = source_stack - .segments - .iter() - .position(|segment| segment.id == subject_segment.id) - .context("BUG: Unable to find subject segment on source stack.")?; - - let subject_segment_ref_name = subject_segment - .ref_name() - .context("Subject segment doesn't have a ref name.")?; - let delimiter_child = editor - .select_reference(subject_segment_ref_name) - .context("Failed to find subject reference in graph.")?; - let delimiter_parent = match subject_segment.commits.last() { - Some(last_commit) => editor - .select_commit(last_commit.id) - .context("Failed to find last commit in subject segment in graph.")?, - None => { - // Subject segment is empty, move only the reference - delimiter_child - } - }; - - // The delimiter for the segment we want to move, is the reference selector - // as the child, and the last commit inside the branch as the parent. - // If the branch is empty, we take the reference selector as the parent as well. - let delimiter = SegmentDelimiter { - child: delimiter_child, - parent: delimiter_parent, - }; - - // The parent segment in the stack if any. - // This will be `None` if the branch we want to move is at the bottom of the stack. - let stack_base_segment = subject_segment.base_segment_id.and_then(|base_segment_id| { - source_stack - .segments - .iter() - .find(|segment| segment.id == base_segment_id) - }); - - // The parent segment in the graph. - // If the `stack_base_segment` is `None` but there's a `base_segment_id` defined, it means we'll find it in the - // graph data, and it's probably the target branch, which is not included in the workspace. - let graph_base_segment = subject_segment - .base_segment_id - .map(|segment_idx| &workspace.graph[segment_idx]); - - let parents_to_disconnect = if let Some(stack_base_segment) = stack_base_segment { - // Base segment is part of the source stack. - select_segment(editor, stack_base_segment)? - } else if let Some(graph_base_segment) = graph_base_segment { - // Base segment is outside of workspace (probably target branch). - select_segment(editor, graph_base_segment)? - } else if subject_segment.base_segment_id.is_some() { - // Base segment could not be found, but there is an ID defined. Error out. - bail!( - "Failed to find the base segment of the subject we want to move, even if it seems to be defined" - ); - } else { - // Nothing found. Remove all parents. - SelectorSet::All - }; - - if index_of_segment == 0 { - // This is the top-most segment in the stack, so the parent is the workspace commit. - let workspace_head_selector = editor - .select_commit(workspace_head) - .context("Failed to find workspace head in graph.")?; - let selectors = SomeSelectors::new(vec![workspace_head_selector])?; - let children_to_disconnect = SelectorSet::Some(selectors); - - return Ok((delimiter, children_to_disconnect, parents_to_disconnect)); - } - - // Segment on top of the subject segment in the stack. - let child_segment = source_stack.segments.get(index_of_segment - 1).context( - "BUG: Unable to find child segment of subject segment but expected it to exist.", - )?; - - // If branch stacked on top of the branch we want to move is empty, we only need to disconnect - // the reference from it. - // Otherwise, disconnect the last commit on the segment. - let child_selector = match child_segment.commits.last() { - Some(last_commit) => editor - .select_commit(last_commit.id) - .context("Failed to find last commit of child segment in graph."), - None => { - // The segment on top of the subject segment is empty. Select the reference. - let child_segment_ref_name = child_segment - .ref_name() - .context("Child segment doesn't have a ref name.")?; - editor - .select_reference(child_segment_ref_name) - .context("Failed to find child segment reference in graph.") - } - }?; - let selectors = SomeSelectors::new(vec![child_selector])?; - let children_to_disconnect = SelectorSet::Some(selectors); - - Ok((delimiter, children_to_disconnect, parents_to_disconnect)) -} - -/// Select a segment by its ref name if available, otherwise fall back to its tip commit. -fn select_segment( - editor: &Editor<'_, '_, M>, - segment: &impl SegmentLike, -) -> anyhow::Result { - let selector = if let Some(ref_name) = segment.ref_name() { - editor.select_reference(ref_name)? - } else if let Some(tip) = segment.tip() { - editor.select_commit(tip)? - } else { - bail!("Base segment has neither a ref name nor any commits."); - }; - let selectors = SomeSelectors::new(vec![selector])?; - Ok(SelectorSet::Some(selectors)) -} - -trait SegmentLike { - fn ref_name(&self) -> Option<&gix::refs::FullNameRef>; - fn tip(&self) -> Option; -} - -impl SegmentLike for StackSegment { - fn ref_name(&self) -> Option<&gix::refs::FullNameRef> { - self.ref_name() - } - fn tip(&self) -> Option { - self.tip() - } -} - -impl SegmentLike for but_graph::Segment { - fn ref_name(&self) -> Option<&gix::refs::FullNameRef> { - self.ref_name() - } - fn tip(&self) -> Option { - self.tip() - } -} diff --git a/crates/but-workspace/src/branch/segment_disconnect.rs b/crates/but-workspace/src/branch/segment_disconnect.rs new file mode 100644 index 00000000000..72faa4ca54a --- /dev/null +++ b/crates/but-workspace/src/branch/segment_disconnect.rs @@ -0,0 +1,205 @@ +use anyhow::{Context, bail}; +use but_core::RefMetadata; +use but_graph::workspace::{Stack, StackSegment}; +use but_rebase::graph_rebase::{ + Editor, LookupStep, Selector, Step, + mutate::{SegmentDelimiter, SelectorSet, SomeSelectors}, +}; + +pub(crate) struct DisconnectParameters { + pub(crate) delimiter: SegmentDelimiter, + pub(crate) children_to_disconnect: SelectorSet, + pub(crate) parents_to_disconnect: SelectorSet, +} + +/// Get the right disconnect parameters for the given subject segment and source stack. +/// +/// This function determines which are the right parents and children to disconnect, +/// as well as the right segment delimiter to move. +pub(crate) fn get_disconnect_parameters<'ws, 'meta, M: RefMetadata>( + editor: &Editor<'ws, 'meta, M>, + workspace: &but_graph::Workspace, + source_stack: &Stack, + subject_segment: &StackSegment, + workspace_head: gix::ObjectId, +) -> anyhow::Result { + let index_of_segment = source_stack + .segments + .iter() + .position(|segment| segment.id == subject_segment.id) + .context("BUG: Unable to find subject segment on source stack.")?; + + let subject_segment_ref_name = subject_segment + .ref_name() + .context("Subject segment doesn't have a ref name.")?; + let delimiter_child = editor + .select_reference(subject_segment_ref_name) + .context("Failed to find subject reference in graph.")?; + let delimiter_parent = match subject_segment.commits.last() { + Some(last_commit) => editor + .select_commit(last_commit.id) + .context("Failed to find last commit in subject segment in graph.")?, + None => { + // Subject segment is empty, move only the reference + delimiter_child + } + }; + + // The delimiter for the segment we want to move, is the reference selector + // as the child, and the last commit inside the branch as the parent. + // If the branch is empty, we take the reference selector as the parent as well. + let delimiter = SegmentDelimiter { + child: delimiter_child, + parent: delimiter_parent, + }; + + // The parent segment in the stack if any. + // This will be `None` if the branch we want to move is at the bottom of the stack. + let stack_base_segment = subject_segment.base_segment_id.and_then(|base_segment_id| { + source_stack + .segments + .iter() + .find(|segment| segment.id == base_segment_id) + }); + + // The parent segment in the graph. + // If the `stack_base_segment` is `None` but there's a `base_segment_id` defined, it means we'll find it in the + // graph data, and it's probably the target branch, which is not included in the workspace. + let graph_base_segment = subject_segment + .base_segment_id + .map(|segment_idx| &workspace.graph[segment_idx]); + + let parents_to_disconnect = if let Some(stack_base_segment) = stack_base_segment { + // Base segment is part of the source stack. + select_segment(editor, stack_base_segment)? + } else if let Some(graph_base_segment) = graph_base_segment { + // Base segment is outside of workspace (probably target branch). + select_segment(editor, graph_base_segment)? + } else if subject_segment.base_segment_id.is_some() { + // Base segment could not be found, but there is an ID defined. Error out. + bail!( + "Failed to find the base segment of the subject we want to move, even if it seems to be defined" + ); + } else { + // Nothing found. Remove all parents. + SelectorSet::All + }; + + if index_of_segment == 0 { + // This is the top-most segment in the stack, so the parent is the workspace commit. + let workspace_head_selector = editor + .select_commit(workspace_head) + .context("Failed to find workspace head in graph.")?; + let selectors = SomeSelectors::new(vec![workspace_head_selector])?; + let children_to_disconnect = SelectorSet::Some(selectors); + + return Ok(DisconnectParameters { + delimiter, + children_to_disconnect, + parents_to_disconnect, + }); + } + + // Segment on top of the subject segment in the stack. + let child_segment = source_stack.segments.get(index_of_segment - 1).context( + "BUG: Unable to find child segment of subject segment but expected it to exist.", + )?; + + // If branch stacked on top of the branch we want to move is empty, we only need to disconnect + // the reference from it. + // Otherwise, disconnect the last commit on the segment. + let child_selector = match child_segment.commits.last() { + Some(last_commit) => editor + .select_commit(last_commit.id) + .context("Failed to find last commit of child segment in graph."), + None => { + // The segment on top of the subject segment is empty. Select the reference. + let child_segment_ref_name = child_segment + .ref_name() + .context("Child segment doesn't have a ref name.")?; + editor + .select_reference(child_segment_ref_name) + .context("Failed to find child segment reference in graph.") + } + }?; + let selectors = SomeSelectors::new(vec![child_selector])?; + let children_to_disconnect = SelectorSet::Some(selectors); + + Ok(DisconnectParameters { + delimiter, + children_to_disconnect, + parents_to_disconnect, + }) +} + +/// Determine which parent to disconnect from the subject commit. +/// +/// Preference rules: +/// - Prefer a `Pick` parent first, which aligns with linear first-parent ancestry. +/// - If no commit parent edge is found, fall back to a `Reference` parent. +/// +/// If no explicit parent candidate exists, return `SelectorSet::All` as a safe fallback. +pub(crate) fn determine_parent_selector<'ws, 'meta, M: RefMetadata>( + editor: &Editor<'ws, 'meta, M>, + subject_commit_selector: Selector, +) -> anyhow::Result { + let mut parents = editor.direct_parents(subject_commit_selector)?; + parents.sort_by_key(|(_, order)| *order); + + let preferred = parents + .iter() + .find(|(selector, _)| matches!(editor.lookup_step(*selector), Ok(Step::Pick(_)))) + .or_else(|| { + parents.iter().find(|(selector, _)| { + matches!(editor.lookup_step(*selector), Ok(Step::Reference { .. })) + }) + }) + .map(|(selector, _)| *selector); + + match preferred { + Some(selector) => { + let selectors = SomeSelectors::new(vec![selector])?; + Ok(SelectorSet::Some(selectors)) + } + None => Ok(SelectorSet::All), + } +} + +/// Select a segment by its ref name if available, otherwise fall back to its tip commit. +fn select_segment( + editor: &Editor<'_, '_, M>, + segment: &impl SegmentLike, +) -> anyhow::Result { + let selector = if let Some(ref_name) = segment.ref_name() { + editor.select_reference(ref_name)? + } else if let Some(tip) = segment.tip() { + editor.select_commit(tip)? + } else { + bail!("Base segment has neither a ref name nor any commits."); + }; + let selectors = SomeSelectors::new(vec![selector])?; + Ok(SelectorSet::Some(selectors)) +} + +trait SegmentLike { + fn ref_name(&self) -> Option<&gix::refs::FullNameRef>; + fn tip(&self) -> Option; +} + +impl SegmentLike for StackSegment { + fn ref_name(&self) -> Option<&gix::refs::FullNameRef> { + self.ref_name() + } + fn tip(&self) -> Option { + self.tip() + } +} + +impl SegmentLike for but_graph::Segment { + fn ref_name(&self) -> Option<&gix::refs::FullNameRef> { + self.ref_name() + } + fn tip(&self) -> Option { + self.tip() + } +} diff --git a/crates/but-workspace/src/commit/move_commit.rs b/crates/but-workspace/src/commit/move_commit.rs index e2bbbad6519..b2e04b23410 100644 --- a/crates/but-workspace/src/commit/move_commit.rs +++ b/crates/but-workspace/src/commit/move_commit.rs @@ -2,10 +2,12 @@ use but_core::RefMetadata; use but_rebase::graph_rebase::{ - Editor, LookupStep, SuccessfulRebase, ToCommitSelector, ToSelector, - mutate::{InsertSide, SegmentDelimiter, SelectorSet, SomeSelectors}, + Editor, SuccessfulRebase, ToCommitSelector, ToSelector, + mutate::{InsertSide, SegmentDelimiter, SelectorSet}, }; +use crate::branch::determine_parent_selector; + /// Move a commit. /// /// `editor` is assumed to be aligned with the graph being mutated. @@ -70,51 +72,3 @@ pub fn move_commit_no_rebase<'ws, 'meta, M: RefMetadata>( editor.insert_segment(anchor, commit_delimiter, side)?; Ok(editor) } - -/// Determine which parent to disconnect from the subject commit. -/// -/// Preference rules: -/// - Prefer a `Pick` parent first. This matches first-parent linear history -/// semantics, which is the primary ancestry edge we want to detach when -/// moving a commit within or across stacks. -/// - If there is no commit parent edge, fall back to a `Reference` parent. -/// -/// If no explicit parent candidate is available (e.g. truncated history or -/// root-like scenarios), we use `SelectorSet::All` as a safe fallback, -/// matching prior behavior for these edge cases. -fn determine_parent_selector<'ws, 'meta, M: RefMetadata>( - editor: &Editor<'ws, 'meta, M>, - subject_commit_selector: but_rebase::graph_rebase::Selector, -) -> Result { - let mut parents = editor.direct_parents(subject_commit_selector)?; - parents.sort_by_key(|(_, order)| *order); - - // Prefer parent commit first (linear segment), then reference fallback. - let preferred = parents - .iter() - .find(|(selector, _)| { - matches!( - editor.lookup_step(*selector), - Ok(but_rebase::graph_rebase::Step::Pick(_)) - ) - }) - .or_else(|| { - parents.iter().find(|(selector, _)| { - matches!( - editor.lookup_step(*selector), - Ok(but_rebase::graph_rebase::Step::Reference { .. }) - ) - }) - }) - .map(|(selector, _)| *selector); - - let parent_to_disconnect = match preferred { - Some(selector) => { - let selectors = SomeSelectors::new(vec![selector])?; - SelectorSet::Some(selectors) - } - // No explicit parent available (e.g. root commit/truncated history). - None => SelectorSet::All, - }; - Ok(parent_to_disconnect) -} From 68e88f2d187c2d4c2fd6533ce26f79aa8efb62d3 Mon Sep 17 00:00:00 2001 From: estib Date: Fri, 15 May 2026 16:00:57 +0200 Subject: [PATCH 3/3] but-api: Integrate branch --- crates/but-api/src/branch.rs | 312 +++++++++++++++++- crates/but-api/src/json.rs | 3 + crates/but-server/src/lib.rs | 8 + .../gitbutler-tauri/permissions/default.toml | 2 + crates/gitbutler-tauri/src/main.rs | 4 +- packages/but-sdk/src/generated/index.d.ts | 84 +++++ packages/but-sdk/src/generated/index.js | 4 +- 7 files changed, 409 insertions(+), 8 deletions(-) diff --git a/crates/but-api/src/branch.rs b/crates/but-api/src/branch.rs index 4ea779086aa..50e54836852 100644 --- a/crates/but-api/src/branch.rs +++ b/crates/but-api/src/branch.rs @@ -7,8 +7,9 @@ use but_ctx::Context; use but_oplog::legacy::{OperationKind, SnapshotDetails, Trailer}; use but_rebase::graph_rebase::{Editor, SuccessfulRebase}; use but_workspace::branch::{ - OnWorkspaceMergeConflict, + InitialBranchIntegration, OnWorkspaceMergeConflict, apply::{WorkspaceMerge, WorkspaceReferenceNaming}, + integrate_branch_upstream::InteractiveIntegration, }; use tracing::instrument; @@ -18,11 +19,20 @@ pub struct MoveBranchResult { pub workspace: WorkspaceState, } +/// Outcome after integrating a branch with an interactive integration plan. +pub struct IntegrateBranchResult { + /// Workspace state after applying or previewing the integration. + pub workspace: WorkspaceState, +} + /// JSON transport types for branch APIs. pub mod json { - use serde::Serialize; + use serde::{Deserialize, Serialize}; - use crate::branch::MoveBranchResult as InternalMoveBranchResult; + use crate::branch::{ + IntegrateBranchResult as InternalIntegrateBranchResult, + MoveBranchResult as InternalMoveBranchResult, + }; /// JSON sibling of [`but_workspace::branch::apply::Outcome`]. #[derive(Debug, Serialize)] @@ -78,6 +88,229 @@ pub mod json { }) } } + + #[derive(Debug, Serialize)] + #[cfg_attr(feature = "export-schema", derive(schemars::JsonSchema))] + #[serde(rename_all = "camelCase")] + /// JSON transport type for integrating a branch. + pub struct IntegrateBranchResult { + /// Workspace state after applying or previewing the integration. + pub workspace: crate::json::WorkspaceState, + } + #[cfg(feature = "export-schema")] + but_schemars::register_sdk_type!(IntegrateBranchResult); + + impl TryFrom for IntegrateBranchResult { + type Error = anyhow::Error; + + fn try_from(value: InternalIntegrateBranchResult) -> Result { + Ok(Self { + workspace: value.workspace.try_into()?, + }) + } + } + + /// JSON transport type for a divergence commit row. + #[derive(Debug, Serialize)] + #[cfg_attr(feature = "export-schema", derive(schemars::JsonSchema))] + #[serde(rename_all = "camelCase")] + pub struct IntegrationDivergenceCommit { + /// The commit shown in the graph row. + pub id: crate::json::HexHashString, + /// The first-line subject shown for the commit. + pub subject: String, + /// Human-facing ref labels rendered inline on the commit row. + pub refs: Vec, + } + #[cfg(feature = "export-schema")] + but_schemars::register_sdk_type!(IntegrationDivergenceCommit); + + impl From for IntegrationDivergenceCommit { + fn from(value: but_workspace::branch::IntegrationDivergenceCommit) -> Self { + let but_workspace::branch::IntegrationDivergenceCommit { id, subject, refs } = value; + Self { + id: id.into(), + subject, + refs, + } + } + } + + /// JSON transport type for current branch/upstream divergence information. + #[derive(Debug, Serialize)] + #[cfg_attr(feature = "export-schema", derive(schemars::JsonSchema))] + #[serde(rename_all = "camelCase")] + pub struct IntegrationDivergenceDisplay { + /// The local branch being integrated. + pub branch_ref_name: crate::json::FullRefName, + /// The upstream branch this local branch integrates with. + pub upstream_ref_name: crate::json::FullRefName, + /// Commits only reachable from the local branch tip down to the shared section. + pub local_only: Vec, + /// Commits only reachable from the upstream branch tip down to the shared section. + pub upstream_only: Vec, + /// Commits shared or matched between local and upstream above the merge-base. + pub matched: Vec, + /// The merge-base row shown once at the bottom. + pub merge_base: IntegrationDivergenceCommit, + } + #[cfg(feature = "export-schema")] + but_schemars::register_sdk_type!(IntegrationDivergenceDisplay); + + impl From for IntegrationDivergenceDisplay { + fn from(value: but_workspace::branch::IntegrationDivergenceDisplay) -> Self { + let but_workspace::branch::IntegrationDivergenceDisplay { + branch_ref_name, + upstream_ref_name, + local_only, + upstream_only, + matched, + merge_base, + } = value; + Self { + branch_ref_name: branch_ref_name.into(), + upstream_ref_name: upstream_ref_name.into(), + local_only: local_only.into_iter().map(Into::into).collect(), + upstream_only: upstream_only.into_iter().map(Into::into).collect(), + matched: matched.into_iter().map(Into::into).collect(), + merge_base: merge_base.into(), + } + } + } + + /// JSON transport type for a branch integration step. + #[derive(Debug, Clone, Serialize, Deserialize)] + #[cfg_attr(feature = "export-schema", derive(schemars::JsonSchema))] + #[serde(rename_all = "camelCase", tag = "kind")] + pub enum InteractiveIntegrationStep { + /// Pick a commit, keeping it in the branch. + Pick { + /// The local commit to keep in the rewritten branch. + commit_id: crate::json::HexHashString, + }, + /// Squash multiple commits into one. + Squash { + /// The ordered commits to squash together. + commits: Vec, + /// Optional replacement message for the squash commit. + message: Option, + }, + /// Merge a commit into the previous one. + Merge { + /// The commit whose change range should be merged. + commit_id: crate::json::HexHashString, + }, + } + #[cfg(feature = "export-schema")] + but_schemars::register_sdk_type!(InteractiveIntegrationStep); + + impl TryFrom + for but_workspace::branch::integrate_branch_upstream::InteractiveIntegrationStep + { + type Error = anyhow::Error; + + fn try_from(value: InteractiveIntegrationStep) -> Result { + Ok(match value { + InteractiveIntegrationStep::Pick { commit_id } => Self::Pick { + commit_id: commit_id.try_into()?, + }, + InteractiveIntegrationStep::Squash { commits, message } => Self::Squash { + commits: commits + .into_iter() + .map(TryInto::try_into) + .collect::>()?, + message, + }, + InteractiveIntegrationStep::Merge { commit_id } => Self::Merge { + commit_id: commit_id.try_into()?, + }, + }) + } + } + + /// JSON transport type describing an interactive branch integration plan. + #[derive(Debug, Clone, Serialize, Deserialize)] + #[cfg_attr(feature = "export-schema", derive(schemars::JsonSchema))] + #[serde(rename_all = "camelCase")] + pub struct InteractiveIntegration { + /// Merge base between the upstream and the local reference. + pub merge_base: crate::json::HexHashString, + /// The ordered integration steps to apply. + pub steps: Vec, + } + #[cfg(feature = "export-schema")] + but_schemars::register_sdk_type!(InteractiveIntegration); + + impl TryFrom + for but_workspace::branch::integrate_branch_upstream::InteractiveIntegration + { + type Error = anyhow::Error; + + fn try_from(value: InteractiveIntegration) -> Result { + let InteractiveIntegration { merge_base, steps } = value; + Ok(Self { + merge_base: merge_base.try_into()?, + steps: steps + .into_iter() + .map(TryInto::try_into) + .collect::>()?, + }) + } + } + + /// JSON transport type for the initial branch integration proposal. + #[derive(Debug, Serialize)] + #[cfg_attr(feature = "export-schema", derive(schemars::JsonSchema))] + #[serde(rename_all = "camelCase")] + pub struct InitialBranchIntegration { + /// The editable execution plan for integrating the branch upstream. + pub integration: InteractiveIntegration, + /// The current divergence between local branch and upstream for display. + pub divergence: IntegrationDivergenceDisplay, + } + #[cfg(feature = "export-schema")] + but_schemars::register_sdk_type!(InitialBranchIntegration); + + impl TryFrom for InitialBranchIntegration { + type Error = anyhow::Error; + + fn try_from( + value: but_workspace::branch::InitialBranchIntegration, + ) -> Result { + let but_workspace::branch::InitialBranchIntegration { + integration, + divergence, + } = value; + Ok(Self { + integration: InteractiveIntegration { + merge_base: integration.merge_base.into(), + steps: integration + .steps + .into_iter() + .map(|step| match step { + but_workspace::branch::integrate_branch_upstream::InteractiveIntegrationStep::Pick { commit_id } => { + InteractiveIntegrationStep::Pick { + commit_id: commit_id.into(), + } + } + but_workspace::branch::integrate_branch_upstream::InteractiveIntegrationStep::Squash { commits, message } => { + InteractiveIntegrationStep::Squash { + commits: commits.into_iter().map(Into::into).collect(), + message, + } + } + but_workspace::branch::integrate_branch_upstream::InteractiveIntegrationStep::Merge { commit_id } => { + InteractiveIntegrationStep::Merge { + commit_id: commit_id.into(), + } + } + }) + .collect(), + }, + divergence: divergence.into(), + }) + } + } } /// Applies a branch using the behavior described by [`apply_only_with_perm()`]. @@ -187,6 +420,73 @@ pub fn branch_diff(ctx: &Context, branch: String) -> anyhow::Result but_workspace::ui::diff::changes_in_branch(&repo, &ws, branch.name()) } +/// Get the initial upstream integration script for `branch`. +#[but_api(napi, try_from = json::InitialBranchIntegration)] +#[instrument(err(Debug))] +pub fn get_initial_branch_integration( + ctx: &Context, + branch: &gix::refs::FullNameRef, +) -> anyhow::Result { + let repo = ctx.repo.get()?; + but_workspace::branch::get_initial_integration_steps_for_branch(branch, &repo) +} + +/// Apply `integration` to `branch`. +/// +/// This acquires exclusive worktree access from `ctx`, applies the integration +/// steps to the branch, and records an oplog snapshot on success. When +/// `dry_run` is enabled, the returned workspace previews the integration +/// result and no oplog entry is persisted. +#[but_api(napi, try_from = json::IntegrateBranchResult)] +#[instrument(err(Debug))] +pub fn apply_branch_integration( + ctx: &mut but_ctx::Context, + branch: &gix::refs::FullNameRef, + integration: json::InteractiveIntegration, + dry_run: DryRun, +) -> anyhow::Result { + let integration = integration.try_into()?; + let mut guard = ctx.exclusive_worktree_access(); + apply_branch_integration_with_perm(ctx, branch, integration, dry_run, guard.write_permission()) +} + +/// Apply `integration` to `branch` under caller-held exclusive repository access. +/// +/// It prepares a best-effort oplog snapshot, runs the interactive branch +/// integration, and commits the snapshot only if the operation succeeds. The +/// returned [`IntegrateBranchResult`] contains the post-operation workspace +/// view. When `dry_run` is enabled, it returns a preview of the resulting +/// workspace state and skips oplog persistence. +pub fn apply_branch_integration_with_perm( + ctx: &mut but_ctx::Context, + branch: &gix::refs::FullNameRef, + integration: InteractiveIntegration, + dry_run: DryRun, + perm: &mut RepoExclusive, +) -> anyhow::Result { + branch_mutation_with_snapshot( + ctx, + perm, + OperationKind::GenericBranchUpdate, + dry_run, + |ctx, perm| { + let mut meta = ctx.meta()?; + let (repo, mut ws, _) = ctx.workspace_mut_and_db_with_perm(perm)?; + let rebase = but_workspace::branch::integrate_branch_with_steps( + branch, + integration, + &mut ws, + &mut meta, + &repo, + )?; + + Ok(IntegrateBranchResult { + workspace: WorkspaceState::from_successful_rebase(rebase, &repo, dry_run)?, + }) + }, + ) +} + /// Moves a branch using the behavior described by [`move_branch_with_perm()`]. /// /// This acquires exclusive worktree access from `ctx`, moves `subject_branch` @@ -299,15 +599,15 @@ pub fn tear_off_branch_with_perm( ) } -fn branch_mutation_with_snapshot( +fn branch_mutation_with_snapshot( ctx: &mut but_ctx::Context, perm: &mut RepoExclusive, operation_kind: OperationKind, dry_run: DryRun, operation: F, -) -> anyhow::Result +) -> anyhow::Result where - F: FnOnce(&mut but_ctx::Context, &mut RepoExclusive) -> anyhow::Result, + F: FnOnce(&mut but_ctx::Context, &mut RepoExclusive) -> anyhow::Result, { let maybe_oplog_entry = but_oplog::UnmaterializedOplogSnapshot::from_details_with_perm( ctx, diff --git a/crates/but-api/src/json.rs b/crates/but-api/src/json.rs index 9cebe25849b..1db11c377bf 100644 --- a/crates/but-api/src/json.rs +++ b/crates/but-api/src/json.rs @@ -131,6 +131,9 @@ mod hex_hash { } pub use hex_hash::{HexHash, HexHashString}; +#[cfg(feature = "export-schema")] +but_schemars::register_sdk_type!(HexHashString); + /// Shared JSON transport type for mutation workspace results. #[derive(Debug, Serialize)] #[cfg_attr(feature = "export-schema", derive(schemars::JsonSchema))] diff --git a/crates/but-server/src/lib.rs b/crates/but-server/src/lib.rs index 8bc9ad16604..754ee962674 100644 --- a/crates/but-server/src/lib.rs +++ b/crates/but-server/src/lib.rs @@ -757,6 +757,14 @@ pub async fn run(config: Config) -> anyhow::Result<()> { ) .route("/branch_diff", but_post(but_api::branch::branch_diff_cmd)) .route("/move_branch", but_post(but_api::branch::move_branch_cmd)) + .route( + "/get_initial_branch_integration", + but_post(but_api::branch::get_initial_branch_integration_cmd), + ) + .route( + "/apply_branch_integration", + but_post(but_api::branch::apply_branch_integration_cmd), + ) .route( "/tear_off_branch", but_post(but_api::branch::tear_off_branch_cmd), diff --git a/crates/gitbutler-tauri/permissions/default.toml b/crates/gitbutler-tauri/permissions/default.toml index a40936d86be..17fb4d12d80 100644 --- a/crates/gitbutler-tauri/permissions/default.toml +++ b/crates/gitbutler-tauri/permissions/default.toml @@ -11,6 +11,7 @@ commands.allow = [ "add_project", "add_project_best_effort", "add_remote", + "apply_branch_integration", "assign_hunk", "branch_details", "branch_diff", @@ -84,6 +85,7 @@ commands.allow = [ "get_gb_config", "get_gh_user", "get_gl_user", + "get_initial_branch_integration", "get_initial_integration_steps_for_branch", "get_login_token", "get_logs_archive_path", diff --git a/crates/gitbutler-tauri/src/main.rs b/crates/gitbutler-tauri/src/main.rs index 73b7a97c4e3..1fe7979c0ad 100644 --- a/crates/gitbutler-tauri/src/main.rs +++ b/crates/gitbutler-tauri/src/main.rs @@ -14,7 +14,7 @@ use std::sync::Arc; use anyhow::{Context, bail}; -use but_api::{commit, diff, github, gitlab, legacy, open, platform, workspace}; +use but_api::{branch, commit, diff, github, gitlab, legacy, open, platform, workspace}; #[cfg(feature = "irc")] use but_irc::IrcManager; use but_settings::AppSettingsWithDiskSync; @@ -358,6 +358,8 @@ fn main() -> anyhow::Result<()> { legacy::virtual_branches::tauri_upstream_integration_statuses::upstream_integration_statuses, legacy::virtual_branches::tauri_integrate_upstream::integrate_upstream, legacy::virtual_branches::tauri_resolve_upstream_integration::resolve_upstream_integration, + branch::tauri_get_initial_branch_integration::get_initial_branch_integration, + branch::tauri_apply_branch_integration::apply_branch_integration, legacy::stack::tauri_create_reference::create_reference, legacy::stack::tauri_create_branch::create_branch, legacy::stack::tauri_remove_branch::remove_branch, diff --git a/packages/but-sdk/src/generated/index.d.ts b/packages/but-sdk/src/generated/index.d.ts index 1035bba9bd0..84d7e371003 100644 --- a/packages/but-sdk/src/generated/index.d.ts +++ b/packages/but-sdk/src/generated/index.d.ts @@ -27,6 +27,16 @@ export declare function absorptionPlan(projectId: string, target: AbsorptionTarg */ export declare function apply(projectId: string, existingBranch: string): Promise +/** + * Apply `integration` to `branch`. + * + * This acquires exclusive worktree access from `ctx`, applies the integration + * steps to the branch, and records an oplog snapshot on success. When + * `dry_run` is enabled, the returned workspace previews the integration + * result and no oplog entry is persisted. + */ +export declare function applyBranchIntegration(projectId: string, branch: string, integration: InteractiveIntegration, dryRun: boolean): Promise + /** * Persists `assignments` for the current workspace and records an oplog * snapshot on success. @@ -201,6 +211,9 @@ export declare function commitUncommitChanges(projectId: string, commitId: strin */ export declare function forgeProvider(projectId: string): Promise +/** Get the initial upstream integration script for `branch`. */ +export declare function getInitialBranchIntegration(projectId: string, branch: string): Promise + /** * Get the snapshot that a redo operation should restore to. * @@ -1301,6 +1314,12 @@ export type HeadSha = { headSha: string; }; +/** + * A type that deserializes a hexadecimal hash into a string, unchanged. + * This is to workaround `schemars` which doesn't (always) work with transformations. + */ +export type HexHashString = string; + export type HunkAssignment = { /** * A stable identifier for the hunk assignment. @@ -1427,14 +1446,79 @@ export type IgnoredWorktreeChange = { /** The status we can't handle, which always originated in the worktree. */ export type IgnoredWorktreeTreeChangeStatus = "Conflict" | "TreeIndex" | "TreeIndexWorktreeChangeIneffective"; +/** JSON transport type for the initial branch integration proposal. */ +export type InitialBranchIntegration = { + /** The editable execution plan for integrating the branch upstream. */ + integration: InteractiveIntegration; + /** The current divergence between local branch and upstream for display. */ + divergence: IntegrationDivergenceDisplay; +}; + /** Describes where relative to the selector a step should be inserted */ export type InsertSide = "above" | "below"; +/** JSON transport type for integrating a branch. */ +export type IntegrateBranchResult = { + /** Workspace state after applying or previewing the integration. */ + workspace: WorkspaceState; +}; + +/** JSON transport type for a divergence commit row. */ +export type IntegrationDivergenceCommit = { + /** The commit shown in the graph row. */ + id: HexHashString; + /** The first-line subject shown for the commit. */ + subject: string; + /** Human-facing ref labels rendered inline on the commit row. */ + refs: Array; +}; + +/** JSON transport type for current branch/upstream divergence information. */ +export type IntegrationDivergenceDisplay = { + /** The local branch being integrated. */ + branchRefName: FullRefName; + /** The upstream branch this local branch integrates with. */ + upstreamRefName: FullRefName; + /** Commits only reachable from the local branch tip down to the shared section. */ + localOnly: Array; + /** Commits only reachable from the upstream branch tip down to the shared section. */ + upstreamOnly: Array; + /** Commits shared or matched between local and upstream above the merge-base. */ + matched: Array; + /** The merge-base row shown once at the bottom. */ + mergeBase: IntegrationDivergenceCommit; +}; + export type IntegrationOutcome = { /** The list of branches that have been deleted as a result of the upstream integration */ deletedBranches: Array; }; +/** JSON transport type describing an interactive branch integration plan. */ +export type InteractiveIntegration = { + /** Merge base between the upstream and the local reference. */ + mergeBase: HexHashString; + /** The ordered integration steps to apply. */ + steps: Array; +}; + +/** JSON transport type for a branch integration step. */ +export type InteractiveIntegrationStep = { + /** The local commit to keep in the rewritten branch. */ + commit_id: HexHashString; + kind: "pick"; +} | { + /** The ordered commits to squash together. */ + commits: Array; + /** Optional replacement message for the squash commit. */ + message: string | null; + kind: "squash"; +} | { + /** The commit whose change range should be merged. */ + commit_id: HexHashString; + kind: "merge"; +}; + export type IrcConnectionSettings = { /** Whether this connection is enabled (controls connect/disconnect). */ enabled: boolean; diff --git a/packages/but-sdk/src/generated/index.js b/packages/but-sdk/src/generated/index.js index af155ce7022..4af55ae1c84 100644 --- a/packages/but-sdk/src/generated/index.js +++ b/packages/but-sdk/src/generated/index.js @@ -579,10 +579,11 @@ if (!nativeBinding) { throw new Error(`Failed to load native binding`) } -const { absorb, absorptionPlan, apply, assignHunk, branchDetails, branchDiff, changesInWorktree, changesInWorktreeWithPerm, commitAmend, commitCreate, commitDetailsWithLineStats, commitDiscard, commitInsertBlank, commitMove, commitMoveChangesBetween, commitReword, commitSquash, commitUncommit, commitUncommitChanges, forgeProvider, getRedoTargetSnapshot, getReview, getUndoTargetSnapshot, headInfo, listAvailableReviewTemplates, listBranches, listCiChecksAndUpdateCache, listProjectsStateless, listReviews, listReviewsForBranch, mergeReview, moveBranch, peelRestoreSnapshot, publishReview, pushStackLegacy, removeBranch, restoreSnapshotWithKind, reviewTemplate, setReviewAutoMerge, setReviewDraftiness, setReviewTemplate, tearOffBranch, treeChangeDiffs, unapplyStack, updateBranchName, updateReviewFooters, warmCiChecksCache, workspaceIntegrateUpstream, WatcherHandle, watcherStart } = nativeBinding +const { absorb, absorptionPlan, apply, applyBranchIntegration, assignHunk, branchDetails, branchDiff, changesInWorktree, changesInWorktreeWithPerm, commitAmend, commitCreate, commitDetailsWithLineStats, commitDiscard, commitInsertBlank, commitMove, commitMoveChangesBetween, commitReword, commitSquash, commitUncommit, commitUncommitChanges, forgeProvider, getInitialBranchIntegration, getRedoTargetSnapshot, getReview, getUndoTargetSnapshot, headInfo, listAvailableReviewTemplates, listBranches, listCiChecksAndUpdateCache, listProjectsStateless, listReviews, listReviewsForBranch, mergeReview, moveBranch, peelRestoreSnapshot, publishReview, pushStackLegacy, removeBranch, restoreSnapshotWithKind, reviewTemplate, setReviewAutoMerge, setReviewDraftiness, setReviewTemplate, tearOffBranch, treeChangeDiffs, unapplyStack, updateBranchName, updateReviewFooters, warmCiChecksCache, workspaceIntegrateUpstream, WatcherHandle, watcherStart } = nativeBinding export { absorb } export { absorptionPlan } export { apply } +export { applyBranchIntegration } export { assignHunk } export { branchDetails } export { branchDiff } @@ -600,6 +601,7 @@ export { commitSquash } export { commitUncommit } export { commitUncommitChanges } export { forgeProvider } +export { getInitialBranchIntegration } export { getRedoTargetSnapshot } export { getReview } export { getUndoTargetSnapshot }