Skip to content

Commit a5b3cf8

Browse files
committed
Add pre-flight conflict detection for cross-stack commit and branch moves
When moving a commit or branch to a different stack, both rebases are computed against the ODB first — no refs, no snapshot, no worktree state is touched. Their outputs are then inspected for newly-conflicted commits before any state is written. This means each stack is rebased exactly once per operation. Source check: if removing the moved commit(s) would cause remaining commits in the source stack to conflict, the move is rejected — those commits depend on the changes being moved away. Destination check: if inserting the moved commit(s) onto the destination stack would cause a conflict, the move is rejected — the changes do not apply cleanly at the target location. Pre-existing conflicts in the source stack are exempted: if a commit was already conflicted before the move, it is not counted as a new conflict caused by the move. Moves that are now prevented: - Moving a commit whose removal would leave dependent commits in the source stack that can no longer rebase cleanly. - Moving a commit onto a destination stack that already modified the same content, causing a conflict at the insertion point. - Moving a branch whose removal would strand dependent branches in the source stack. - Moving a branch onto a destination that has already modified the same content. Moves that are not affected: - Intra-stack moves (source == destination); these cannot produce inter-stack conflicts and skip the check entirely. - Cross-stack moves where source and destination are truly independent (disjoint file changes). - Moves where the source stack has pre-existing conflicts unrelated to the commit being moved.
1 parent fadc92b commit a5b3cf8

File tree

6 files changed

+1097
-148
lines changed

6 files changed

+1097
-148
lines changed

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: 138 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,17 @@ 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};
1317

1418
#[derive(Debug, Clone, Serialize)]
1519
#[serde(rename_all = "camelCase")]
@@ -22,55 +26,141 @@ pub struct MoveBranchResult {
2226
}
2327

2428
pub(crate) fn move_branch(
25-
ctx: &Context,
29+
ctx: &mut Context,
2630
target_stack_id: StackId,
2731
target_branch_name: &str,
2832
source_stack_id: StackId,
2933
subject_branch_name: &str,
3034
perm: &mut RepoExclusive,
3135
) -> Result<MoveBranchResult> {
3236
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());
3537

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

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")?;
107+
(source_output, dest_output, source_will_be_deleted)
108+
};
44109

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

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-
)?;
113+
// Apply source changes.
114+
let mut deleted_stacks = Vec::new();
115+
{
116+
let repo = ctx.repo.get()?;
117+
let mut vb_state = ctx.virtual_branches();
118+
if source_will_be_deleted {
119+
vb_state.delete_branch_entry(&source_stack_id)?;
120+
deleted_stacks.push(source_stack_id);
121+
} else if let Some(src_out) = source_output {
122+
let mut source_stack = vb_state.get_stack_in_workspace(source_stack_id)?;
123+
let new_source_head = repo.find_commit(src_out.top_commit)?;
124+
source_stack.remove_branch(ctx, subject_branch_name)?;
125+
source_stack.set_stack_head(&mut vb_state, &repo, new_source_head.id().detach())?;
126+
source_stack.set_heads_from_rebase_output(ctx, src_out.references)?;
127+
}
128+
}
57129

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-
)?;
130+
// Apply dest changes.
131+
{
132+
let repo = ctx.repo.get()?;
133+
let mut vb_state = ctx.virtual_branches();
134+
let mut destination_stack = vb_state.get_stack_in_workspace(target_stack_id)?;
135+
let new_destination_head = repo.find_commit(dest_output.top_commit)?;
136+
137+
// We initialize the new StackBranch with the target branch's current head so that
138+
// StackBranch::new's range validation passes (the rebased subject head is not yet
139+
// "in range" because the stack head hasn't been updated). The correct commit is
140+
// applied by set_heads_from_rebase_output below.
141+
let target_branch_reference = dest_output
142+
.references
143+
.iter()
144+
.find(|r| r.reference.to_string() == target_branch_name)
145+
.context("target branch not found in dest rebase output")?;
146+
let target_branch_head = target_branch_reference.commit_id;
147+
148+
let mut new_head =
149+
StackBranch::new(target_branch_head, subject_branch_name.to_string(), &repo)?;
150+
new_head.pr_number = source_branch_pr_number;
151+
152+
destination_stack.add_series(ctx, new_head, Some(target_branch_name.to_string()))?;
153+
destination_stack.set_stack_head(
154+
&mut vb_state,
155+
&repo,
156+
new_destination_head.id().detach(),
157+
)?;
158+
destination_stack.set_heads_from_rebase_output(ctx, dest_output.references)?;
159+
}
70160

