Skip to content

Commit 17f5f1f

Browse files
authored
Merge pull request #9461 from gitbutlerapp/feature-split-dependent-branches
Split branch into dependent branch
2 parents 57e7009 + 12786f0 commit 17f5f1f

File tree

6 files changed

+280
-12
lines changed

6 files changed

+280
-12
lines changed

apps/desktop/src/components/v3/FileContextMenu.svelte

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@
6363
const [branchChanges, branchingChanges] = actionService.branchChanges;
6464
const [absorbChanges, absorbingChanges] = actionService.absorb;
6565
const [splitOffChanges] = stackService.splitBranch;
66+
const [splitBranchIntoDependentBranch] = stackService.splitBrancIntoDependentBranch;
6667
6768
const userSettings = getContextStoreBySymbol<Settings, Writable<Settings>>(SETTINGS);
6869
const isUncommitted = $derived(selectionId.type === 'worktree');
@@ -243,6 +244,36 @@
243244
newBranchName: newBranchName.data
244245
});
245246
}
247+
248+
async function splitIntoDependentBranch(changes: TreeChange[]) {
249+
if (!stackId) {
250+
toasts.error('No stack selected to split off changes.');
251+
return;
252+
}
253+
254+
if (selectionId.type !== 'branch') {
255+
toasts.error('Please select a branch to split off changes.');
256+
return;
257+
}
258+
259+
const branchName = selectionId.branchName;
260+
261+
const fileNames = changes.map((change) => change.path);
262+
const newBranchName = await stackService.newBranchName(projectId);
263+
264+
if (!newBranchName.data) {
265+
toasts.error('Failed to generate a new branch name.');
266+
return;
267+
}
268+
269+
await splitBranchIntoDependentBranch({
270+
projectId,
271+
sourceStackId: stackId,
272+
sourceBranchName: branchName,
273+
fileChangesToSplitOff: fileNames,
274+
newBranchName: newBranchName.data
275+
});
276+
}
246277
</script>
247278

248279
<ContextMenu bind:this={contextMenu} rightClickTrigger={trigger}>
@@ -332,6 +363,13 @@
332363
contextMenu.close();
333364
}}
334365
/>
366+
<ContextMenuItem
367+
label="Split into dependent branch"
368+
onclick={async () => {
369+
await splitIntoDependentBranch(changes);
370+
contextMenu.close();
371+
}}
372+
/>
335373
{/if}
336374
{/if}
337375
</ContextMenuSection>

apps/desktop/src/lib/stacks/stackService.svelte.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -808,6 +808,10 @@ export class StackService {
808808
return this.api.endpoints.splitBranch.useMutation();
809809
}
810810

