Skip to content

Commit d81b0c1

Browse files
authored
Merge pull request #13086 from gitbutlerapp/mg-branch-11
Add pre-flight conflict detection for cross-stack commit and branch moves
2 parents 89ad8e2 + 538cad6 commit d81b0c1

6 files changed

Lines changed: 1108 additions & 148 deletions

File tree

crates/gitbutler-branch-actions/src/actions.rs

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -396,10 +396,6 @@ pub fn move_commit(
396396
ctx.verify(guard.write_permission())?;
397397
ensure_open_workspace_mode(ctx, guard.read_permission())
398398
.context("Moving a commit requires open workspace mode")?;
399-
let _ = ctx.create_snapshot(
400-
SnapshotDetails::new(OperationKind::MoveCommit),
401-
guard.write_permission(),
402-
);
403399
move_commits::move_commit(
404400
ctx,
405401
target_stack_id,
@@ -420,10 +416,6 @@ pub fn move_branch(
420416
ctx.verify(guard.write_permission())?;
421417
ensure_open_workspace_mode(ctx, guard.read_permission())
422418
.context("Moving a branch requires open workspace mode")?;
423-
let _ = ctx.create_snapshot(
424-
SnapshotDetails::new(OperationKind::MoveBranch),
425-
guard.write_permission(),
426-
);
427419
crate::move_branch::move_branch(
428420
ctx,
429421
target_stack_id,

crates/gitbutler-branch-actions/src/move_branch.rs

Lines changed: 148 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,18 @@ use but_core::ref_metadata::StackId;
33
use but_ctx::{Context, access::RepoExclusive};
44
use but_rebase::{Rebase, RebaseStep};
55
use but_workspace::legacy::stack_ext::StackExt;
6+
use gitbutler_oplog::{
7+
OplogExt,
8+
entry::{OperationKind, SnapshotDetails},
9+
};
610
use gitbutler_reference::{LocalRefname, Refname};
7-
use gitbutler_stack::{StackBranch, VirtualBranchesHandle};
11+
use gitbutler_stack::StackBranch;
812
use gitbutler_workspace::branch_trees::{WorkspaceState, update_uncommitted_changes};
913
use gix::refs::transaction::PreviousValue;
1014
use serde::Serialize;
1115

12-
use crate::BranchManagerExt;
16+
use crate::{BranchManagerExt, VirtualBranchesExt as _, move_commits::bail_on_new_conflicts};
17+
use anyhow::bail;
1318

1419
#[derive(Debug, Clone, Serialize)]
1520
#[serde(rename_all = "camelCase")]
@@ -22,55 +27,143 @@ pub struct MoveBranchResult {
2227
}
2328

2429
pub(crate) fn move_branch(
25-
ctx: &Context,
30+
ctx: &mut Context,
2631
target_stack_id: StackId,
2732
target_branch_name: &str,
2833
source_stack_id: StackId,
2934
subject_branch_name: &str,
3035
perm: &mut RepoExclusive,
3136
) -> Result<MoveBranchResult> {
37+
if source_stack_id == target_stack_id {
38+
bail!("Cannot move a branch within the same stack; use the reorder operation instead.");
39+
}
3240
let old_workspace = WorkspaceState::create(ctx, perm.read_permission())?;
33-
let repo = ctx.repo.get()?;
34-
let mut vb_state = VirtualBranchesHandle::new(ctx.project_data_dir());
3541

36-
let source_stack = vb_state.get_stack_in_workspace(source_stack_id)?;
37-
let source_merge_base = source_stack.merge_base(ctx)?;
42+
let (source_merge_base, dest_merge_base, source_branch_pr_number) = {
43+
let vb_state = ctx.virtual_branches();
44+
let source_stack = vb_state.get_stack_in_workspace(source_stack_id)?;
45+
let dest_stack = vb_state.get_stack_in_workspace(target_stack_id)?;
46+
let pr_number = source_stack
47+
.branches()
48+
.into_iter()
49+
.find(|b| b.name == subject_branch_name)
50+
.context("Subject branch not found in source stack")?
51+
.pr_number;
52+
(
53+
source_stack.merge_base(ctx)?,
54+
dest_stack.merge_base(ctx)?,
55+
pr_number,
56+
)
57+
};
58+
59+
// Cross-stack move: compute both rebases (ODB-only writes), check for
60+
// conflicts, then snapshot, then apply state changes.
61+
let (source_output, dest_output, source_will_be_deleted) = {
62+
let repo = ctx.repo.get()?;
63+
let vb_state = ctx.virtual_branches();
64+
let source_stack = vb_state.get_stack_in_workspace(source_stack_id)?;
65+
let dest_stack = vb_state.get_stack_in_workspace(target_stack_id)?;
66+
67+
let (subject_branch_steps, remaining_steps) =
68+
extract_branch_steps(ctx, &repo, &source_stack, subject_branch_name)?;
69+
70+
let source_will_be_deleted = remaining_steps.is_empty();
71+
72+
// Source: rebase remaining commits without the moved branch (if any remain).
73+
let source_output = if !source_will_be_deleted {
74+
let mut src_rebase = Rebase::new(&repo, source_merge_base, None)?;
75+
src_rebase.steps(remaining_steps)?;
76+
src_rebase.rebase_noops(false);
77+
Some(src_rebase.rebase(&*ctx.cache.get_cache()?)?)
78+
} else {
79+
None
80+
};
81+
82+
// Dest: rebase dest stack with the moved branch injected.
83+
let new_dest_steps = inject_branch_steps(
84+
ctx,
85+
&repo,
86+
&dest_stack,
87+
target_branch_name,
88+
subject_branch_steps,
89+
)?;
90+
let mut dst_rebase = Rebase::new(&repo, dest_merge_base, None)?;
91+
dst_rebase.steps(new_dest_steps)?;
92+
dst_rebase.rebase_noops(false);
93+
let dest_output = dst_rebase.rebase(&*ctx.cache.get_cache()?)?;
94+
95+
// Conflict check — bail before any state is written.
96+
if let Some(ref src_out) = source_output {
97+
bail_on_new_conflicts(
98+
&repo,
99+
src_out,
100+
"This move would cause a conflict in the source stack: \
101+
other commits depend on the changes being moved.",
102+
)?;
103+
}
104+
bail_on_new_conflicts(
105+
&repo,
106+
&dest_output,
107+
"This move would cause a conflict in the destination stack: \
108+
the branch does not apply cleanly at the target location.",
109+
)?;
38110

39-
let source_branch = source_stack
40-
.branches()
41-
.into_iter()
42-
.find(|b| b.name == subject_branch_name)
43-
.context("Subject branch not found in source stack")?;
111+
(source_output, dest_output, source_will_be_deleted)
112+
};
44113

45-
let destination_stack = vb_state.get_stack_in_workspace(target_stack_id)?;
46-
let destination_merge_base = destination_stack.merge_base(ctx)?;
114+
// Snapshot after the conflict check, but before any state writes.
115+
let _ = ctx.create_snapshot(SnapshotDetails::new(OperationKind::MoveBranch), perm);
47116

48-
let (subject_branch_steps, deleted_stacks) = extract_and_rebase_source_branch(
49-
ctx,
50-
source_stack_id,
51-
subject_branch_name,
52-
&repo,
53-
&mut vb_state,
54-
source_stack,
55-
source_merge_base,
56-
)?;
117+
// Apply source changes.
118+
let mut deleted_stacks = Vec::new();
119+
{
120+
let repo = ctx.repo.get()?;
121+
let mut vb_state = ctx.virtual_branches();
122+
if source_will_be_deleted {
123+
vb_state.delete_branch_entry(&source_stack_id)?;
124+
deleted_stacks.push(source_stack_id);
125+
} else if let Some(src_out) = source_output {
126+
let mut source_stack = vb_state.get_stack_in_workspace(source_stack_id)?;
127+
let new_source_head = repo.find_commit(src_out.top_commit)?;
128+
source_stack.remove_branch(ctx, subject_branch_name)?;
129+
source_stack.set_stack_head(&mut vb_state, &repo, new_source_head.id().detach())?;
130+
source_stack.set_heads_from_rebase_output(ctx, src_out.references)?;
131+
}
132+
}
57133

58-
// Inject the extracted branch steps into the destination stack and rebase the stack
59-
inject_branch_steps_into_destination(
60-
ctx,
61-
target_branch_name,
62-
subject_branch_name,
63-
&repo,
64-
&mut vb_state,
65-
destination_stack,
66-
destination_merge_base,
67-
subject_branch_steps,
68-
source_branch.pr_number,
69-
)?;
134+
// Apply dest changes.
135+
{
136+
let repo = ctx.repo.get()?;
137+
let mut vb_state = ctx.virtual_branches();
138+
let mut destination_stack = vb_state.get_stack_in_workspace(target_stack_id)?;
139+
let new_destination_head = repo.find_commit(dest_output.top_commit)?;
140+
141+
// StackBranch::new validates that the supplied commit is within the current stack
142+
// range. The rebased subject head isn't "in range" yet because the stack head hasn't
143+
// been updated, so we seed the new branch with the anchor branch's current head as a
144+
// placeholder. set_heads_from_rebase_output corrects it to the proper commit below.
145+
let anchor_ref = dest_output
146+
.references
147+
.iter()
148+
.find(|r| r.reference.to_string() == target_branch_name)
149+
.context("target branch not found in dest rebase output")?;
150+
151+
let mut new_head =
152+
StackBranch::new(anchor_ref.commit_id, subject_branch_name.to_string(), &repo)?;
153+
new_head.pr_number = source_branch_pr_number;
154+
155+
destination_stack.add_series(ctx, new_head, Some(target_branch_name.to_string()))?;
156+
destination_stack.set_stack_head(
157+
&mut vb_state,
158+
&repo,
159+
new_destination_head.id().detach(),
160+
)?;
161+
destination_stack.set_heads_from_rebase_output(ctx, dest_output.references)?;
162+
}
70163

71164
let new_workspace = WorkspaceState::create(ctx, perm.read_permission())?;
72165
let _ = update_uncommitted_changes(ctx, old_workspace, new_workspace, perm);
73-
crate::integration::update_workspace_commit_with_vb_state(&vb_state, ctx, false)
166+
crate::integration::update_workspace_commit_with_vb_state(&ctx.virtual_branches(), ctx, false)
74167
.context("failed to update gitbutler workspace")?;
75168

76169
Ok(MoveBranchResult {
@@ -88,17 +181,17 @@ pub(crate) fn tear_off_branch(
88181
) -> Result<MoveBranchResult> {
89182
let old_workspace = WorkspaceState::create(ctx, perm.read_permission())?;
90183
let repo = ctx.repo.get()?;
91-
let mut vb_state = VirtualBranchesHandle::new(ctx.project_data_dir());
92184

93-
let source_stack = vb_state.get_stack_in_workspace(source_stack_id)?;
185+
let source_stack = ctx
186+
.virtual_branches()
187+
.get_stack_in_workspace(source_stack_id)?;
94188
let source_merge_base = source_stack.merge_base(ctx)?;
95189

96190
let (subject_branch_steps, deleted_stacks) = extract_and_rebase_source_branch(
97191
ctx,
98192
source_stack_id,
99193
subject_branch_name,
100194
&repo,
101-
&mut vb_state,
102195
source_stack,
103196
source_merge_base,
104197
)?;
@@ -126,7 +219,7 @@ pub(crate) fn tear_off_branch(
126219

127220
let new_workspace = WorkspaceState::create(ctx, perm.read_permission())?;
128221
let _ = update_uncommitted_changes(ctx, old_workspace, new_workspace, perm);
129-
crate::integration::update_workspace_commit_with_vb_state(&vb_state, ctx, false)
222+
crate::integration::update_workspace_commit_with_vb_state(&ctx.virtual_branches(), ctx, false)
130223
.context("failed to update gitbutler workspace")?;
131224

132225
let branch_manager = ctx.branch_manager();
@@ -144,63 +237,12 @@ pub(crate) fn tear_off_branch(
144237
})
145238
}
146239

147-
#[expect(clippy::too_many_arguments)]
148-
/// Injects the extracted branch steps into the destination stack and rebases it.
149-
fn inject_branch_steps_into_destination(
150-
ctx: &Context,
151-
target_branch_name: &str,
152-
subject_branch_name: &str,
153-
repo: &gix::Repository,
154-
vb_state: &mut VirtualBranchesHandle,
155-
destination_stack: gitbutler_stack::Stack,
156-
destination_merge_base: gix::ObjectId,
157-
subject_branch_steps: Vec<RebaseStep>,
158-
subject_branch_pr_number: Option<usize>,
159-
) -> Result<(), anyhow::Error> {
160-
let new_destination_steps = inject_branch_steps(
161-
ctx,
162-
repo,
163-
&destination_stack,
164-
target_branch_name,
165-
subject_branch_steps,
166-
)?;
167-
168-
let mut destination_stack_rebase = Rebase::new(repo, destination_merge_base, None)?;
169-
destination_stack_rebase.steps(new_destination_steps)?;
170-
destination_stack_rebase.rebase_noops(false);
171-
let destination_rebase_result = destination_stack_rebase.rebase(&*ctx.cache.get_cache()?)?;
172-
let new_destination_head = repo.find_commit(destination_rebase_result.top_commit)?;
173-
let mut destination_stack = destination_stack;
174-
175-
let target_branch_reference = destination_rebase_result
176-
.clone()
177-
.references
178-
.into_iter()
179-
.find(|r| r.reference.to_string() == target_branch_name)
180-
.context("subject branch not found in rebase output")?;
181-
182-
let target_branch_head = target_branch_reference.commit_id;
183-
184-
let mut new_head = StackBranch::new(target_branch_head, subject_branch_name.to_string(), repo)?;
185-
186-
new_head.pr_number = subject_branch_pr_number;
187-
188-
destination_stack.add_series(ctx, new_head, Some(target_branch_name.to_string()))?;
189-
190-
destination_stack.set_stack_head(vb_state, repo, new_destination_head.id().detach())?;
191-
192-
destination_stack
193-
.set_heads_from_rebase_output(ctx, destination_rebase_result.clone().references)?;
194-
Ok(())
195-
}
196-
197240
/// Extracts the steps corresponding to the branch to move, and rebases the source stack without those steps.
198241
fn extract_and_rebase_source_branch(
199242
ctx: &Context,
200243
source_stack_id: StackId,
201244
subject_branch_name: &str,
202245
repository: &gix::Repository,
203-
vb_state: &mut VirtualBranchesHandle,
204246
source_stack: gitbutler_stack::Stack,
205247
source_merge_base: gix::ObjectId,
206248
) -> Result<(Vec<RebaseStep>, Vec<StackId>), anyhow::Error> {
@@ -211,7 +253,8 @@ fn extract_and_rebase_source_branch(
211253

212254
if new_source_steps.is_empty() {
213255
// If there are no other branches left in the source stack, delete the stack.
214-
vb_state.delete_branch_entry(&source_stack_id)?;
256+
ctx.virtual_branches()
257+
.delete_branch_entry(&source_stack_id)?;
215258
deleted_stacks.push(source_stack_id);
216259
} else {
217260
// Rebase the source stack without the extracted branch steps
@@ -223,13 +266,24 @@ fn extract_and_rebase_source_branch(
223266

224267
source_stack.remove_branch(ctx, subject_branch_name)?;
225268

226-
source_stack.set_stack_head(vb_state, repository, new_source_head.id().detach())?;
269+
source_stack.set_stack_head(
270+
&mut ctx.virtual_branches(),
271+
repository,
272+
new_source_head.id().detach(),
273+
)?;
227274

228275
source_stack.set_heads_from_rebase_output(ctx, source_rebase_result.clone().references)?;
229276
}
230277
Ok((subject_branch_steps, deleted_stacks))
231278
}
232279

280+
/// Splits the source stack's rebase steps into two groups: those belonging to
281+
/// `subject_branch_name` and those that remain.
282+
///
283+
/// Steps are partitioned by scanning for a `Reference` marker whose name matches
284+
/// the subject branch (either as a Git ref or a virtual ref). All steps between
285+
/// consecutive Reference markers are considered part of that branch. Returns
286+
/// `(subject_steps, remaining_steps)`, both in execution order (oldest first).
233287
fn extract_branch_steps(
234288
ctx: &Context,
235289
repository: &gix::Repository,
@@ -287,7 +341,7 @@ fn inject_branch_steps(
287341
branch_steps: Vec<RebaseStep>,
288342
) -> Result<Vec<RebaseStep>> {
289343
let destination_steps = destination_stack.as_rebase_steps_rev(ctx)?;
290-
let mut branch_steps = branch_steps.clone();
344+
let mut branch_steps = branch_steps;
291345
branch_steps.reverse();
292346

293347
let mut new_destination_steps = Vec::new();

0 commit comments

Comments
 (0)