Skip to content

Commit c048ffa

Browse files
committed
Cover more cases when applying branches to prevent failures.
Otherwise, we think we applied the branch, but it's not enough due to ambiguity. Then the stack or segment wouldn't actually show up unless we make the right choice of adding it as dependent branch instead of stack.
1 parent 80143de commit c048ffa

File tree

8 files changed

+262
-13
lines changed

8 files changed

+262
-13
lines changed

crates/but-core/src/ref_metadata.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,28 @@ impl Workspace {
6868
}
6969
true
7070
}
71+
72+
/// Insert `branch` as new segment if it's not yet contained in the workspace,
73+
/// and insert it above the given `anchor` segment name, which maybe the tip of a stack or any segment within one
74+
/// Returns `true` if the ref was newly added, or `false` if it already existed, or `None` if `anchor` didn't exist.
75+
pub fn insert_new_segment_above_anchor_if_not_present(
76+
&mut self,
77+
branch: &FullNameRef,
78+
anchor: &FullNameRef,
79+
) -> Option<bool> {
80+
if self.contains_ref(branch) {
81+
return Some(false);
82+
};
83+
let (stack_idx, segment_idx) = self.find_owner_indexes_by_name(anchor)?;
84+
self.stacks[stack_idx].branches.insert(
85+
segment_idx,
86+
WorkspaceStackBranch {
87+
ref_name: branch.to_owned(),
88+
archived: false,
89+
},
90+
);
91+
Some(true)
92+
}
7193
}
7294

7395
/// Access

crates/but-core/tests/core/ref_metadata.rs

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,70 @@ mod workspace {
2020
assert_eq!(ws.stack_names().collect::<Vec<_>>(), [b_ref, a_ref, c_ref]);
2121
}
2222

