Skip to content

Commit 05164dc

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 4fab4b9 commit 05164dc

6 files changed

Lines changed: 1078 additions & 136 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: 122 additions & 85 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};
711
use gitbutler_stack::{StackBranch, VirtualBranchesHandle};
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, move_commits::bail_on_new_conflicts};
1317

1418
#[derive(Debug, Clone, Serialize)]
1519
#[serde(rename_all = "camelCase")]
@@ -22,54 +26,137 @@ 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 = VirtualBranchesHandle::new(ctx.project_data_dir());
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 = VirtualBranchesHandle::new(ctx.project_data_dir());
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 = VirtualBranchesHandle::new(ctx.project_data_dir());
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 = VirtualBranchesHandle::new(ctx.project_data_dir());
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+
let target_branch_reference = dest_output
138+
.references
139+
.iter()
140+
.find(|r| r.reference.to_string() == target_branch_name)
141+
.context("target branch not found in dest rebase output")?;
142+
let target_branch_head = target_branch_reference.commit_id;
143+
144+
let mut new_head =
145+
StackBranch::new(target_branch_head, subject_branch_name.to_string(), &repo)?;
146+
new_head.pr_number = source_branch_pr_number;
147+
148+
destination_stack.add_series(ctx, new_head, Some(target_branch_name.to_string()))?;
149+
destination_stack.set_stack_head(
150+
&mut vb_state,
151+
&repo,
152+
new_destination_head.id().detach(),
153+
)?;
154+
destination_stack.set_heads_from_rebase_output(ctx, dest_output.references)?;
155+
}
70156

71157
let new_workspace = WorkspaceState::create(ctx, perm.read_permission())?;
72158
let _ = update_uncommitted_changes(ctx, old_workspace, new_workspace, perm);
159+
let vb_state = VirtualBranchesHandle::new(ctx.project_data_dir());
73160
crate::integration::update_workspace_commit_with_vb_state(&vb_state, ctx, false)
74161
.context("failed to update gitbutler workspace")?;
75162

@@ -144,56 +231,6 @@ pub(crate) fn tear_off_branch(
144231
})
145232
}
146233

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-
197234
/// Extracts the steps corresponding to the branch to move, and rebases the source stack without those steps.
198235
fn extract_and_rebase_source_branch(
199236
ctx: &Context,
@@ -287,7 +324,7 @@ fn inject_branch_steps(
287324
branch_steps: Vec<RebaseStep>,
288325
) -> Result<Vec<RebaseStep>> {
289326
let destination_steps = destination_stack.as_rebase_steps_rev(ctx)?;
290-
let mut branch_steps = branch_steps.clone();
327+
let mut branch_steps = branch_steps;
291328
branch_steps.reverse();
292329

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

0 commit comments

Comments
 (0)