Skip to content

Commit cced2c9

Browse files
committed
fix(sync): drop local merged parents before cleanup
1 parent 2dbd3b3 commit cced2c9

File tree

2 files changed

+170
-2
lines changed

2 files changed

+170
-2
lines changed

src/core/sync.rs

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@ use crate::core::deleted_local;
77
use crate::core::gh::{self, PullRequestState, PullRequestStatus};
88
use crate::core::graph::BranchGraph;
99
use crate::core::restack::{self, RestackAction, RestackPreview};
10+
use crate::core::store::types::DigState;
1011
use crate::core::store::{
11-
BranchNode, PendingOperationKind, PendingOperationState, PendingSyncOperation,
12+
BranchNode, ParentRef, PendingOperationKind, PendingOperationState, PendingSyncOperation,
1213
PendingSyncPhase, clear_operation, load_operation, open_initialized,
1314
};
1415
use crate::core::workflow;
@@ -256,8 +257,10 @@ fn run_full_sync() -> io::Result<SyncOutcome> {
256257
} else {
257258
Vec::new()
258259
};
259-
260260
let original_branch = git::current_branch_name()?;
261+
if remote_sync_enabled {
262+
delete_local_branches_merged_into_deleted_parent_branches(&session, &original_branch)?;
263+
}
261264
let outcome = execute_local_sync(
262265
&mut session,
263266
original_branch,
@@ -670,6 +673,73 @@ fn repair_closed_pull_requests_for_deleted_parent_branches(
670673
Ok(repaired_pull_requests)
671674
}
672675

676+
fn delete_local_branches_merged_into_deleted_parent_branches(
677+
session: &crate::core::store::StoreSession,
678+
current_branch_name: &str,
679+
) -> io::Result<()> {
680+
let graph = BranchGraph::new(&session.state);
681+
let mut candidates = session
682+
.state
683+
.nodes
684+
.iter()
685+
.filter(|node| !node.archived)
686+
.cloned()
687+
.collect::<Vec<_>>();
688+
689+
candidates.sort_by(|left, right| {
690+
graph
691+
.branch_depth(right.id)
692+
.cmp(&graph.branch_depth(left.id))
693+
.then_with(|| left.branch_name.cmp(&right.branch_name))
694+
});
695+
696+
for node in candidates {
697+
if node.branch_name == current_branch_name || !git::branch_exists(&node.branch_name)? {
698+
continue;
699+
}
700+
if !parent_branch_is_unavailable_for_sync_cleanup(&session.state, &node)? {
701+
continue;
702+
}
703+
704+
let Some(remote_target) = git::branch_push_target(&node.branch_name)? else {
705+
continue;
706+
};
707+
if git::remote_tracking_branch_exists(
708+
&remote_target.remote_name,
709+
&remote_target.branch_name,
710+
)? {
711+
continue;
712+
}
713+
if merged_pull_request_restore_source(&node)?.is_none() {
714+
continue;
715+
}
716+
717+
let delete_status = git::delete_branch_force(&node.branch_name)?;
718+
if !delete_status.success() {
719+
return Err(io::Error::other(format!(
720+
"failed to remove merged local branch '{}' before sync cleanup",
721+
node.branch_name
722+
)));
723+
}
724+
}
725+
726+
Ok(())
727+
}
728+
729+
fn parent_branch_is_unavailable_for_sync_cleanup(
730+
state: &DigState,
731+
node: &BranchNode,
732+
) -> io::Result<bool> {
733+
let ParentRef::Branch { node_id } = node.parent else {
734+
return Ok(false);
735+
};
736+
let Some(parent_node) = state.find_any_branch_by_id(node_id) else {
737+
return Ok(false);
738+
};
739+
740+
Ok(parent_node.archived || !git::branch_exists(&parent_node.branch_name)?)
741+
}
742+
673743
fn plan_parent_pull_request_repair(
674744
session: &crate::core::store::StoreSession,
675745
node: &BranchNode,

tests/sync.rs

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1106,6 +1106,104 @@ exit 1
11061106
});
11071107
}
11081108