71161
let new_workspace = WorkspaceState::create(ctx, perm.read_permission())?;
72162
let _ = update_uncommitted_changes(ctx, old_workspace, new_workspace, perm);
73-
crate::integration::update_workspace_commit_with_vb_state(&vb_state, ctx, false)
163+
crate::integration::update_workspace_commit_with_vb_state(&ctx.virtual_branches(), ctx, false)
74164
.context("failed to update gitbutler workspace")?;
75165

76166
Ok(MoveBranchResult {
@@ -88,17 +178,17 @@ pub(crate) fn tear_off_branch(
88178
) -> Result<MoveBranchResult> {
89179
let old_workspace = WorkspaceState::create(ctx, perm.read_permission())?;
90180
let repo = ctx.repo.get()?;
91-
let mut vb_state = VirtualBranchesHandle::new(ctx.project_data_dir());
92181

93-
let source_stack = vb_state.get_stack_in_workspace(source_stack_id)?;
182+
let source_stack = ctx
183+
.virtual_branches()
184+
.get_stack_in_workspace(source_stack_id)?;
94185
let source_merge_base = source_stack.merge_base(ctx)?;
95186

96187
let (subject_branch_steps, deleted_stacks) = extract_and_rebase_source_branch(
97188
ctx,
98189
source_stack_id,
99190
subject_branch_name,
100191
&repo,
101-
&mut vb_state,
102192
source_stack,
103193
source_merge_base,
104194
)?;
@@ -126,7 +216,7 @@ pub(crate) fn tear_off_branch(
126216

127217
let new_workspace = WorkspaceState::create(ctx, perm.read_permission())?;
128218
let _ = update_uncommitted_changes(ctx, old_workspace, new_workspace, perm);
129-
crate::integration::update_workspace_commit_with_vb_state(&vb_state, ctx, false)
219+
crate::integration::update_workspace_commit_with_vb_state(&ctx.virtual_branches(), ctx, false)
130220
.context("failed to update gitbutler workspace")?;
131221

132222
let branch_manager = ctx.branch_manager();
@@ -144,63 +234,12 @@ pub(crate) fn tear_off_branch(
144234
})
145235
}
146236

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-
197237
/// Extracts the steps corresponding to the branch to move, and rebases the source stack without those steps.
198238
fn extract_and_rebase_source_branch(
199239
ctx: &Context,
200240
source_stack_id: StackId,
201241
subject_branch_name: &str,
202242
repository: &gix::Repository,
203-
vb_state: &mut VirtualBranchesHandle,
204243
source_stack: gitbutler_stack::Stack,
205244
source_merge_base: gix::ObjectId,
206245
) -> Result<(Vec<RebaseStep>, Vec<StackId>), anyhow::Error> {
@@ -211,7 +250,8 @@ fn extract_and_rebase_source_branch(
211250

212251
if new_source_steps.is_empty() {
213252
// If there are no other branches left in the source stack, delete the stack.
214-
vb_state.delete_branch_entry(&source_stack_id)?;
253+
ctx.virtual_branches()
254+
.delete_branch_entry(&source_stack_id)?;
215255
deleted_stacks.push(source_stack_id);
216256
} else {
217257
// Rebase the source stack without the extracted branch steps
@@ -223,7 +263,11 @@ fn extract_and_rebase_source_branch(
223263

224264
source_stack.remove_branch(ctx, subject_branch_name)?;
225265

226-
source_stack.set_stack_head(vb_state, repository, new_source_head.id().detach())?;
266+
source_stack.set_stack_head(
267+
&mut ctx.virtual_branches(),
268+
repository,
269+
new_source_head.id().detach(),
270+
)?;
227271

228272
source_stack.set_heads_from_rebase_output(ctx, source_rebase_result.clone().references)?;
229273
}
@@ -287,7 +331,7 @@ fn inject_branch_steps(
287331
branch_steps: Vec<RebaseStep>,
288332
) -> Result<Vec<RebaseStep>> {
289333
let destination_steps = destination_stack.as_rebase_steps_rev(ctx)?;
290-
let mut branch_steps = branch_steps.clone();
334+
let mut branch_steps = branch_steps;
291335
branch_steps.reverse();
292336

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

0 commit comments

Comments
 (0)