811+
get splitBrancIntoDependentBranch() {
812+
return this.api.endpoints.splitBranchIntoDependentBranch.useMutation();
813+
}
814+
811815
stackDetailsUpdateListener(projectId: string) {
812816
return this.api.endpoints.stackDetailsUpdate.useQuery({
813817
projectId
@@ -1538,6 +1542,26 @@ function injectEndpoints(api: ClientState['backendApi']) {
15381542
invalidatesList(ReduxTag.Stacks)
15391543
]
15401544
}),
1545+
splitBranchIntoDependentBranch: build.mutation<
1546+
{ replacedCommits: [string, string][] },
1547+
{
1548+
projectId: string;
1549+
sourceStackId: string;
1550+
sourceBranchName: string;
1551+
newBranchName: string;
1552+
fileChangesToSplitOff: string[];
1553+
}
1554+
>({
1555+
extraOptions: {
1556+
command: 'split_branch_into_dependent_branch'
1557+
},
1558+
query: (args) => args,
1559+
invalidatesTags: (_result, _error, args) => [
1560+
invalidatesItem(ReduxTag.StackDetails, args.sourceStackId),
1561+
invalidatesItem(ReduxTag.BranchChanges, args.sourceStackId),
1562+
invalidatesList(ReduxTag.Stacks)
1563+
]
1564+
}),
15411565
stackDetailsUpdate: build.query<void, { projectId: string }>({
15421566
queryFn: () => ({ data: undefined }),
15431567
async onCacheEntryAdded(arg, lifecycleApi) {

crates/but-workspace/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ pub use tree_manipulation::{
4545
discard_worktree_changes::discard_workspace_changes,
4646
move_between_commits::move_changes_between_commits,
4747
remove_changes_from_commit_in_stack::remove_changes_from_commit_in_stack,
48-
split_branch::split_branch,
48+
split_branch::{split_branch, split_into_dependent_branch},
4949
split_commit::{CommitFiles, CommmitSplitOutcome, split_commit},
5050
};
5151
pub mod head;

crates/but-workspace/src/tree_manipulation/split_branch.rs

Lines changed: 180 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,13 @@ use but_core::Reference;
33
use but_rebase::Rebase;
44
use but_rebase::RebaseStep;
55
use but_rebase::ReferenceSpec;
6+
use gitbutler_cherry_pick::GixRepositoryExt;
67
use gitbutler_command_context::CommandContext;
78
use gitbutler_oxidize::ObjectIdExt;
89
use gitbutler_oxidize::OidExt;
910
use gitbutler_repo::logging::{LogUntil, RepositoryExt as _};
11+
use gitbutler_stack::CommitOrChangeId;
12+
use gitbutler_stack::StackBranch;
1013
use gitbutler_stack::{StackId, VirtualBranchesHandle};
1114

1215
use crate::MoveChangesResult;
@@ -128,6 +131,142 @@ pub fn split_branch(
128131
Ok((new_branch_ref, move_changes_result))
129132
}
130133

134+
/// Splits a branch into a dependent branch.
135+
///
136+
/// This function splits a branch into two dependent branches,
137+
/// moving the specified changes to the new branch (dependent branch).
138+
///
139+
/// In steps:
140+
///
141+
/// 1. Create a new branch from the source branch's head.
142+
/// 2. Remove all the specified changes from the source branch.
143+
/// 3. Remove all but the specified changes from the new branch.
144+
/// 4. Insert the new branch as a dependent branch in the stack.
145+
/// 5. Update the stack
146+
pub fn split_into_dependent_branch(
147+
ctx: &CommandContext,
148+
stack_id: StackId,
149+
source_branch_name: String,
150+
new_branch_name: String,
151+
file_changes_to_split_off: &[String],
152+
context_lines: u32,
153+
) -> Result<MoveChangesResult> {
154+
let repository = ctx.gix_repo()?;
155+
let vb_state = VirtualBranchesHandle::new(ctx.project().gb_dir());
156+
157+
let source_stack = vb_state.get_stack_in_workspace(stack_id)?;
158+
let merge_base = source_stack.merge_base(ctx)?;
159+
160+
// Create a new branch from the source branch's head
161+
let push_details = source_stack.push_details(ctx, source_branch_name.clone())?;
162+
let branch_head = push_details.head;
163+
164+
// Create a new branch reference
165+
let new_branch_ref_name = format!("refs/heads/{}", new_branch_name);
166+
let new_branch_log_message = format!(
167+
"Split off changes from branch '{}' into new dependent branch '{}'",
168+
source_branch_name, new_branch_name
169+
);
170+
171+
let new_ref = repository.reference(
172+
new_branch_ref_name.clone(),
173+
branch_head.to_gix(),
174+
gix::refs::transaction::PreviousValue::Any,
175+
new_branch_log_message.clone(),
176+
)?;
177+
178+
// Remove all but the specified changes from the new branch
179+
let new_branch_commits =
180+
ctx.repo()
181+
.l(branch_head, LogUntil::Commit(merge_base.to_git2()), false)?;
182+
183+
// Branch as rebase steps
184+
let mut dependent_branch_steps: Vec<RebaseStep> = Vec::new();
185+
186+
let reference_step = RebaseStep::Reference(but_core::Reference::Git(new_ref.name().to_owned()));
187+
dependent_branch_steps.push(reference_step);
188+
189+
for commit in new_branch_commits {
190+
let commit_id = commit.to_gix();
191+
if let Some(new_commit_id) = keep_only_file_changes_in_commit(
192+
ctx,
193+
commit_id,
194+
file_changes_to_split_off,
195+
context_lines,
196+
true,
197+
)? {
198+
let pick_step = RebaseStep::Pick {
199+
commit_id: new_commit_id,
200+
new_message: None,
201+
};
202+
dependent_branch_steps.push(pick_step);
203+
}
204+
}
205+
206+
println!(
207+
"Dependent branch steps before filtering: {:?}",
208+
dependent_branch_steps
209+
);
210+
211+
let steps = construct_source_steps(
212+
ctx,
213+
&repository,
214+
file_changes_to_split_off,
215+
&source_stack,
216+
source_branch_name.clone(),
217+
context_lines,
218+
Some(&dependent_branch_steps),
219+
)?;
220+
221+
println!(
222+
"Rebasing source branch '{}' with steps: {:?}",
223+
source_branch_name, steps
224+
);
225+
226+
let mut source_rebase = Rebase::new(&repository, merge_base, None)?;
227+
source_rebase.steps(steps)?;
228+
source_rebase.rebase_noops(false);
229+
let source_result = source_rebase.rebase()?;
230+
// let new_head = repo_git2.find_commit(source_result.top_commit.to_git2())?;
231+
let new_head = repository.find_commit(source_result.top_commit)?;
232+
233+
let mut source_stack = source_stack;
234+
235+
source_stack.add_series(
236+
ctx,
237+
StackBranch::new(
238+
CommitOrChangeId::CommitId(branch_head.to_string()),
239+
new_branch_name,
240+
None,
241+
&repository,
242+
)?,
243+
Some(source_branch_name),
244+
)?;
245+
246+
source_stack.set_stack_head(
247+
&vb_state,
248+
&repository,
249+
new_head.id().to_git2(),
250+
Some(
251+
repository
252+
.find_real_tree(&new_head.id(), Default::default())?
253+
.to_git2(),
254+
),
255+
)?;
256+
source_stack.set_heads_from_rebase_output(ctx, source_result.clone().references)?;
257+
258+
let move_changes_result = MoveChangesResult {
259+
replaced_commits: source_result
260+
.commit_mapping
261+
.iter()
262+
.filter(|(_, old, new)| old != new)
263+
.map(|(_, old, new)| (*old, *new))
264+
.collect(),
265+
};
266+
267+
Ok(move_changes_result)
268+
}
269+
131270
/// Filters out the specified file changes from the branch.
132271
///
133272
/// All commits that end up empty after removing the specified file changes will be dropped.
@@ -140,6 +279,37 @@ fn filter_file_changes_in_branch(
140279
merge_base: gix::ObjectId,
141280
context_lines: u32,
142281
) -> Result<but_rebase::RebaseOutput, anyhow::Error> {
282+
let source_steps = construct_source_steps(
283+
ctx,
284+
repository,
285+
file_changes_to_split_off,
286+
&source_stack,
287+
source_branch_name,
288+
context_lines,
289+
None,
290+
)?;
291+
292+
let mut source_rebase = Rebase::new(repository, merge_base, None)?;
293+
source_rebase.steps(source_steps)?;
294+
source_rebase.rebase_noops(false);
295+
let source_result = source_rebase.rebase()?;
296+
297+
let mut source_stack = source_stack;
298+
299+
source_stack.set_heads_from_rebase_output(ctx, source_result.clone().references)?;
300+
301+
Ok(source_result)
302+
}
303+
304+
fn construct_source_steps(
305+
ctx: &CommandContext,
306+
repository: &gix::Repository,
307+
file_changes_to_split_off: &[String],
308+
source_stack: &gitbutler_stack::Stack,
309+
source_branch_name: String,
310+
context_lines: u32,
311+
steps_to_insert: Option<&[RebaseStep]>,
312+
) -> Result<Vec<RebaseStep>, anyhow::Error> {
143313
let source_steps = source_stack.as_rebase_steps_rev(ctx, repository)?;
144314
let mut new_source_steps = Vec::new();
145315
let mut inside_branch = false;
@@ -153,6 +323,7 @@ fn filter_file_changes_in_branch(
153323
})?;
154324
let branch_ref_name = branch_ref.name().to_owned();
155325

326+
let mut inserted_steps = false;
156327
for step in source_steps {
157328
if let RebaseStep::Reference(but_core::Reference::Git(name)) = &step {
158329
if *name == branch_ref_name {
@@ -176,6 +347,14 @@ fn filter_file_changes_in_branch(
176347
continue;
177348
}
178349

350+
// Insert steps just above the branch reference step
351+
if !inserted_steps {
352+
if let Some(steps_to_insert) = steps_to_insert {
353+
inserted_steps = true;
354+
new_source_steps.extend(steps_to_insert.iter().cloned());
355+
}
356+
}
357+
179358
if let RebaseStep::Pick { commit_id, .. } = &step {
180359
match remove_file_changes_from_commit(
181360
ctx,
@@ -206,16 +385,6 @@ fn filter_file_changes_in_branch(
206385
new_source_steps.push(step);
207386
}
208387
}
209-
210388
new_source_steps.reverse();
211-
212-
let mut source_rebase = Rebase::new(repository, merge_base, None)?;
213-
source_rebase.steps(new_source_steps)?;
214-
source_rebase.rebase_noops(false);
215-
let source_result = source_rebase.rebase()?;
216-
217-
let mut source_stack = source_stack;
218-
source_stack.set_heads_from_rebase_output(ctx, source_result.clone().references)?;
219-
220-
Ok(source_result)
389+
Ok(new_source_steps)
221390
}

crates/gitbutler-tauri/src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,7 @@ fn main() {
299299
workspace::move_changes_between_commits,
300300
workspace::uncommit_changes,
301301
workspace::split_branch,
302+
workspace::split_branch_into_dependent_branch,
302303
diff::changes_in_worktree,
303304
diff::commit_details,
304305
diff::changes_in_branch,

crates/gitbutler-tauri/src/workspace.rs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,42 @@ pub fn split_branch(
366366
Ok(move_changes_result.into())
367367
}
368368

369+
#[allow(clippy::too_many_arguments)]
370+
#[tauri::command(async)]
371+
#[instrument(skip(projects, settings), err(Debug))]
372+
pub fn split_branch_into_dependent_branch(
373+
projects: State<'_, projects::Controller>,
374+
settings: State<'_, AppSettingsWithDiskSync>,
375+
project_id: ProjectId,
376+
source_stack_id: StackId,
377+
source_branch_name: String,
378+
new_branch_name: String,
379+
file_changes_to_split_off: Vec<String>,
380+
) -> Result<UIMoveChangesResult, Error> {
381+
let project = projects.get(project_id)?;
382+
let ctx = CommandContext::open(&project, settings.get()?.clone())?;
383+
let mut guard = project.exclusive_worktree_access();
384+
385+
let _ = ctx.create_snapshot(
386+
SnapshotDetails::new(OperationKind::SplitBranch),
387+
guard.write_permission(),
388+
);
389+
390+
let move_changes_result = but_workspace::split_into_dependent_branch(
391+
&ctx,
392+
source_stack_id,
393+
source_branch_name,
394+
new_branch_name.clone(),
395+
&file_changes_to_split_off,
396+
settings.get()?.context_lines,
397+
)?;
398+
399+
let vb_state = VirtualBranchesHandle::new(ctx.project().gb_dir());
400+
update_workspace_commit(&vb_state, &ctx)?;
401+
402+
Ok(move_changes_result.into())
403+
}
404+
369405
/// Uncommits the changes specified in the `diffspec`.
370406
///
371407
/// If `assign_to` is provided, the changes will be assigned to the stack

0 commit comments

Comments
 (0)