1109+
#[test]
1110+
fn sync_removes_local_parent_branch_after_repair_when_parent_was_merged_upstream() {
1111+
with_temp_repo("dig-sync-cli", |repo| {
1112+
initialize_main_repo(repo);
1113+
initialize_origin_remote(repo);
1114+
dig_ok(repo, &["init"]);
1115+
dig_ok(repo, &["branch", "feat/root"]);
1116+
commit_file(repo, "root.txt", "root\n", "feat: root");
1117+
git_ok(repo, &["push", "-u", "origin", "feat/root"]);
1118+
track_pull_request_number(repo, "feat/root", 101);
1119+
dig_ok(repo, &["branch", "feat/auth"]);
1120+
commit_file(repo, "auth.txt", "auth\n", "feat: auth");
1121+
git_ok(repo, &["push", "-u", "origin", "feat/auth"]);
1122+
track_pull_request_number(repo, "feat/auth", 102);
1123+
dig_ok(repo, &["branch", "feat/auth-ui"]);
1124+
commit_file(repo, "ui.txt", "ui\n", "feat: ui");
1125+
git_ok(repo, &["push", "-u", "origin", "feat/auth-ui"]);
1126+
track_pull_request_number(repo, "feat/auth-ui", 103);
1127+
1128+
let parent_head_oid = git_stdout(repo, &["rev-parse", "feat/auth"]);
1129+
let remote_repo = clone_origin(repo, "origin-worktree-local-parent");
1130+
git_ok(&remote_repo, &["checkout", "main"]);
1131+
git_ok(&remote_repo, &["merge", "--squash", "origin/feat/root"]);
1132+
git_ok(
1133+
&remote_repo,
1134+
&["commit", "--quiet", "-m", "feat: merge root"],
1135+
);
1136+
git_ok(&remote_repo, &["push", "origin", "main"]);
1137+
git_ok(&remote_repo, &["push", "origin", "--delete", "feat/root"]);
1138+
git_ok(&remote_repo, &["push", "origin", "--delete", "feat/auth"]);
1139+
1140+
git_ok(repo, &["checkout", "main"]);
1141+
git_ok(repo, &["branch", "-D", "feat/root"]);
1142+
set_branch_archived(repo, "feat/root", true);
1143+
1144+
let remote_update_log = install_remote_update_logger(repo);
1145+
let (path, gh_log_path) = install_fake_gh(
1146+
repo,
1147+
&format!(
1148+
r#"#!/bin/sh
1149+
set -eu
1150+
printf '%s\n' "$*" >> "$DIG_TEST_GH_LOG"
1151+
if [ "$1" = "pr" ] && [ "$2" = "view" ] && [ "$3" = "102" ]; then
1152+
printf '{{"number":102,"state":"MERGED","mergedAt":"2026-03-26T12:00:00Z","baseRefName":"feat/root","headRefName":"feat/auth","headRefOid":"{parent_head_oid}","isDraft":false,"url":"https://github.com/acme/dig/pull/102"}}\n'
1153+
exit 0
1154+
fi
1155+
if [ "$1" = "pr" ] && [ "$2" = "view" ] && [ "$3" = "103" ]; then
1156+
printf '{{"number":103,"state":"CLOSED","mergedAt":null,"baseRefName":"feat/auth","headRefName":"feat/auth-ui","isDraft":false,"url":"https://github.com/acme/dig/pull/103"}}\n'
1157+
exit 0
1158+
fi
1159+
if [ "$1" = "pr" ] && [ "$2" = "reopen" ] && [ "$3" = "103" ]; then
1160+
exit 0
1161+
fi
1162+
if [ "$1" = "pr" ] && [ "$2" = "ready" ] && [ "$3" = "103" ] && [ "$4" = "--undo" ]; then
1163+
exit 0
1164+
fi
1165+
if [ "$1" = "pr" ] && [ "$2" = "edit" ] && [ "$3" = "103" ] && [ "$4" = "--base" ] && [ "$5" = "main" ]; then
1166+
exit 0
1167+
fi
1168+
echo "unexpected gh args: $*" >&2
1169+
exit 1
1170+
"#
1171+
),
1172+
);
1173+
1174+
let output = dig_with_input_and_env(
1175+
repo,
1176+
&["sync"],
1177+
"n\n",
1178+
&[
1179+
("PATH", path.as_str()),
1180+
("DIG_TEST_GH_LOG", gh_log_path.as_str()),
1181+
],
1182+
);
1183+
let stdout = strip_ansi(&String::from_utf8(output.stdout).unwrap());
1184+
let stderr = String::from_utf8(output.stderr).unwrap();
1185+
1186+
assert!(
1187+
output.status.success(),
1188+
"stdout:\n{stdout}\nstderr:\n{stderr}"
1189+
);
1190+
assert!(stdout.contains("Recovered pull requests:"));
1191+
assert!(stdout.contains("Deleted locally and no longer tracked by dig:"));
1192+
assert!(stdout.contains("- feat/auth"));
1193+
assert_eq!(git_stdout(repo, &["branch", "--list", "feat/auth"]), "");
1194+
1195+
let state = load_state_json(repo);
1196+
let child = find_node(&state, "feat/auth-ui").unwrap();
1197+
assert_eq!(child["base_ref"], "main");
1198+
assert_eq!(child["parent"]["kind"], "trunk");
1199+
assert!(find_archived_node(&state, "feat/auth").is_some());
1200+
assert_eq!(
1201+
count_remote_ref_updates(&remote_update_log, "refs/heads/feat/auth"),
1202+
2
1203+
);
1204+
});
1205+
}
1206+
11091207
#[test]
11101208
fn sync_aborts_before_local_cleanup_when_pull_request_repair_fails() {
11111209
with_temp_repo("dig-sync-cli", |repo| {

0 commit comments

Comments
 (0)