Skip to content

Commit 2cce29d

Browse files
feat(split): add --detach flag
1 parent 951cf28 commit 2cce29d

File tree

4 files changed

+157
-52
lines changed

4 files changed

+157
-52
lines changed

git-branchless-opts/src/lib.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -662,6 +662,10 @@ pub enum Command {
662662
#[clap(value_parser, required = true)]
663663
files: Vec<String>,
664664

665+
/// Restack any descendents onto the split commit, not the extracted commit.
666+
#[clap(action, short = 'd', long = "detach")]
667+
detach: bool,
668+
665669
/// Options for resolving revset expressions.
666670
#[clap(flatten)]
667671
resolve_revset_options: ResolveRevsetOptions,

git-branchless/src/commands/mod.rs

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -181,18 +181,27 @@ fn command_main(ctx: CommandContext, opts: Opts) -> EyreExitOr<()> {
181181
},
182182

183183
Command::Split {
184+
detach,
184185
files,
185186
resolve_revset_options,
186187
revset,
187188
move_options,
188-
} => split::split(
189-
&effects,
190-
revset,
191-
&resolve_revset_options,
192-
files,
193-
&move_options,
194-
&git_run_info,
195-
)?,
189+
} => {
190+
let split_mode = match detach {
191+
true => split::SplitMode::DetachAfter,
192+
false => split::SplitMode::InsertAfter,
193+
};
194+
195+
split::split(
196+
&effects,
197+
revset,
198+
&resolve_revset_options,
199+
files,
200+
split_mode,
201+
&move_options,
202+
&git_run_info,
203+
)?
204+
}
196205

197206
Command::Submit(args) => git_branchless_submit::command_main(ctx, args)?,
198207

git-branchless/src/commands/split.rs

Lines changed: 73 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,21 @@ use lib::{
3434
};
3535
use tracing::instrument;
3636

