Skip to content

Commit 3ba762f

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 4523caa commit 3ba762f

File tree

9 files changed

+355
-16
lines changed

9 files changed

+355
-16
lines changed

crates/but-core/src/ref_metadata.rs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,27 @@ pub struct Workspace {
3636
pub push_remote: Option<String>,
3737
}
3838

39+
impl Workspace {}
40+
3941
/// Mutations
4042
impl Workspace {
43+
/// Remove the named segment `branch`, which removes the whole stack if it's empty after removing a segment
44+
/// of that name.
45+
/// Returns `true` if it was removed or `false` if it wasn't found.
46+
pub fn remove_segment(&mut self, branch: &FullNameRef) -> bool {
47+
let Some((stack_idx, segment_idx)) = self.find_owner_indexes_by_name(branch) else {
48+
return false;
49+
};
50+
51+
let stack = &mut self.stacks[stack_idx];
52+
stack.branches.remove(segment_idx);
53+
54+
if stack.branches.is_empty() {
55+
self.stacks.remove(stack_idx);
56+
}
57+
true
58+
}
59+
4160
/// Insert `branch` as new stack if it's not yet contained in the workspace and if `order` is not `None` or push
4261
/// it to the end of the stack list.
4362
/// Note that `order` is only relevant at insertion time.
@@ -68,6 +87,28 @@ impl Workspace {
6887
}
6988
true
7089
}
90+
91+
/// Insert `branch` as new segment if it's not yet contained in the workspace,
92+
/// and insert it above the given `anchor` segment name, which maybe the tip of a stack or any segment within one
93+
/// Returns `true` if the ref was newly added, or `false` if it already existed, or `None` if `anchor` didn't exist.
94+
pub fn insert_new_segment_above_anchor_if_not_present(
95+
&mut self,
96+
branch: &FullNameRef,
97+
anchor: &FullNameRef,
98+
) -> Option<bool> {
99+
if self.contains_ref(branch) {
100+
return Some(false);
101+
};
102+
let (stack_idx, segment_idx) = self.find_owner_indexes_by_name(anchor)?;
103+
self.stacks[stack_idx].branches.insert(
104+
segment_idx,
105+
WorkspaceStackBranch {
106+
ref_name: branch.to_owned(),
107+
archived: false,
108+
},
109+
);
110+
Some(true)
111+
}
71112
}
72113

