Skip to content

Commit 46555ab

Browse files
committed
fix: resolve unmount repoint target past other unmounted ancestors
When sync unmounts a chain of closed-PR branches (e.g. A ← B ← C all merged), each entry stored its original parent as repoint_children_to. Applied in HashMap order, an earlier unmount could delete the parent a later unmount still expected to find, causing State::mount to bail with "Parent branch not found in the git-stack tree". Split compute_sync_plan's unmount handling into two passes: collect the full unmount set first, then resolve each branch's repoint target by walking up the parent chain and skipping any ancestor also being unmounted, falling back to trunk. The resolved value is used for both UnmountBranch and the open-PR RetargetPr it emits, so plan output and apply now agree on execution-time-accurate targets.
1 parent 5cc4fa2 commit 46555ab

File tree

1 file changed

+144
-41
lines changed

1 file changed

+144
-41
lines changed

src/sync.rs

Lines changed: 144 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -682,6 +682,32 @@ fn build_target_state(git_repo: &GitRepo, local: &LocalState, remote: &RemoteSta
682682

683683
// ============== Stage 3: Diff Functions ==============
684684

685+
/// Walk up the parent chain from `branch`, skipping ancestors that are themselves
686+
/// being unmounted, and return the first surviving ancestor (falling back to
687+
/// `trunk` when we run off the top of the chain).
688+
fn resolve_repoint(
689+
branch: &str,
690+
branches: &HashMap<String, LocalBranch>,
691+
trunk: &str,
692+
unmount_set: &HashSet<String>,
693+
) -> String {
694+
let mut visited: HashSet<String> = HashSet::new();
695+
let mut candidate: Option<String> = branches.get(branch).and_then(|b| b.parent.clone());
696+
697+
while let Some(name) = candidate {
698+
if name == trunk || !unmount_set.contains(&name) {
699+
return name;
700+
}
701+
if !visited.insert(name.clone()) {
702+
// Cycle guard (shouldn't happen in a well-formed tree).
703+
return trunk.to_string();
704+
}
705+
candidate = branches.get(&name).and_then(|b| b.parent.clone());
706+
}
707+
708+
trunk.to_string()
709+
}
710+
685711
/// Compute the sync plan by diffing current state against target
686712
fn compute_sync_plan(
687713
git_repo: &GitRepo,
@@ -807,7 +833,6 @@ fn compute_sync_plan(
807833

808834
// Detect merged/closed PRs and handle unmounting (pull direction)
809835
// This runs regardless of push_only/pull_only since it's about reconciling state
810-
let mut branches_to_unmount: Vec<(String, String)> = Vec::new(); // (branch, repoint_to)
811836
let mut branches_to_delete: Vec<String> = Vec::new();
812837

813838
tracing::debug!(
@@ -816,53 +841,60 @@ fn compute_sync_plan(
816841
remote.closed_prs.keys().collect::<Vec<_>>()
817842
);
818843

819-
for (branch_name, local_branch) in &local.branches {
820-
// Skip trunk
844+
// First pass: collect the raw set of branches to unmount (eligible by closed PR).
845+
let mut unmount_set: HashSet<String> = HashSet::new();
846+
for branch_name in local.branches.keys() {
821847
if branch_name == &local.trunk {
822848
continue;
823849
}
824-
825-
// Check if this branch has a merged/closed PR but no open PR
826850
if !remote.prs.contains_key(branch_name)
827851
&& let Some(closed_pr) = remote.closed_prs.get(branch_name)
828-
{
829-
tracing::debug!(
830-
"Branch '{}' has closed PR #{} with state {:?}",
831-
branch_name,
832-
closed_pr.number,
833-
closed_pr.state
834-
);
835-
836-
// Any closed PR (merged or just closed) should unmount from git-stack
837-
if matches!(
852+
&& matches!(
838853
closed_pr.state,
839854
RemotePrState::Merged | RemotePrState::Closed
840-
) {
841-
// This branch's PR was merged/closed - it should be unmounted
842-
// Children should be repointed to this branch's parent
843-
let repoint_to = local_branch
844-
.parent
845-
.clone()
846-
.unwrap_or_else(|| local.trunk.clone());
847-
branches_to_unmount.push((branch_name.clone(), repoint_to));
848-
849-
// Determine if local branch is safe to delete
850-
// Safe if: merged, OR (closed AND remote exists AND local is ancestor of remote)
851-
let safe_to_delete = if closed_pr.state == RemotePrState::Merged {
852-
true
853-
} else {
854-
// Closed but not merged - check if remote has our work
855-
let remote_ref = format!("{}/{}", DEFAULT_REMOTE, branch_name);
856-
git_repo.ref_exists(&remote_ref)
857-
&& git_repo
858-
.is_ancestor(branch_name, &remote_ref)
859-
.unwrap_or(false)
860-
};
861-
862-
if safe_to_delete {
863-
branches_to_delete.push(branch_name.clone());
864-
}
865-
}
855+
)
856+
{
857+
unmount_set.insert(branch_name.clone());
858+
}
859+
}
860+
861+
// Second pass: emit unmount entries with transitively-resolved repoint targets,
862+
// and compute safe-to-delete.
863+
let mut branches_to_unmount: Vec<(String, String)> = Vec::new(); // (branch, repoint_to)
864+
for branch_name in local.branches.keys() {
865+
if !unmount_set.contains(branch_name) {
866+
continue;
867+
}
868+
let closed_pr = remote
869+
.closed_prs
870+
.get(branch_name)
871+
.expect("unmount_set membership implies closed PR");
872+
873+
tracing::debug!(
874+
"Branch '{}' has closed PR #{} with state {:?}",
875+
branch_name,
876+
closed_pr.number,
877+
closed_pr.state
878+
);
879+
880+
let repoint_to = resolve_repoint(branch_name, &local.branches, &local.trunk, &unmount_set);
881+
branches_to_unmount.push((branch_name.clone(), repoint_to));
882+
883+
// Determine if local branch is safe to delete
884+
// Safe if: merged, OR (closed AND remote exists AND local is ancestor of remote)
885+
let safe_to_delete = if closed_pr.state == RemotePrState::Merged {
886+
true
887+
} else {
888+
// Closed but not merged - check if remote has our work
889+
let remote_ref = format!("{}/{}", DEFAULT_REMOTE, branch_name);
890+
git_repo.ref_exists(&remote_ref)
891+
&& git_repo
892+
.is_ancestor(branch_name, &remote_ref)
893+
.unwrap_or(false)
894+
};
895+
896+
if safe_to_delete {
897+
branches_to_delete.push(branch_name.clone());
866898
}
867899
}
868900

@@ -1567,3 +1599,74 @@ fn find_branch_by_name_mut<'a>(tree: &'a mut Branch, name: &str) -> Option<&'a m
15671599
}
15681600
None
15691601
}
1602+
1603+
#[cfg(test)]
1604+
mod tests {
1605+
use super::*;
1606+
1607+
fn lb(parent: Option<&str>) -> LocalBranch {
1608+
LocalBranch {
1609+
parent: parent.map(String::from),
1610+
pr_number: None,
1611+
pushed_to_remote: true,
1612+
}
1613+
}
1614+
1615+
#[test]
1616+
fn resolve_repoint_skips_chain_of_unmounted_ancestors() {
1617+
// Two chains on top of main, all closed-PR → all in unmount_set.
1618+
//
1619+
// main ← grpc-level-08 ← grpc-level-09 ← grpc-level-10 (leaf)
1620+
// main ← sadhan/09 ← sadhan/12 ← sadhan/15 ← sadhan/17
1621+
let trunk = "main".to_string();
1622+
let mut branches: HashMap<String, LocalBranch> = HashMap::new();
1623+
branches.insert("grpc-level-08".into(), lb(Some("main")));
1624+
branches.insert("grpc-level-09".into(), lb(Some("grpc-level-08")));
1625+
branches.insert("grpc-level-10".into(), lb(Some("grpc-level-09")));
1626+
branches.insert("sadhan/09".into(), lb(Some("main")));
1627+
branches.insert("sadhan/12".into(), lb(Some("sadhan/09")));
1628+
branches.insert("sadhan/15".into(), lb(Some("sadhan/12")));
1629+
branches.insert("sadhan/17".into(), lb(Some("sadhan/15")));
1630+
1631+
let unmount_set: HashSet<String> = branches.keys().cloned().collect();
1632+
1633+
for branch in branches.keys() {
1634+
let got = resolve_repoint(branch, &branches, &trunk, &unmount_set);
1635+
assert_eq!(
1636+
got, trunk,
1637+
"branch '{branch}' should resolve past all unmounted ancestors to trunk, got '{got}'"
1638+
);
1639+
}
1640+
}
1641+
1642+
#[test]
1643+
fn resolve_repoint_stops_at_first_surviving_ancestor() {
1644+
// main ← A (live) ← B (unmount) ← C (unmount) ← D (leaf, unmount)
1645+
// D, C, B are being unmounted; A survives.
1646+
// Expected: D and C and B all repoint to A.
1647+
let trunk = "main".to_string();
1648+
let mut branches: HashMap<String, LocalBranch> = HashMap::new();
1649+
branches.insert("A".into(), lb(Some("main")));
1650+
branches.insert("B".into(), lb(Some("A")));
1651+
branches.insert("C".into(), lb(Some("B")));
1652+
branches.insert("D".into(), lb(Some("C")));
1653+
1654+
let unmount_set: HashSet<String> = ["B", "C", "D"].iter().map(|s| s.to_string()).collect();
1655+
1656+
assert_eq!(resolve_repoint("D", &branches, &trunk, &unmount_set), "A");
1657+
assert_eq!(resolve_repoint("C", &branches, &trunk, &unmount_set), "A");
1658+
assert_eq!(resolve_repoint("B", &branches, &trunk, &unmount_set), "A");
1659+
}
1660+
1661+
#[test]
1662+
fn resolve_repoint_falls_back_to_trunk_when_no_parent() {
1663+
let trunk = "main".to_string();
1664+
let mut branches: HashMap<String, LocalBranch> = HashMap::new();
1665+
branches.insert("orphan".into(), lb(None));
1666+
let unmount_set: HashSet<String> = HashSet::new();
1667+
assert_eq!(
1668+
resolve_repoint("orphan", &branches, &trunk, &unmount_set),
1669+
trunk
1670+
);
1671+
}
1672+
}

0 commit comments

Comments
 (0)