37+
#[derive(Debug, PartialEq)]
38+
/// What should `split` do with the extracted changes?
39+
pub enum SplitMode {
40+
DetachAfter,
41+
InsertAfter,
42+
}
43+
3744
/// Split a commit and restack its descendants.
3845
#[instrument]
3946
pub fn split(
4047
effects: &Effects,
4148
revset: Revset,
4249
resolve_revset_options: &ResolveRevsetOptions,
4350
files_to_extract: Vec<String>,
51+
split_mode: SplitMode,
4452
move_options: &MoveOptions,
4553
git_run_info: &GitRunInfo,
4654
) -> EyreExitOr<()> {
@@ -117,15 +125,19 @@ pub fn split(
117125
let target_commit = repo.find_commit_or_fail(target_oid)?;
118126
let target_tree = target_commit.get_tree()?;
119127
let parent_commits = target_commit.get_parents();
120-
let (parent_tree, mut remainder_tree) = match parent_commits.as_slice() {
128+
let (parent_tree, mut remainder_tree) = match (&split_mode, parent_commits.as_slice()) {
121129
// split the commit by removing the changes from the target, and then
122130
// cherry picking the orignal target as the "extracted" commit
123-
[only_parent] => (only_parent.get_tree()?, target_commit.get_tree()?),
131+
(SplitMode::InsertAfter, [only_parent]) | (SplitMode::DetachAfter, [only_parent]) => {
132+
(only_parent.get_tree()?, target_commit.get_tree()?)
133+
}
124134

125135
// no parent: use an empty tree for comparison
126-
[] => (make_empty_tree(&repo)?, target_commit.get_tree()?),
136+
(SplitMode::InsertAfter, []) | (SplitMode::DetachAfter, []) => {
137+
(make_empty_tree(&repo)?, target_commit.get_tree()?)
138+
}
127139

128-
[..] => {
140+
(_, [..]) => {
129141
writeln!(
130142
effects.get_error_stream(),
131143
"Cannot split merge commit {}.",
@@ -204,14 +216,18 @@ pub fn split(
204216
};
205217

206218
let target_entry = target_tree.get_path(path)?;
207-
let temp_tree_oid = match (parent_entry, target_entry) {
219+
let temp_tree_oid = match (parent_entry, target_entry, &split_mode) {
208220
// added => remove from remainder commit
209-
(None, Some(_)) => remainder_tree.remove(&repo, path)?,
221+
(None, Some(_), SplitMode::InsertAfter) | (None, Some(_), SplitMode::DetachAfter) => {
222+
remainder_tree.remove(&repo, path)?
223+
}
210224

211225
// deleted or modified => replace w/ parent content in split commit
212-
(Some(parent_entry), _) => remainder_tree.add_or_replace(&repo, path, &parent_entry)?,
226+
(Some(parent_entry), _, _) => {
227+
remainder_tree.add_or_replace(&repo, path, &parent_entry)?
228+
}
213229

214-
(None, _) => {
230+
(None, _, _) => {
215231
if path.exists() {
216232
writeln!(
217233
effects.get_error_stream(),
@@ -263,35 +279,37 @@ pub fn split(
263279
new_commit_oid: MaybeZeroOid::NonZero(remainder_commit_oid),
264280
}])?;
265281

266-
let extracted_commit_oid = {
267-
let extracted_tree = repo.cherry_pick_fast(
268-
&target_commit,
269-
&remainder_commit,
270-
&CherryPickFastOptions {
271-
reuse_parent_tree_if_possible: true,
272-
},
273-
)?;
274-
let extracted_commit_oid = repo.create_commit(
275-
None,
276-
&target_commit.get_author(),
277-
&target_commit.get_committer(),
278-
format!("temp(split): {message}").as_str(),
279-
&extracted_tree,
280-
vec![&remainder_commit],
281-
)?;
282+
let extracted_commit_oid = match split_mode {
283+
SplitMode::InsertAfter | SplitMode::DetachAfter => {
284+
let extracted_tree = repo.cherry_pick_fast(
285+
&target_commit,
286+
&remainder_commit,
287+
&CherryPickFastOptions {
288+
reuse_parent_tree_if_possible: true,
289+
},
290+
)?;
291+
let extracted_commit_oid = repo.create_commit(
292+
None,
293+
&target_commit.get_author(),
294+
&target_commit.get_committer(),
295+
format!("temp(split): {message}").as_str(),
296+
&extracted_tree,
297+
vec![&remainder_commit],
298+
)?;
282299

283-
// see git-branchless/src/commands/amend.rs:172
284-
// TODO maybe this should happen after we've confirmed the rebase has succeeded
285-
mark_commit_reachable(&repo, extracted_commit_oid)
286-
.wrap_err("Marking commit as reachable for GC purposes.")?;
300+
// see git-branchless/src/commands/amend.rs:172
301+
// TODO maybe this should happen after we've confirmed the rebase has succeeded?
302+
mark_commit_reachable(&repo, extracted_commit_oid)
303+
.wrap_err("Marking commit as reachable for GC purposes.")?;
287304

288-
event_log_db.add_events(vec![Event::CommitEvent {
289-
timestamp: now.duration_since(UNIX_EPOCH)?.as_secs_f64(),
290-
event_tx_id,
291-
commit_oid: extracted_commit_oid,
292-
}])?;
305+
event_log_db.add_events(vec![Event::CommitEvent {
306+
timestamp: now.duration_since(UNIX_EPOCH)?.as_secs_f64(),
307+
event_tx_id,
308+
commit_oid: extracted_commit_oid,
309+
}])?;
293310

294-
Some(extracted_commit_oid)
311+
Some(extracted_commit_oid)
312+
}
295313
};
296314

297315
// push the new commits into the dag for the rebase planner
@@ -338,23 +356,25 @@ pub fn split(
338356
rewritten_oids: Vec<(NonZeroOid, MaybeZeroOid)>,
339357
}
340358

341-
let cleanup = match (target_state, extracted_commit_oid) {
359+
let cleanup = match (target_state, &split_mode, extracted_commit_oid) {
342360
// branch @ split commit checked out: extend branch to include extracted
343361
// commit; branch will stay checked out w/o any explicit checkout
344-
(TargetState::CurrentBranch, Some(extracted_commit_oid)) => CleanUp {
345-
checkout_target: None,
346-
rewritten_oids: vec![(target_oid, MaybeZeroOid::NonZero(extracted_commit_oid))],
347-
},
362+
(TargetState::CurrentBranch, SplitMode::InsertAfter, Some(extracted_commit_oid)) => {
363+
CleanUp {
364+
checkout_target: None,
365+
rewritten_oids: vec![(target_oid, MaybeZeroOid::NonZero(extracted_commit_oid))],
366+
}
367+
}
348368

349369
// commit to split checked out as detached HEAD, don't extend any
350370
// branches, but explicitly check out the newly split commit
351-
(TargetState::DetachedHead, _) => CleanUp {
371+
(TargetState::DetachedHead, _, _) => CleanUp {
352372
checkout_target: Some(CheckoutTarget::Oid(remainder_commit_oid)),
353373
rewritten_oids: vec![(target_oid, MaybeZeroOid::NonZero(remainder_commit_oid))],
354374
},
355375

356376
// some other commit or branch was checked out, default behavior is fine
357-
(TargetState::CurrentBranch, _) | (TargetState::Other, _) => CleanUp {
377+
(TargetState::CurrentBranch, _, _) | (TargetState::Other, _, _) => CleanUp {
358378
checkout_target: None,
359379
rewritten_oids: vec![(target_oid, MaybeZeroOid::NonZero(remainder_commit_oid))],
360380
},
@@ -393,9 +413,18 @@ pub fn split(
393413
let mut builder = RebasePlanBuilder::new(&dag, permissions);
394414
let children = dag.query_children(CommitSet::from(target_oid))?;
395415
for child in dag.commit_set_to_vec(&children)? {
396-
match extracted_commit_oid {
397-
None => builder.move_subtree(child, vec![remainder_commit_oid])?,
398-
Some(extracted_commit_oid) => {
416+
match (&split_mode, extracted_commit_oid) {
417+
(_, None) => builder.move_subtree(child, vec![remainder_commit_oid])?,
418+
(_, Some(extracted_commit_oid)) => {
419+
builder.move_subtree(child, vec![extracted_commit_oid])?
420+
}
421+
}
422+
423+
match (&split_mode, extracted_commit_oid) {
424+
(_, None) | (SplitMode::DetachAfter, Some(_)) => {
425+
builder.move_subtree(child, vec![remainder_commit_oid])?
426+
}
427+
(_, Some(extracted_commit_oid)) => {
399428
builder.move_subtree(child, vec![extracted_commit_oid])?
400429
}
401430
}

git-branchless/tests/test_split.rs

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,69 @@ fn test_split_restacks_descendents() -> eyre::Result<()> {
439439
Ok(())
440440
}
441441

442+
#[test]
443+
fn test_split_detach() -> eyre::Result<()> {
444+
let git = make_git()?;
445+
git.init_repo()?;
446+
git.detach_head()?;
447+
448+
git.write_file_txt("test1", "contents1")?;
449+
git.write_file_txt("test2", "contents2")?;
450+
git.write_file_txt("test3", "contents3")?;
451+
git.run(&["add", "."])?;
452+
git.run(&["commit", "-m", "first commit"])?;
453+
454+
git.commit_file("test3", 1)?;
455+
456+
{
457+
let (stdout, _stderr) = git.branchless("smartlog", &[])?;
458+
insta::assert_snapshot!(stdout, @r###"
459+
O f777ecc (master) create initial.txt
460+
|
461+
o e48cdc5 first commit
462+
|
463+
@ 3d220e0 create test3.txt
464+
"###);
465+
}
466+
467+
{
468+
let (stdout, _stderr) = git.branchless("split", &["HEAD~", "test2.txt", "--detach"])?;
469+
insta::assert_snapshot!(&stdout, @r###"
470+
Attempting rebase in-memory...
471+
[1/1] Committed as: f88fbe5 create test3.txt
472+
branchless: processing 1 rewritten commit
473+
branchless: running command: <git-executable> checkout f88fbe5901493ffe1c669cdb8aa5f056dc0bb605
474+
In-memory rebase succeeded.
475+
O f777ecc (master) create initial.txt
476+
|
477+
o 2932db7 first commit
478+
|\
479+
| o 01523cc temp(split): test2.txt (+1)
480+
|
481+
@ f88fbe5 create test3.txt
482+
"###);
483+
}
484+
485+
{
486+
let (stdout, _stderr) = git.run(&["show", "--pretty=format:", "--stat", "HEAD~"])?;
487+
insta::assert_snapshot!(&stdout, @"
488+
test1.txt | 1 +
489+
test3.txt | 1 +
490+
2 files changed, 2 insertions(+)
491+
");
492+
493+
let (split_commit, _stderr) = git.run(&["query", "--raw", "exactly(siblings(HEAD), 1)"])?;
494+
let (stdout, _stderr) =
495+
git.run(&["show", "--pretty=format:", "--stat", split_commit.trim()])?;
496+
insta::assert_snapshot!(&stdout, @"
497+
test2.txt | 1 +
498+
1 file changed, 1 insertion(+)
499+
");
500+
}
501+
502+
Ok(())
503+
}
504+
442505
#[test]
443506
fn test_split_undo_works() -> eyre::Result<()> {
444507
let git = make_git()?;

0 commit comments

Comments
 (0)