23+
#[test]
24+
fn insert_new_segment_above_anchor_if_not_present() {
25+
let mut ws = Workspace::default();
26+
assert_eq!(ws.stacks.len(), 0);
27+
28+
let a_ref = r("refs/heads/A");
29+
let b_ref = r("refs/heads/B");
30+
assert_eq!(
31+
ws.insert_new_segment_above_anchor_if_not_present(b_ref, a_ref),
32+
None,
33+
"anchor doesn't exist"
34+
);
35+
assert!(ws.add_or_insert_new_stack_if_not_present(a_ref, None));
36+
assert_eq!(
37+
ws.insert_new_segment_above_anchor_if_not_present(b_ref, a_ref),
38+
Some(true),
39+
"anchor existed and it was added"
40+
);
41+
assert_eq!(
42+
ws.insert_new_segment_above_anchor_if_not_present(b_ref, a_ref),
43+
Some(false),
44+
"anchor existed and it was NOT added as it already existed"
45+
);
46+
47+
let c_ref = r("refs/heads/C");
48+
assert_eq!(
49+
ws.insert_new_segment_above_anchor_if_not_present(c_ref, a_ref),
50+
Some(true)
51+
);
52+
53+
insta::assert_snapshot!(but_testsupport::sanitize_uuids_and_timestamps(format!("{ws:#?}")), @r#"
54+
Workspace {
55+
ref_info: RefInfo { created_at: None, updated_at: None },
56+
stacks: [
57+
WorkspaceStack {
58+
id: 1,
59+
branches: [
60+
WorkspaceStackBranch {
61+
ref_name: FullName(
62+
"refs/heads/B",
63+
),
64+
archived: false,
65+
},
66+
WorkspaceStackBranch {
67+
ref_name: FullName(
68+
"refs/heads/C",
69+
),
70+
archived: false,
71+
},
72+
WorkspaceStackBranch {
73+
ref_name: FullName(
74+
"refs/heads/A",
75+
),
76+
archived: false,
77+
},
78+
],
79+
},
80+
],
81+
target_ref: None,
82+
push_remote: None,
83+
}
84+
"#);
85+
}
86+
2387
fn r(name: &str) -> &gix::refs::FullNameRef {
2488
name.try_into().expect("statically known ref")
2589
}

crates/but-workspace/src/branch/apply.rs

Lines changed: 70 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ pub(crate) mod function {
157157
repo,
158158
checkout::Options {
159159
uncommitted_changes,
160+
skip_head_update: false,
160161
},
161162
)?;
162163
let graph = workspace.graph.redo_traversal_with_overlay(
@@ -262,7 +263,39 @@ pub(crate) mod function {
262263
{
263264
let ws_mut: &mut Workspace = &mut ws_md;
264265
for rn in &branches_to_apply {
265-
ws_mut.add_or_insert_new_stack_if_not_present(rn, order);
266+
// Here we have to check if the new ref would be able to become its own stack,
267+
// or if it has to be a dependent branch. Stacks only work if the ref rests on a base
268+
// outside the workspace, so if we find it in the workspace (in an ambiguous spot) it must be
269+
// a dependent branch
270+
if let Some(segment_to_insert_above) = workspace
271+
.stacks
272+
.iter()
273+
.flat_map(|stack| stack.segments.iter())
274+
.find_map(|segment| {
275+
segment.commits.iter().flat_map(|c| c.refs.iter()).find_map(
276+
|ambiguous_rn| {
277+
(ambiguous_rn.as_ref() == *rn)
278+
.then_some(segment.ref_name.as_ref())
279+
.flatten()
280+
},
281+
)
282+
})
283+
{
284+
if ws_mut
285+
.insert_new_segment_above_anchor_if_not_present(
286+
rn,
287+
segment_to_insert_above.as_ref(),
288+
)
289+
.is_none()
290+
{
291+
// For now bail, until we know it's worth fixing this case automatically.
292+
bail!(
293+
"Missing reference {segment_to_insert_above} which should be known to workspace metadata to serve as insertion position for {rn}"
294+
);
295+
}
296+
} else {
297+
ws_mut.add_or_insert_new_stack_if_not_present(rn, order);
298+
}
266299
}
267300
}
268301
let ws_md_override = Some((workspace_ref_name_to_update.clone(), (*ws_md).clone()));
@@ -353,31 +386,55 @@ pub(crate) mod function {
353386
});
354387
}
355388

356-
// All work is done, persist and exit.
357-
// Note that it could be that some stacks aren't merged in,
358-
// while being present in the workspace metadata.
359-
// This is OK for us. We also trust that the hero-branch was merged in, no matter what.
360-
if let Some(storage) = in_memory_repo.objects.take_object_memory() {
361-
storage.persist(repo)?;
362-
drop(in_memory_repo);
363-
}
364389
let prev_head_id = graph
365390
.entrypoint_commit()
366391
.context("BUG: how is it possible that there is no head commit?")?
367392
.id;
368393
let new_head_id = merge_result.workspace_commit_id;
369394
let graph = graph.redo_traversal_with_overlay(
370-
repo,
395+
&in_memory_repo,
371396
meta,
372397
overlay.with_entrypoint(new_head_id, Some(workspace_ref_name_to_update.clone())),
373398
)?;
399+
400+
let workspace = graph.to_workspace()?;
401+
let unapplied_branches: Vec<_> = branches_to_apply
402+
.iter()
403+
.filter_map(|rn| {
404+
if workspace.refname_is_segment(rn) {
405+
None
406+
} else {
407+
Some(rn)
408+
}
409+
})
410+
.collect();
411+
if !unapplied_branches.is_empty() {
412+
bail!(
413+
"Unexpectedly failed to apply {branches} which is/are still not in the workspace",
414+
branches = unapplied_branches
415+
.iter()
416+
.map(|rn| rn.shorten().to_string())
417+
.collect::<Vec<_>>()
418+
.join(", ")
419+
)
420+
}
421+
422+
// All work is done, persist and exit.
423+
// Note that it could be that some stacks aren't merged in,
424+
// while being present in the workspace metadata.
425+
// This is OK for us. We also trust that the hero-branch was merged in, no matter what.
426+
if let Some(storage) = in_memory_repo.objects.take_object_memory() {
427+
storage.persist(repo)?;
428+
drop(in_memory_repo);
429+
}
374430
persist_metadata(meta, &branches_to_apply, &ws_md)?;
375431
crate::branch::safe_checkout(
376432
prev_head_id,
377433
new_head_id,
378434
repo,
379435
checkout::Options {
380436
uncommitted_changes,
437+
skip_head_update: true,
381438
},
382439
)?;
383440

@@ -416,10 +473,9 @@ pub(crate) mod function {
416473
new_ref_target: gix::ObjectId,
417474
new_ref: Option<&gix::refs::FullNameRef>,
418475
) -> anyhow::Result<()> {
419-
// This also means we want HEAD to point to it.
420-
let head_message = "GitButler switch to workspace during apply-branch".into();
421476
let edits = match new_ref {
422477
None => {
478+
let head_message = "GitButler checkout workspace during apply-branch".into();
423479
vec![RefEdit {
424480
change: Change::Update {
425481
log: LogChange {
@@ -435,6 +491,8 @@ pub(crate) mod function {
435491
}]
436492
}
437493
Some(new_ref) => {
494+
// This also means we want HEAD to point to it.
495+
let head_message = "GitButler switch to workspace during apply-branch".into();
438496
vec![
439497
RefEdit {
440498
change: Change::Update {

crates/but-workspace/src/branch/checkout/function.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ pub fn safe_checkout(
5050
repo: &gix::Repository,
5151
Options {
5252
uncommitted_changes: conflicting_worktree_changes_opts,
53+
skip_head_update,
5354
}: Options,
5455
) -> anyhow::Result<Outcome> {
5556
let source_tree = current_head_id.attach(repo).object()?.peel_to_tree()?;
@@ -138,7 +139,7 @@ pub fn safe_checkout(
138139
}
139140

140141
let mut head_update = None;
141-
if new_object.kind.is_commit() {
142+
if new_object.kind.is_commit() && !skip_head_update {
142143
let needs_update = repo
143144
.head()?
144145
.id()

crates/but-workspace/src/branch/checkout/mod.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ pub enum UncommitedWorktreeChanges {
1717
pub struct Options {
1818
/// How to deal with uncommitted changes.
1919
pub uncommitted_changes: UncommitedWorktreeChanges,
20+
/// If `true`, do not change `HEAD` to the new commit.
21+
///
22+
/// This is typically to be avoided, but may be used if you want to change the HEAD location yourself.
23+
pub skip_head_update: bool,
2024
}
2125

2226
/// The successful outcome of [super::safe_checkout()] operation.
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
#!/usr/bin/env bash
2+
3+
set -eu -o pipefail
4+
5+
source "${BASH_SOURCE[0]%/*}/shared.sh"
6+
7+
### General Description
8+
9+
# A target branch along with a stack and a depdendent branch at the same tip, without workspace commit.
10+
git init
11+
commit M
12+
setup_target_to_match_main
13+
git checkout -b A
14+
commit A1
15+
git branch D
16+
git branch E
17+
commit A2
18+
git branch B
19+
git branch C
20+
21+
git checkout main

crates/but-workspace/tests/workspace/branch/apply_unapply_commit_uncommit.rs

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -521,6 +521,84 @@ fn detached_head_journey() -> anyhow::Result<()> {
521521
Ok(())
522522
}
523523

524+
#[test]
525+
fn apply_two_ambiguous_stacks_with_target() -> anyhow::Result<()> {
526+
let (_tmp, graph, repo, mut meta, _description) =
527+
named_writable_scenario_with_description_and_graph(
528+
"no-ws-ref-stack-and-dependent-branch",
529+
|_meta| {},
530+
)?;
531+
insta::assert_snapshot!(visualize_commit_graph_all(&repo)?, @r"
532+
* f084d61 (C, B, A) A2
533+
* 7076dee (E, D) A1
534+
* 85efbe4 (HEAD -> main, origin/main) M
535+
");
536+
537+
let ws = graph.to_workspace()?;
538+
insta::assert_snapshot!(graph_workspace(&ws), @r"
539+
⌂:0:main <> ✓!
540+
└── ≡:0:main
541+
└── :0:main
542+
└── ·85efbe4
543+
");
544+
545+
// Apply `A` first.
546+
let out =
547+
but_workspace::branch::apply(r("refs/heads/A"), &ws, &repo, &mut meta, default_options())?;
548+
insta::assert_debug_snapshot!(out, @r"
549+
Outcome {
550+
workspace_changed: true,
551+
workspace_ref_created: true,
552+
}
553+
");
554+
let graph = out.graph;
555+
let ws = graph.to_workspace()?;
556+
insta::assert_snapshot!(graph_workspace(&ws), @r"
557+
📕🏘️:0:gitbutler/workspace <> ✓refs/remotes/origin/main on 85efbe4
558+
└── ≡📙:3:A on 85efbe4
559+
└── 📙:3:A
560+
├── ·f084d61 (🏘️) ►B, ►C
561+
└── ·7076dee (🏘️) ►D, ►E
562+
");
563+
insta::assert_snapshot!(visualize_commit_graph_all(&repo)?, @r"
564+
* 6a706b7 (HEAD -> gitbutler/workspace) GitButler Workspace Commit
565+
* f084d61 (C, B, A) A2
566+
* 7076dee (E, D) A1
567+
* 85efbe4 (origin/main, main) M
568+
");
569+
570+
// Apply `B` - the only sane way is to make it its own stack, but allow it to diverge.
571+
let out =
572+
but_workspace::branch::apply(r("refs/heads/B"), &ws, &repo, &mut meta, default_options())
573+
.expect("apply actually works");
574+
insta::assert_debug_snapshot!(out, @r"
575+
Outcome {
576+
workspace_changed: true,
577+
workspace_ref_created: false,
578+
}
579+
");
580+
581+
let graph = out.graph;
582+
let ws = graph.to_workspace()?;
583+
insta::assert_snapshot!(graph_workspace(&ws), @r"
584+
📕🏘️:0:gitbutler/workspace <> ✓refs/remotes/origin/main on 85efbe4
585+
└── ≡📙:4:B on 85efbe4
586+
├── 📙:4:B
587+
└── 📙:5:A
588+
├── ·f084d61 (🏘️) ►C
589+
└── ·7076dee (🏘️) ►D, ►E
590+
");
591+
insta::assert_snapshot!(visualize_commit_graph_all(&repo)?, @r"
592+
* badd1b4 (HEAD -> gitbutler/workspace) GitButler Workspace Commit
593+
* f084d61 (C, B, A) A2
594+
* 7076dee (E, D) A1
595+
* 85efbe4 (origin/main, main) M
596+
");
597+
598+
// TODO: add all other dependent branches as well.
599+
Ok(())
600+
}
601+
524602
#[test]
525603
fn auto_checkout_of_enclosing_workspace_flat() -> anyhow::Result<()> {
526604
let (_tmp, graph, repo, mut meta, _description) =

crates/but-workspace/tests/workspace/branch/checkout.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -827,6 +827,7 @@ fn unrelated_additions_do_not_affect_worktree_changes() -> anyhow::Result<()> {
827827
fn overwrite_options() -> checkout::Options {
828828
checkout::Options {
829829
uncommitted_changes: UncommitedWorktreeChanges::KeepConflictingInSnapshotAndOverwrite,
830+
skip_head_update: false,
830831
}
831832
}
832833

0 commit comments

Comments
 (0)