73114
/// Access

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

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ mod workspace {
22
use but_core::ref_metadata::Workspace;
33

44
#[test]
5-
fn add_new_stack_if_not_present() {
5+
fn add_new_stack_if_not_present_journey() {
66
let mut ws = Workspace::default();
77
assert_eq!(ws.stacks.len(), 0);
88

@@ -18,6 +18,100 @@ mod workspace {
1818
let c_ref = r("refs/heads/C");
1919
assert!(ws.add_or_insert_new_stack_if_not_present(c_ref, None));
2020
assert_eq!(ws.stack_names().collect::<Vec<_>>(), [b_ref, a_ref, c_ref]);
21+
22+
assert!(ws.remove_segment(a_ref));
23+
assert!(ws.remove_segment(b_ref));
24+
assert!(!ws.remove_segment(b_ref));
25+
assert!(ws.remove_segment(c_ref));
26+
assert!(!ws.remove_segment(c_ref));
27+
28+
// Everything should be removed.
29+
insta::assert_debug_snapshot!(ws, @r"
30+
Workspace {
31+
ref_info: RefInfo { created_at: None, updated_at: None },
32+
stacks: [],
33+
target_ref: None,
34+
push_remote: None,
35+
}
36+
");
37+
}
38+
39+
#[test]
40+
fn insert_new_segment_above_anchor_if_not_present_journey() {
41+
let mut ws = Workspace::default();
42+
assert_eq!(ws.stacks.len(), 0);
43+
44+
let a_ref = r("refs/heads/A");
45+
let b_ref = r("refs/heads/B");
46+
assert_eq!(
47+
ws.insert_new_segment_above_anchor_if_not_present(b_ref, a_ref),
48+
None,
49+
"anchor doesn't exist"
50+
);
51+
assert!(ws.add_or_insert_new_stack_if_not_present(a_ref, None));
52+
assert_eq!(
53+
ws.insert_new_segment_above_anchor_if_not_present(b_ref, a_ref),
54+
Some(true),
55+
"anchor existed and it was added"
56+
);
57+
assert_eq!(
58+
ws.insert_new_segment_above_anchor_if_not_present(b_ref, a_ref),
59+
Some(false),
60+
"anchor existed and it was NOT added as it already existed"
61+
);
62+
63+
let c_ref = r("refs/heads/C");
64+
assert_eq!(
65+
ws.insert_new_segment_above_anchor_if_not_present(c_ref, a_ref),
66+
Some(true)
67+
);
68+
69+
insta::assert_snapshot!(but_testsupport::sanitize_uuids_and_timestamps(format!("{ws:#?}")), @r#"
70+
Workspace {
71+
ref_info: RefInfo { created_at: None, updated_at: None },
72+
stacks: [
73+
WorkspaceStack {
74+
id: 1,
75+
branches: [
76+
WorkspaceStackBranch {
77+
ref_name: FullName(
78+
"refs/heads/B",
79+
),
80+
archived: false,
81+
},
82+
WorkspaceStackBranch {
83+
ref_name: FullName(
84+
"refs/heads/C",
85+
),
86+
archived: false,
87+
},
88+
WorkspaceStackBranch {
89+
ref_name: FullName(
90+
"refs/heads/A",
91+
),
92+
archived: false,
93+
},
94+
],
95+
},
96+
],
97+
target_ref: None,
98+
push_remote: None,
99+
}
100+
"#);
101+
102+
assert!(ws.remove_segment(b_ref));
103+
assert!(ws.remove_segment(a_ref));
104+
assert!(ws.remove_segment(c_ref));
105+
106+
// Everything should be removed.
107+
insta::assert_debug_snapshot!(ws, @r"
108+
Workspace {
109+
ref_info: RefInfo { created_at: None, updated_at: None },
110+
stacks: [],
111+
target_ref: None,
112+
push_remote: None,
113+
}
114+
");
21115
}
22116

23117
fn r(name: &str) -> &gix::refs::FullNameRef {

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

Lines changed: 112 additions & 14 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(
@@ -335,8 +336,9 @@ pub(crate) mod function {
335336
);
336337
}
337338
// At this point, the workspace-metadata already knows the new branch(es), but the workspace itself
338-
// doesn't see one or more of to-be-applied branches. These are, however, part of the graph by now,
339-
// and we want to try to create a workspace merge.
339+
// doesn't see one or more of to-be-applied branches (to become stacks).
340+
// These are, however, part of the graph by now, and we want to try to create a workspace
341+
// merge.
340342
let mut in_memory_repo = repo.clone().for_tree_diffing()?.with_object_memory();
341343
let merge_result = WorkspaceCommit::from_new_merge_with_metadata(
342344
&ws_md.stacks,
@@ -353,6 +355,110 @@ pub(crate) mod function {
353355
});
354356
}
355357

358+
let prev_head_id = graph
359+
.entrypoint_commit()
360+
.context("BUG: how is it possible that there is no head commit?")?
361+
.id;
362+
let mut new_head_id = merge_result.workspace_commit_id;
363+
let overlay =
364+
overlay.with_entrypoint(new_head_id, Some(workspace_ref_name_to_update.clone()));
365+
let mut graph =
366+
graph.redo_traversal_with_overlay(&in_memory_repo, meta, overlay.clone())?;
367+
368+
let workspace = graph.to_workspace()?;
369+
let collect_unapplied_branches = |workspace: &but_graph::projection::Workspace| {
370+
branches_to_apply
371+
.iter()
372+
.filter(|rn| !workspace.refname_is_segment(rn))
373+
.collect::<Vec<_>>()
374+
};
375+
let unapplied_branches = collect_unapplied_branches(&workspace);
376+
if !unapplied_branches.is_empty() {
377+
// Now that the merge is done, try to redo the operation one last time with dependent branches instead.
378+
// Only do that for the still unapplied branches, which should always find some sort of anchor.
379+
let ws_mut: &mut Workspace = &mut ws_md;
380+
for branch_to_remove in &unapplied_branches {
381+
ws_mut.remove_segment(branch_to_remove);
382+
}
383+
for rn in &unapplied_branches {
384+
// Here we have to check if the new ref would be able to become its own stack,
385+
// or if it has to be a dependent branch. Stacks only work if the ref rests on a base
386+
// outside the workspace, so if we find it in the workspace (in an ambiguous spot) it must be
387+
// a dependent branch
388+
if let Some(segment_to_insert_above) = workspace
389+
.stacks
390+
.iter()
391+
.flat_map(|stack| stack.segments.iter())
392+
.find_map(|segment| {
393+
segment.commits.iter().flat_map(|c| c.refs.iter()).find_map(
394+
|ambiguous_rn| {
395+
(ambiguous_rn.as_ref() == **rn)
396+
.then_some(segment.ref_name.as_ref())
397+
.flatten()
398+
},
399+
)
400+
})
401+
{
402+
if ws_mut
403+
.insert_new_segment_above_anchor_if_not_present(
404+
rn,
405+
segment_to_insert_above.as_ref(),
406+
)
407+
.is_none()
408+
{
409+
// For now bail, until we know it's worth fixing this case automatically.
410+
bail!(
411+
"Missing reference {segment_to_insert_above} which should be known to workspace metadata to serve as insertion position for {rn}"
412+
);
413+
}
414+
} else {
415+
bail!(
416+
"Unexpectedly failed to find anchor for {rn} to make it a dependent branch"
417+
)
418+
}
419+
}
420+
421+
// Redo the merge, with the different stack configuration.
422+
// Note that this is the exception, typically using stacks will be fine.
423+
let merge_result = WorkspaceCommit::from_new_merge_with_metadata(
424+
&ws_md.stacks,
425+
workspace.graph,
426+
&in_memory_repo,
427+
Some(branch),
428+
)?;
429+
430+
if merge_result.has_conflicts() && on_workspace_conflict.should_abort() {
431+
return Ok(Outcome {
432+
graph: Cow::Owned(graph),
433+
workspace_ref_created: needs_ws_ref_creation,
434+
workspace_merge: Some(merge_result),
435+
});
436+
}
437+
new_head_id = merge_result.workspace_commit_id;
438+
let ws_md_override = Some((workspace_ref_name_to_update.clone(), (*ws_md).clone()));
439+
440+
graph = graph.redo_traversal_with_overlay(
441+
&in_memory_repo,
442+
meta,
443+
overlay
444+
.with_entrypoint(new_head_id, Some(workspace_ref_name_to_update.clone()))
445+
.with_workspace_metadata_override(ws_md_override),
446+
)?;
447+
let workspace = graph.to_workspace()?;
448+
let unapplied_branches = collect_unapplied_branches(&workspace);
449+
450+
if !unapplied_branches.is_empty() {
451+
bail!(
452+
"Unexpectedly failed to apply {branches} which is/are still not in the workspace",
453+
branches = unapplied_branches
454+
.iter()
455+
.map(|rn| rn.shorten().to_string())
456+
.collect::<Vec<_>>()
457+
.join(", ")
458+
)
459+
}
460+
}
461+
356462
// All work is done, persist and exit.
357463
// Note that it could be that some stacks aren't merged in,
358464
// while being present in the workspace metadata.
@@ -361,23 +467,14 @@ pub(crate) mod function {
361467
storage.persist(repo)?;
362468
drop(in_memory_repo);
363469
}
364-
let prev_head_id = graph
365-
.entrypoint_commit()
366-
.context("BUG: how is it possible that there is no head commit?")?
367-
.id;
368-
let new_head_id = merge_result.workspace_commit_id;
369-
let graph = graph.redo_traversal_with_overlay(
370-
repo,
371-
meta,
372-
overlay.with_entrypoint(new_head_id, Some(workspace_ref_name_to_update.clone())),
373-
)?;
374470
persist_metadata(meta, &branches_to_apply, &ws_md)?;
375471
crate::branch::safe_checkout(
376472
prev_head_id,
377473
new_head_id,
378474
repo,
379475
checkout::Options {
380476
uncommitted_changes,
477+
skip_head_update: true,
381478
},
382479
)?;
383480

@@ -416,10 +513,9 @@ pub(crate) mod function {
416513
new_ref_target: gix::ObjectId,
417514
new_ref: Option<&gix::refs::FullNameRef>,
418515
) -> 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();
421516
let edits = match new_ref {
422517
None => {
518+
let head_message = "GitButler checkout workspace during apply-branch".into();
423519
vec![RefEdit {
424520
change: Change::Update {
425521
log: LogChange {
@@ -435,6 +531,8 @@ pub(crate) mod function {
435531
}]
436532
}
437533
Some(new_ref) => {
534+
// This also means we want HEAD to point to it.
535+
let head_message = "GitButler switch to workspace during apply-branch".into();
438536
vec![
439537
RefEdit {
440538
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

0 commit comments

Comments
 (0)