@@ -3,13 +3,18 @@ use but_core::ref_metadata::StackId;
33use but_ctx:: { Context , access:: RepoExclusive } ;
44use but_rebase:: { Rebase , RebaseStep } ;
55use but_workspace:: legacy:: stack_ext:: StackExt ;
6+ use gitbutler_oplog:: {
7+ OplogExt ,
8+ entry:: { OperationKind , SnapshotDetails } ,
9+ } ;
610use gitbutler_reference:: { LocalRefname , Refname } ;
7- use gitbutler_stack:: { StackBranch , VirtualBranchesHandle } ;
11+ use gitbutler_stack:: StackBranch ;
812use gitbutler_workspace:: branch_trees:: { WorkspaceState , update_uncommitted_changes} ;
913use gix:: refs:: transaction:: PreviousValue ;
1014use 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
2429pub ( 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.
198241fn 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).
233287fn 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