diff --git a/crates/but-workspace/src/branch/move_branch.rs b/crates/but-workspace/src/branch/move_branch.rs index b45d2534aeb..68bffa2ff54 100644 --- a/crates/but-workspace/src/branch/move_branch.rs +++ b/crates/but-workspace/src/branch/move_branch.rs @@ -375,20 +375,10 @@ fn get_disconnect_parameters<'ws, 'meta, M: RefMetadata>( let parents_to_disconnect = if let Some(stack_base_segment) = stack_base_segment { // Base segment is part of the source stack. - let base_segment_ref_name = stack_base_segment - .ref_name() - .context("Base segment doesn't have a ref name.")?; - let reference_selector = editor.select_reference(base_segment_ref_name)?; - let selectors = SomeSelectors::new(vec![reference_selector])?; - SelectorSet::Some(selectors) + 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). - let ref_name = graph_base_segment - .ref_name() - .context("Graph base segment doesn't have a ref name.")?; - let reference_selector = editor.select_reference(ref_name)?; - let selectors = SomeSelectors::new(vec![reference_selector])?; - SelectorSet::Some(selectors) + // Base segment is outside the stack (e.g. the target branch, or an unnamed fork-point segment). + 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!( @@ -437,3 +427,47 @@ fn get_disconnect_parameters<'ws, 'meta, M: RefMetadata>( Ok((delimiter, children_to_disconnect, parents_to_disconnect)) } + +/// Select a segment for use as a disconnect point. +/// +/// Prefers the ref name when available, otherwise falls back to the tip commit. +/// Fails if the segment has neither (empty and unnamed). +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)) +} + +/// Common interface for selecting a graph segment, abstracting over +/// [`StackSegment`] (workspace projection) and [`but_graph::Segment`] (raw graph). +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/e2e/playwright/scripts/fetch-in-clone.sh b/e2e/playwright/scripts/fetch-in-clone.sh new file mode 100755 index 00000000000..27853e25ec0 --- /dev/null +++ b/e2e/playwright/scripts/fetch-in-clone.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +echo "GIT CONFIG $GIT_CONFIG_GLOBAL" +echo "DATA DIR $E2E_TEST_APP_DATA_DIR" +echo "DIRECTORY: $1" + +# Fetch updates from the remote. +pushd "$1" + git fetch origin +popd diff --git a/e2e/playwright/tests/branches.spec.ts b/e2e/playwright/tests/branches.spec.ts index 9fc24a3e775..a016500fc50 100644 --- a/e2e/playwright/tests/branches.spec.ts +++ b/e2e/playwright/tests/branches.spec.ts @@ -643,3 +643,41 @@ test("should be able to unapply a stack", async ({ page, context }, testInfo) => branchHeaders = getByTestId(page, "branch-header").filter({ hasText: "branch1" }); await expect(branchHeaders).toHaveCount(0); }); + +test("should be able to move a branch when origin/master has advanced past the fork point", async ({ + page, + context, +}, testInfo) => { + const workdir = testInfo.outputPath("workdir"); + const configdir = testInfo.outputPath("config"); + gitbutler = await startGitButler(workdir, configdir, context); + + // Set up project with branches (branch1 and branch3 fork from initial master). + await gitbutler.runScript("project-with-remote-branches.sh"); + // Apply branch1 and branch3 as two separate stacks. + await gitbutler.runScript("apply-upstream-branch.sh", ["branch1", "local-clone"]); + await gitbutler.runScript("apply-upstream-branch.sh", ["branch3", "local-clone"]); + + // Advance remote master past the fork point. This creates a commit on + // origin/master that is ahead of where branch1 and branch3 diverge. + await gitbutler.runScript("project-with-remote-branches__add-commit-to-base.sh"); + // Fetch so that origin/master advances in the local clone, making the + // old fork point an unnamed segment in the graph. + await gitbutler.runScript("fetch-in-clone.sh", ["local-clone"]); + + // Move branch3 on top of branch1. This should succeed even though the + // base segment at the old fork point has no ref name. + await gitbutler.runScript("move-branch.sh", ["branch3", "branch1", "local-clone"]); + + await page.goto("/"); + + // Should load the workspace + await waitForTestId(page, "workspace-view"); + + // There should be one stack with both branches + const stacks = getByTestId(page, "stack"); + await expect(stacks).toHaveCount(1); + + const branchHeaders = getByTestId(page, "branch-header"); + await expect(branchHeaders).toHaveCount(2); +});