diff --git a/src/cli/clean/mod.rs b/src/cli/clean/mod.rs index 0673424..a09cdab 100644 --- a/src/cli/clean/mod.rs +++ b/src/cli/clean/mod.rs @@ -105,7 +105,7 @@ pub(crate) fn format_clean_plan(plan: &CleanPlan) -> String { for candidate in merged_candidates { let parent_branch = match &candidate.reason { CleanReason::DeletedLocally => continue, - CleanReason::IntegratedIntoParent { parent_branch } => parent_branch, + CleanReason::IntegratedIntoParent { parent_base } => &parent_base.branch_name, }; lines.push(format!( @@ -306,7 +306,7 @@ mod tests { BlockedBranch, CleanBlockReason, CleanCandidate, CleanOptions, CleanPlan, CleanReason, CleanTreeNode, }; - use crate::core::restack::RestackPreview; + use crate::core::restack::{RestackBaseTarget, RestackPreview}; use uuid::Uuid; #[test] @@ -331,7 +331,7 @@ mod tests { branch_name: "feat/auth".into(), parent_branch_name: "main".into(), reason: CleanReason::IntegratedIntoParent { - parent_branch: "main".into(), + parent_base: RestackBaseTarget::local("main"), }, tree: CleanTreeNode { branch_name: "feat/auth".into(), diff --git a/src/cli/clean/render.rs b/src/cli/clean/render.rs index c95b109..18aa491 100644 --- a/src/cli/clean/render.rs +++ b/src/cli/clean/render.rs @@ -101,6 +101,7 @@ fn visual_node_from_tree(tree: &CleanTreeNode) -> VisualNode { mod tests { use super::CleanAnimation; use crate::core::clean::{CleanCandidate, CleanEvent, CleanPlan, CleanReason, CleanTreeNode}; + use crate::core::restack::RestackBaseTarget; use uuid::Uuid; #[test] @@ -114,7 +115,7 @@ mod tests { branch_name: "feat/auth".into(), parent_branch_name: "main".into(), reason: CleanReason::IntegratedIntoParent { - parent_branch: "main".into(), + parent_base: RestackBaseTarget::local("main"), }, tree: CleanTreeNode { branch_name: "feat/auth".into(), diff --git a/src/cli/common.rs b/src/cli/common.rs index aa09bf7..2ddc8c5 100644 --- a/src/cli/common.rs +++ b/src/cli/common.rs @@ -22,6 +22,8 @@ pub fn confirm_yes_no(prompt: &str) -> io::Result { let mut input = String::new(); io::stdin().read_line(&mut input)?; + writeln!(stdout)?; + stdout.flush()?; Ok(matches!(input.trim(), "y" | "Y" | "yes" | "YES" | "Yes")) } diff --git a/src/cli/sync/mod.rs b/src/cli/sync/mod.rs index a09b903..ff74c15 100644 --- a/src/cli/sync/mod.rs +++ b/src/cli/sync/mod.rs @@ -4,7 +4,9 @@ use clap::Args; use crate::core::clean; use crate::core::merge; -use crate::core::sync::{self, SyncCompletion, SyncOptions}; +use crate::core::sync::{ + self, RemotePushActionKind, RemotePushOutcome, SyncCompletion, SyncOptions, +}; use crate::core::tree; use super::CommandOutcome; @@ -102,12 +104,26 @@ pub fn execute(args: SyncArgs) -> io::Result { } SyncCompletion::Full(full_outcome) if outcome.status.success() => { let summary = format_full_sync_summary(full_outcome); + let mut printed_output = false; + let mut restacked_branch_names = full_outcome + .restacked_branches + .iter() + .map(|branch| branch.branch_name.clone()) + .collect::>(); + let excluded_branch_names = full_outcome + .cleanup_plan + .candidates + .iter() + .map(|candidate| candidate.branch_name.clone()) + .collect::>(); + if !summary.is_empty() { println!("{summary}"); + printed_output = true; } if !full_outcome.cleanup_plan.candidates.is_empty() { - if !summary.is_empty() { + if printed_output { println!(); } @@ -115,6 +131,7 @@ pub fn execute(args: SyncArgs) -> io::Result { "{}", super::clean::format_clean_plan(&full_outcome.cleanup_plan) ); + printed_output = true; if !super::clean::confirm_cleanup(&full_outcome.cleanup_plan)? { println!("Skipped cleanup."); @@ -125,6 +142,12 @@ pub fn execute(args: SyncArgs) -> io::Result { final_status = clean_outcome.status; if clean_outcome.status.success() { + restacked_branch_names.extend( + clean_outcome + .restacked_branches + .iter() + .map(|branch| branch.branch_name.clone()), + ); let output = super::clean::format_clean_success_output( &full_outcome.cleanup_plan.trunk_branch, &clean_outcome, @@ -141,6 +164,54 @@ pub fn execute(args: SyncArgs) -> io::Result { } } } + + if final_status.success() { + let push_plan = + sync::plan_remote_pushes(&restacked_branch_names, &excluded_branch_names)?; + + if !push_plan.actions.is_empty() { + if printed_output { + println!(); + } + + println!("{}", format_remote_push_plan(&push_plan)); + + if !confirm_remote_pushes()? { + println!("Skipped remote updates."); + } else { + println!(); + + let push_outcome = sync::execute_remote_push_plan(&push_plan)?; + final_status = push_outcome.status; + + if push_outcome.status.success() { + let output = format_remote_push_success_output(&push_outcome); + if !output.is_empty() { + println!("{output}"); + } + } else { + let output = format_partial_remote_push_output( + "Updated before failure:", + &push_outcome, + ); + if !output.is_empty() { + println!("{output}"); + println!(); + } + if let Some(failed_action) = push_outcome.failed_action.as_ref() { + eprintln!( + "Failed to update '{}' on '{}'.", + failed_action.target.branch_name, + failed_action.target.remote_name + ); + } + common::print_trimmed_stderr( + push_outcome.failure_output.as_deref(), + ); + } + } + } + } } _ => {} } @@ -191,6 +262,53 @@ fn format_full_sync_summary(outcome: &sync::FullSyncOutcome) -> String { common::join_sections(§ions) } +fn format_remote_push_plan(plan: &sync::RemotePushPlan) -> String { + let mut lines = vec!["Remote branches to update:".to_string()]; + + for action in &plan.actions { + let action_label = match action.kind { + RemotePushActionKind::CreateRemoteBranch => "create", + RemotePushActionKind::UpdateRemoteBranch => "push", + RemotePushActionKind::ForceUpdateRemoteBranch => "force-push", + }; + lines.push(format!( + "- {action_label} {} on {}", + action.target.branch_name, action.target.remote_name + )); + } + + lines.join("\n") +} + +fn confirm_remote_pushes() -> io::Result { + common::confirm_yes_no("Push these remote updates? [y/N] ") +} + +fn format_remote_push_success_output(outcome: &RemotePushOutcome) -> String { + format_partial_remote_push_output("Updated remote branches:", outcome) +} + +fn format_partial_remote_push_output(header: &str, outcome: &RemotePushOutcome) -> String { + if outcome.pushed_actions.is_empty() { + return String::new(); + } + + let mut lines = vec![header.to_string()]; + for action in &outcome.pushed_actions { + let action_label = match action.kind { + RemotePushActionKind::CreateRemoteBranch => "created", + RemotePushActionKind::UpdateRemoteBranch => "pushed", + RemotePushActionKind::ForceUpdateRemoteBranch => "force-pushed", + }; + lines.push(format!( + "- {action_label} {} on {}", + action.target.branch_name, action.target.remote_name + )); + } + + lines.join("\n") +} + #[cfg(test)] mod tests { use super::SyncArgs; diff --git a/src/core/adopt.rs b/src/core/adopt.rs index 846ad15..2c7aa23 100644 --- a/src/core/adopt.rs +++ b/src/core/adopt.rs @@ -5,7 +5,7 @@ use uuid::Uuid; use crate::core::branch; use crate::core::git; -use crate::core::restack::RestackAction; +use crate::core::restack::{RestackAction, RestackBaseTarget}; use crate::core::store::{ BranchNode, ParentRef, PendingAdoptOperation, PendingOperationKind, PendingOperationState, now_unix_timestamp_secs, open_initialized, open_or_initialize, record_branch_adopted, @@ -149,7 +149,7 @@ pub fn apply(plan: &AdoptPlan) -> io::Result { branch_name: plan.branch_name.clone(), old_upstream_branch_name: plan.parent_branch_name.clone(), old_upstream_oid: plan.old_upstream_oid.clone(), - new_base_branch_name: plan.parent_branch_name.clone(), + new_base: RestackBaseTarget::local(plan.parent_branch_name.clone()), new_parent: None, }], &mut |_| Ok(()), diff --git a/src/core/clean/apply.rs b/src/core/clean/apply.rs index de3be5a..ba2aa79 100644 --- a/src/core/clean/apply.rs +++ b/src/core/clean/apply.rs @@ -306,21 +306,12 @@ where &node.branch_name, )?, )?, - PendingCleanCandidateKind::IntegratedIntoParent => { - let Some(parent_branch_name) = BranchGraph::new(&session.state) - .parent_branch_name(&node, &session.config.trunk_branch) - else { - return Err(io::Error::other(format!( - "tracked parent for '{}' is missing from dig", - node.branch_name - ))); - }; - + PendingCleanCandidateKind::IntegratedIntoParent { ref parent_base } => { restack::plan_after_branch_detach( &session.state, node.id, &node.branch_name, - &parent_branch_name, + parent_base, &node.parent, )? } @@ -341,19 +332,19 @@ where &mut |event| match event { RestackExecutionEvent::Started(action) => reporter(CleanEvent::RebaseStarted { branch_name: action.branch_name.clone(), - onto_branch: action.new_base_branch_name.clone(), + onto_branch: action.new_base.branch_name.clone(), }), RestackExecutionEvent::Progress { action, progress } => { reporter(CleanEvent::RebaseProgress { branch_name: action.branch_name.clone(), - onto_branch: action.new_base_branch_name.clone(), + onto_branch: action.new_base.branch_name.clone(), current_commit: progress.current, total_commits: progress.total, }) } RestackExecutionEvent::Completed(action) => reporter(CleanEvent::RebaseCompleted { branch_name: action.branch_name.clone(), - onto_branch: action.new_base_branch_name.clone(), + onto_branch: action.new_base.branch_name.clone(), }), }, )?; @@ -411,7 +402,7 @@ where untracked_branches.push(candidate.branch_name.clone()); git::success_status() } - PendingCleanCandidateKind::IntegratedIntoParent => { + PendingCleanCandidateKind::IntegratedIntoParent { .. } => { let status = delete_clean_candidate(session, &candidate.branch_name, reporter)?; if status.success() { deleted_branches.push(candidate.branch_name.clone()); @@ -428,8 +419,10 @@ fn pending_clean_candidate_from_clean_candidate( branch_name: candidate.branch_name.clone(), kind: match &candidate.reason { CleanReason::DeletedLocally => PendingCleanCandidateKind::DeletedLocally, - CleanReason::IntegratedIntoParent { .. } => { - PendingCleanCandidateKind::IntegratedIntoParent + CleanReason::IntegratedIntoParent { parent_base } => { + PendingCleanCandidateKind::IntegratedIntoParent { + parent_base: parent_base.clone(), + } } }, } diff --git a/src/core/clean/mod.rs b/src/core/clean/mod.rs index b6efe51..2ce4068 100644 --- a/src/core/clean/mod.rs +++ b/src/core/clean/mod.rs @@ -3,7 +3,10 @@ mod plan; mod types; pub(crate) use apply::{apply, apply_with_reporter, resume_after_sync}; -pub(crate) use plan::{branch_is_integrated, plan}; +pub(crate) use plan::{ + CleanPlanMode, branch_is_integrated, cleanup_candidate_for_branch, mode_for_sync, plan, + plan_for_sync, +}; pub(crate) use types::{ BlockedBranch, CleanApplyOutcome, CleanBlockReason, CleanCandidate, CleanEvent, CleanOptions, CleanPlan, CleanReason, CleanTreeNode, diff --git a/src/core/clean/plan.rs b/src/core/clean/plan.rs index 196f1c8..a21dd55 100644 --- a/src/core/clean/plan.rs +++ b/src/core/clean/plan.rs @@ -4,7 +4,7 @@ use std::io; use crate::core::deleted_local; use crate::core::git::{self, CherryMarker, CommitMetadata}; use crate::core::graph::BranchGraph; -use crate::core::restack; +use crate::core::restack::{self, RestackBaseTarget}; use crate::core::store::types::DigState; use crate::core::store::{BranchNode, dig_paths, load_config, load_state}; use crate::core::workflow; @@ -19,7 +19,29 @@ enum BranchEvaluation { Blocked(BlockedBranch), } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum CleanPlanMode { + LocalOnly, + RemoteAwareSync, +} + pub(crate) fn plan(options: &CleanOptions) -> io::Result { + plan_with_mode(options, CleanPlanMode::LocalOnly) +} + +pub(crate) fn plan_for_sync() -> io::Result { + plan_with_mode(&CleanOptions::default(), CleanPlanMode::RemoteAwareSync) +} + +pub(crate) fn mode_for_sync(remote_sync_enabled: bool) -> CleanPlanMode { + if remote_sync_enabled { + CleanPlanMode::RemoteAwareSync + } else { + CleanPlanMode::LocalOnly + } +} + +fn plan_with_mode(options: &CleanOptions, mode: CleanPlanMode) -> io::Result { workflow::ensure_no_pending_operation_for_command("clean")?; let repo = git::resolve_repo_context()?; let store_paths = dig_paths(&repo.git_dir); @@ -35,10 +57,14 @@ pub(crate) fn plan(options: &CleanOptions) -> io::Result { .map(str::to_string); match &requested_branch_name { - Some(branch_name) => { - plan_for_requested_branch(&state, &config.trunk_branch, ¤t_branch, branch_name) - } - None => plan_for_all_branches(&state, &config.trunk_branch, ¤t_branch), + Some(branch_name) => plan_for_requested_branch( + &state, + &config.trunk_branch, + ¤t_branch, + branch_name, + mode, + ), + None => plan_for_all_branches(&state, &config.trunk_branch, ¤t_branch, mode), } } @@ -47,6 +73,7 @@ fn plan_for_requested_branch( trunk_branch: &str, current_branch: &str, branch_name: &str, + mode: CleanPlanMode, ) -> io::Result { let Some(node) = state.find_branch_by_name(branch_name) else { return Ok(CleanPlan { @@ -77,7 +104,7 @@ fn plan_for_requested_branch( }); } - let evaluation = evaluate_integrated_branch(state, trunk_branch, node)?; + let evaluation = evaluate_integrated_branch(state, trunk_branch, node, mode)?; let (candidates, blocked) = match evaluation { BranchEvaluation::Cleanable(candidate) => (vec![candidate], Vec::new()), @@ -97,6 +124,7 @@ fn plan_for_all_branches( state: &DigState, trunk_branch: &str, current_branch: &str, + mode: CleanPlanMode, ) -> io::Result { let deleted_steps = deleted_local::collect_deleted_local_steps(state, trunk_branch)?; let deleted_candidates = deleted_steps @@ -110,7 +138,7 @@ fn plan_for_all_branches( let mut blocked = Vec::new(); for node in projected_state.nodes.iter().filter(|node| !node.archived) { - match evaluate_integrated_branch(&projected_state, trunk_branch, node)? { + match evaluate_integrated_branch(&projected_state, trunk_branch, node, mode)? { BranchEvaluation::Cleanable(candidate) => cleanable.push(candidate), BranchEvaluation::Blocked(blocked_branch) => blocked.push(blocked_branch), } @@ -167,6 +195,7 @@ fn evaluate_integrated_branch( state: &DigState, trunk_branch: &str, node: &BranchNode, + mode: CleanPlanMode, ) -> io::Result { if !git::branch_exists(&node.branch_name)? { return Ok(BranchEvaluation::Blocked(BlockedBranch { @@ -203,43 +232,81 @@ fn evaluate_integrated_branch( })); } - if !branch_is_integrated(&parent_branch_name, &node.branch_name)? { + let local_parent_base = RestackBaseTarget::local(&parent_branch_name); + let tracked_pull_request_number = node.pull_request.as_ref().map(|pr| pr.number); + let parent_base = if branch_is_integrated_for_pull_request( + local_parent_base.rebase_ref(), + &node.branch_name, + tracked_pull_request_number, + )? { + local_parent_base + } else if mode == CleanPlanMode::RemoteAwareSync { + match resolve_remote_parent_base(&parent_branch_name)? { + Some(remote_parent_base) + if branch_is_integrated_for_pull_request( + remote_parent_base.rebase_ref(), + &node.branch_name, + tracked_pull_request_number, + )? => + { + remote_parent_base + } + _ => { + return Ok(BranchEvaluation::Blocked(BlockedBranch { + branch_name: node.branch_name.clone(), + reason: CleanBlockReason::NotIntegrated { + parent_branch: parent_branch_name, + }, + })); + } + } + } else { return Ok(BranchEvaluation::Blocked(BlockedBranch { branch_name: node.branch_name.clone(), reason: CleanBlockReason::NotIntegrated { parent_branch: parent_branch_name, }, })); - } + }; let restack_actions = restack::plan_after_branch_detach( state, node.id, &node.branch_name, - &parent_branch_name, + &parent_base, &node.parent, )?; Ok(BranchEvaluation::Cleanable(CleanCandidate { node_id: node.id, branch_name: node.branch_name.clone(), - parent_branch_name: parent_branch_name.clone(), - reason: CleanReason::IntegratedIntoParent { - parent_branch: parent_branch_name, - }, + parent_branch_name: parent_base.branch_name.clone(), + reason: CleanReason::IntegratedIntoParent { parent_base }, tree: graph.subtree(node.id)?, restack_plan: restack::previews_for_actions(&restack_actions), depth: graph.branch_depth(node.id), })) } +pub(crate) fn cleanup_candidate_for_branch( + state: &DigState, + trunk_branch: &str, + node: &BranchNode, + mode: CleanPlanMode, +) -> io::Result> { + match evaluate_integrated_branch(state, trunk_branch, node, mode)? { + BranchEvaluation::Cleanable(candidate) => Ok(Some(candidate)), + BranchEvaluation::Blocked(_) => Ok(None), + } +} + fn clean_candidate_from_deleted_step( step: deleted_local::DeletedLocalBranchStep, ) -> CleanCandidate { CleanCandidate { node_id: step.node_id, branch_name: step.branch_name, - parent_branch_name: step.new_parent_branch_name, + parent_branch_name: step.new_parent_base.branch_name, reason: CleanReason::DeletedLocally, tree: step.tree, restack_plan: step.restack_plan, @@ -250,12 +317,39 @@ fn clean_candidate_from_deleted_step( pub(crate) fn branch_is_integrated( parent_branch_name: &str, branch_name: &str, +) -> io::Result { + branch_is_integrated_for_pull_request(parent_branch_name, branch_name, None) +} + +fn branch_is_integrated_for_pull_request( + parent_branch_name: &str, + branch_name: &str, + tracked_pull_request_number: Option, ) -> io::Result { if branch_is_integrated_by_cherry(parent_branch_name, branch_name)? { return Ok(true); } - branch_is_integrated_by_squash_message(parent_branch_name, branch_name) + branch_is_integrated_by_squash_message( + parent_branch_name, + branch_name, + tracked_pull_request_number, + ) +} + +fn resolve_remote_parent_base(parent_branch_name: &str) -> io::Result> { + let Some(target) = git::branch_push_target(parent_branch_name)? else { + return Ok(None); + }; + + if !git::remote_tracking_branch_exists(&target.remote_name, &target.branch_name)? { + return Ok(None); + } + + Ok(Some(RestackBaseTarget::with_rebase_ref( + parent_branch_name, + git::remote_tracking_branch_ref(&target.remote_name, &target.branch_name), + ))) } fn branch_is_integrated_by_cherry(parent_branch_name: &str, branch_name: &str) -> io::Result { @@ -269,6 +363,7 @@ fn branch_is_integrated_by_cherry(parent_branch_name: &str, branch_name: &str) - fn branch_is_integrated_by_squash_message( parent_branch_name: &str, branch_name: &str, + tracked_pull_request_number: Option, ) -> io::Result { let merge_base = git::merge_base(parent_branch_name, branch_name)?; let branch_commits = git::commit_metadata_in_range(&format!("{merge_base}..{branch_name}"))?; @@ -282,6 +377,9 @@ fn branch_is_integrated_by_squash_message( Ok(parent_commits.iter().any(|parent_commit| { parent_commit_mentions_all_branch_commits(parent_commit, &branch_commits) + || tracked_pull_request_number.is_some_and(|number| { + parent_commit_mentions_tracked_pull_request(parent_commit, number) + }) })) } @@ -307,3 +405,20 @@ pub(super) fn parent_commit_mentions_all_branch_commits( .iter() .all(|branch_commit| message_lines.contains(branch_commit.subject.as_str())) } + +pub(super) fn parent_commit_mentions_tracked_pull_request( + parent_commit: &CommitMetadata, + pull_request_number: u64, +) -> bool { + let tracked_pull_request_suffix = format!("(#{pull_request_number})"); + let tracked_pull_request_ref = format!("pull request #{pull_request_number}"); + let tracked_pull_request_url = format!("/pull/{pull_request_number}"); + let subject = parent_commit.subject.to_ascii_lowercase(); + let body = parent_commit.body.to_ascii_lowercase(); + + [subject.as_str(), body.as_str()].iter().any(|text| { + text.contains(&tracked_pull_request_suffix) + || text.contains(&tracked_pull_request_ref) + || text.contains(&tracked_pull_request_url) + }) +} diff --git a/src/core/clean/tests.rs b/src/core/clean/tests.rs index 2a89e4d..9ba2760 100644 --- a/src/core/clean/tests.rs +++ b/src/core/clean/tests.rs @@ -1,12 +1,46 @@ -use super::plan::{parent_commit_mentions_all_branch_commits, plan as build_plan}; +use std::path::PathBuf; + +use super::plan::{ + parent_commit_mentions_all_branch_commits, parent_commit_mentions_tracked_pull_request, + plan as build_plan, plan_for_sync as build_sync_plan, +}; use super::{BlockedBranch, CleanBlockReason, CleanOptions, CleanReason, apply}; use crate::core::git::{self, CommitMetadata}; -use crate::core::store::{ParentRef, dig_paths, load_state}; +use crate::core::restack::RestackBaseTarget; +use crate::core::store::{ + BranchPullRequestTrackedSource, ParentRef, TrackedPullRequest, dig_paths, load_state, + open_initialized, record_branch_pull_request_tracked, +}; use crate::core::test_support::{ append_file, commit_file, create_tracked_branch, git_ok, initialize_main_repo, squash_merge_branch_with_commit_listing, with_temp_repo, }; +fn initialize_origin_remote(repo: &std::path::Path) { + git_ok(repo, &["init", "--bare", "origin.git"]); + git_ok(repo, &["remote", "add", "origin", "origin.git"]); + git_ok(repo, &["push", "-u", "origin", "main"]); + git_ok( + repo, + &[ + "--git-dir=origin.git", + "symbolic-ref", + "HEAD", + "refs/heads/main", + ], + ); +} + +fn clone_origin(repo: &std::path::Path, clone_name: &str) -> PathBuf { + let clone_dir = repo.join(clone_name); + let clone_path = clone_dir.to_string_lossy().into_owned(); + git_ok(repo, &["clone", "origin.git", &clone_path]); + git_ok(&clone_dir, &["config", "user.name", "Dig Remote"]); + git_ok(&clone_dir, &["config", "user.email", "remote@example.com"]); + git_ok(&clone_dir, &["config", "commit.gpgsign", "false"]); + clone_dir +} + #[test] fn reports_non_integrated_branch_reason() { let blocked = BlockedBranch { @@ -27,13 +61,13 @@ fn reports_non_integrated_branch_reason() { #[test] fn tracks_integrated_clean_reason() { let reason = CleanReason::IntegratedIntoParent { - parent_branch: "main".into(), + parent_base: RestackBaseTarget::local("main"), }; assert_eq!( reason, CleanReason::IntegratedIntoParent { - parent_branch: "main".into() + parent_base: RestackBaseTarget::local("main") } ); } @@ -68,6 +102,26 @@ fn detects_squash_commit_message_that_mentions_branch_commits() { )); } +#[test] +fn detects_parent_commit_that_mentions_tracked_pull_request_number() { + assert!(parent_commit_mentions_tracked_pull_request( + &CommitMetadata { + sha: "parent".into(), + subject: "feat: add GitHub PR workflows (#2)".into(), + body: "See https://github.com/acme/dig/pull/2 for details.".into(), + }, + 2, + )); + assert!(!parent_commit_mentions_tracked_pull_request( + &CommitMetadata { + sha: "parent".into(), + subject: "feat: add GitHub PR workflows (#12)".into(), + body: String::new(), + }, + 2, + )); +} + #[test] fn cleans_squash_merged_parent_and_restacks_descendants() { with_temp_repo("dig-clean", |repo| { @@ -157,6 +211,142 @@ fn cleans_squash_merged_parent_and_restacks_descendants() { }); } +#[test] +fn clean_plan_detects_local_integration_by_tracked_pull_request_number() { + with_temp_repo("dig-clean", |repo| { + initialize_main_repo(repo); + create_tracked_branch("feat/auth"); + commit_file(repo, "auth.txt", "auth\n", "feat: add GitHub PR workflows"); + commit_file( + repo, + "tree.txt", + "tree\n", + "fix(tree): show PR numbers in lineage views", + ); + + let mut session = open_initialized("dig is not initialized").unwrap(); + let branch = session + .state + .find_branch_by_name("feat/auth") + .unwrap() + .clone(); + record_branch_pull_request_tracked( + &mut session, + branch.id, + branch.branch_name.clone(), + TrackedPullRequest { number: 2 }, + BranchPullRequestTrackedSource::Created, + ) + .unwrap(); + + git_ok(repo, &["checkout", "main"]); + git_ok(repo, &["merge", "--squash", "feat/auth"]); + git_ok( + repo, + &[ + "commit", + "--quiet", + "-m", + "feat: add GitHub PR workflows (#2)", + "-m", + "Adds GitHub PR creation and tracking support.", + ], + ); + + let plan = build_plan(&CleanOptions { + branch_name: Some("feat/auth".into()), + }) + .unwrap(); + + assert_eq!( + plan.candidates + .iter() + .map(|candidate| candidate.branch_name.clone()) + .collect::>(), + vec!["feat/auth".to_string()] + ); + assert_eq!( + plan.candidates[0].reason, + CleanReason::IntegratedIntoParent { + parent_base: RestackBaseTarget::local("main"), + } + ); + }); +} + +#[test] +fn sync_plan_detects_remote_only_integrated_branch() { + with_temp_repo("dig-clean", |repo| { + initialize_main_repo(repo); + initialize_origin_remote(repo); + create_tracked_branch("feat/auth"); + commit_file(repo, "auth.txt", "auth\n", "feat: auth"); + git_ok(repo, &["push", "-u", "origin", "feat/auth"]); + create_tracked_branch("feat/auth-ui"); + commit_file(repo, "ui.txt", "ui\n", "feat: auth ui"); + + let remote_repo = clone_origin(repo, "origin-worktree"); + git_ok(&remote_repo, &["checkout", "main"]); + git_ok(&remote_repo, &["merge", "--squash", "origin/feat/auth"]); + git_ok( + &remote_repo, + &["commit", "--quiet", "-m", "feat: merge auth"], + ); + git_ok(&remote_repo, &["push", "origin", "main"]); + git_ok(repo, &["fetch", "--prune", "origin"]); + + let local_plan = build_plan(&CleanOptions::default()).unwrap(); + assert!(local_plan.candidates.is_empty()); + + let sync_plan = build_sync_plan().unwrap(); + assert_eq!( + sync_plan + .candidates + .iter() + .map(|candidate| candidate.branch_name.clone()) + .collect::>(), + vec!["feat/auth".to_string()] + ); + assert_eq!( + sync_plan.candidates[0] + .restack_plan + .iter() + .map(|step| format!("{}->{}", step.branch_name, step.onto_branch)) + .collect::>(), + vec!["feat/auth-ui->main".to_string()] + ); + assert_eq!( + sync_plan.candidates[0].reason, + CleanReason::IntegratedIntoParent { + parent_base: RestackBaseTarget::with_rebase_ref("main", "origin/main"), + } + ); + }); +} + +#[test] +fn sync_plan_ignores_remote_integration_when_parent_remote_ref_is_missing() { + with_temp_repo("dig-clean", |repo| { + initialize_main_repo(repo); + initialize_origin_remote(repo); + create_tracked_branch("feat/auth"); + commit_file(repo, "auth.txt", "auth\n", "feat: auth"); + create_tracked_branch("feat/auth-api"); + commit_file(repo, "api.txt", "api\n", "feat: auth api"); + + let sync_plan = build_sync_plan().unwrap(); + + assert!(sync_plan.candidates.is_empty()); + assert!(sync_plan.blocked.iter().any(|blocked| { + blocked.branch_name == "feat/auth-api" + && blocked.reason + == CleanBlockReason::NotIntegrated { + parent_branch: "feat/auth".into(), + } + })); + }); +} + #[test] fn returns_to_original_branch_after_cleaning_from_another_checkout() { with_temp_repo("dig-clean", |repo| { diff --git a/src/core/clean/types.rs b/src/core/clean/types.rs index 3dd4acf..e05c9f8 100644 --- a/src/core/clean/types.rs +++ b/src/core/clean/types.rs @@ -3,7 +3,7 @@ use std::process::ExitStatus; use uuid::Uuid; use crate::core::graph::BranchTreeNode; -use crate::core::restack::RestackPreview; +use crate::core::restack::{RestackBaseTarget, RestackPreview}; #[derive(Debug, Clone, Default, PartialEq, Eq)] pub(crate) struct CleanOptions { @@ -67,7 +67,7 @@ pub(crate) type CleanTreeNode = BranchTreeNode; #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum CleanReason { DeletedLocally, - IntegratedIntoParent { parent_branch: String }, + IntegratedIntoParent { parent_base: RestackBaseTarget }, } #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/src/core/deleted_local.rs b/src/core/deleted_local.rs index ec3bc6c..97dd859 100644 --- a/src/core/deleted_local.rs +++ b/src/core/deleted_local.rs @@ -4,7 +4,7 @@ use uuid::Uuid; use crate::core::git; use crate::core::graph::{BranchGraph, BranchTreeNode}; -use crate::core::restack::{self, RestackAction, RestackPreview}; +use crate::core::restack::{self, RestackAction, RestackBaseTarget, RestackPreview}; use crate::core::store::types::DigState; use crate::core::store::{BranchArchiveReason, ParentRef, StoreSession, record_branch_archived}; @@ -12,7 +12,7 @@ use crate::core::store::{BranchArchiveReason, ParentRef, StoreSession, record_br pub(crate) struct DeletedLocalBranchStep { pub node_id: Uuid, pub branch_name: String, - pub new_parent_branch_name: String, + pub new_parent_base: RestackBaseTarget, pub new_parent: ParentRef, pub tree: BranchTreeNode, pub restack_plan: Vec, @@ -59,7 +59,7 @@ pub(crate) fn restack_actions_for_step( state, step.node_id, &step.branch_name, - &step.new_parent_branch_name, + &step.new_parent_base, &step.new_parent, ) } @@ -178,7 +178,7 @@ fn plan_deleted_local_step( Ok(DeletedLocalBranchStep { node_id: node.id, branch_name: node.branch_name.clone(), - new_parent_branch_name, + new_parent_base: new_parent_branch_name, new_parent, tree: graph.subtree(node.id)?, restack_plan, @@ -205,12 +205,14 @@ fn resolve_replacement_parent( state: &DigState, trunk_branch: &str, parent: &ParentRef, -) -> io::Result<(String, ParentRef)> { +) -> io::Result<(RestackBaseTarget, ParentRef)> { let mut current_parent = parent.clone(); loop { match current_parent { - ParentRef::Trunk => return Ok((trunk_branch.to_string(), ParentRef::Trunk)), + ParentRef::Trunk => { + return Ok((RestackBaseTarget::local(trunk_branch), ParentRef::Trunk)); + } ParentRef::Branch { node_id } => { let parent_node = state .find_branch_by_id(node_id) @@ -218,7 +220,7 @@ fn resolve_replacement_parent( if !parent_node.archived && git::branch_exists(&parent_node.branch_name)? { return Ok(( - parent_node.branch_name.clone(), + RestackBaseTarget::local(&parent_node.branch_name), ParentRef::Branch { node_id: parent_node.id, }, diff --git a/src/core/git.rs b/src/core/git.rs index 5075100..d71805c 100644 --- a/src/core/git.rs +++ b/src/core/git.rs @@ -261,7 +261,7 @@ pub fn ensure_clean_worktree(command_name: &str) -> io::Result<()> { Ok(()) } else { Err(io::Error::other(format!( - "dig {command_name} requires a clean working tree" + "{command_name} requires a clean working tree" ))) } } @@ -340,14 +340,22 @@ pub fn commit_metadata_in_range(range_spec: &str) -> io::Result io::Result> { - let Some(remote_name) = resolve_push_remote_name(branch_name)? else { + let Some(target) = branch_push_target(branch_name)? else { return Ok(None); }; - if branch_head_is_pushed_to_remote(branch_name, &remote_name)? { + if branch_head_is_pushed_to_remote(&target.branch_name, &target.remote_name)? { return Ok(None); } + Ok(Some(target)) +} + +pub fn branch_push_target(branch_name: &str) -> io::Result> { + let Some(remote_name) = resolve_push_remote_name(branch_name)? else { + return Ok(None); + }; + Ok(Some(BranchPushTarget { remote_name, branch_name: branch_name.to_string(), @@ -362,6 +370,73 @@ pub fn push_branch_to_remote(target: &BranchPushTarget) -> io::Result io::Result { + let output = Command::new("git") + .args([ + "push", + "--force-with-lease", + "-u", + &target.remote_name, + &target.branch_name, + ]) + .output()?; + + output_to_git_command_output(output) +} + +pub fn fetch_remote(remote_name: &str) -> io::Result { + let output = Command::new("git") + .args(["fetch", "--prune", remote_name]) + .output()?; + + output_to_git_command_output(output) +} + +pub fn remote_tracking_ref_name(remote_name: &str, branch_name: &str) -> String { + format!("refs/remotes/{remote_name}/{branch_name}") +} + +pub fn remote_tracking_branch_ref(remote_name: &str, branch_name: &str) -> String { + format!("{remote_name}/{branch_name}") +} + +pub fn remote_tracking_branch_exists(remote_name: &str, branch_name: &str) -> io::Result { + let status = Command::new("git") + .args([ + "show-ref", + "--verify", + "--quiet", + &remote_tracking_ref_name(remote_name, branch_name), + ]) + .status()?; + + Ok(status.success()) +} + +pub fn remote_tracking_branch_oid( + remote_name: &str, + branch_name: &str, +) -> io::Result> { + let output = Command::new("git") + .args([ + "rev-parse", + "--verify", + &remote_tracking_ref_name(remote_name, branch_name), + ]) + .output()?; + + if !output.status.success() { + return Ok(None); + } + + let stdout = String::from_utf8(output.stdout) + .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?; + + Ok(Some(stdout.trim().to_string())) +} + fn resolve_push_remote_name(branch_name: &str) -> io::Result> { if let Some(remote_name) = configured_branch_remote_name(branch_name)? { return Ok(Some(remote_name)); diff --git a/src/core/merge/apply.rs b/src/core/merge/apply.rs index 5ec124b..2df1e4f 100644 --- a/src/core/merge/apply.rs +++ b/src/core/merge/apply.rs @@ -95,7 +95,7 @@ where &session.state, node.id, &node.branch_name, - &plan.target_branch_name, + &restack::RestackBaseTarget::local(&plan.target_branch_name), &node.parent, )?; @@ -112,19 +112,19 @@ where &mut |event| match event { RestackExecutionEvent::Started(action) => reporter(MergeEvent::RebaseStarted { branch_name: action.branch_name.clone(), - onto_branch: action.new_base_branch_name.clone(), + onto_branch: action.new_base.branch_name.clone(), }), RestackExecutionEvent::Progress { action, progress } => { reporter(MergeEvent::RebaseProgress { branch_name: action.branch_name.clone(), - onto_branch: action.new_base_branch_name.clone(), + onto_branch: action.new_base.branch_name.clone(), current_commit: progress.current, total_commits: progress.total, }) } RestackExecutionEvent::Completed(action) => reporter(MergeEvent::RebaseCompleted { branch_name: action.branch_name.clone(), - onto_branch: action.new_base_branch_name.clone(), + onto_branch: action.new_base.branch_name.clone(), }), }, )?; diff --git a/src/core/merge/plan.rs b/src/core/merge/plan.rs index dd0afb3..18f41b5 100644 --- a/src/core/merge/plan.rs +++ b/src/core/merge/plan.rs @@ -92,7 +92,7 @@ pub(crate) fn plan(options: &MergeOptions) -> io::Result { &state, node.id, &node.branch_name, - &target_branch_name, + &restack::RestackBaseTarget::local(&target_branch_name), &node.parent, )?; diff --git a/src/core/orphan/apply.rs b/src/core/orphan/apply.rs index d029012..dfab272 100644 --- a/src/core/orphan/apply.rs +++ b/src/core/orphan/apply.rs @@ -42,7 +42,7 @@ pub(crate) fn apply(plan: &OrphanPlan) -> io::Result { &session.state, node.id, &node.branch_name, - &parent_branch_name, + &restack::RestackBaseTarget::local(&parent_branch_name), &node.parent, )?; let restack_outcome = workflow::execute_resumable_restack_operation( diff --git a/src/core/orphan/plan.rs b/src/core/orphan/plan.rs index 33bd58e..0010676 100644 --- a/src/core/orphan/plan.rs +++ b/src/core/orphan/plan.rs @@ -75,7 +75,7 @@ pub(crate) fn plan(options: &OrphanOptions) -> io::Result { &session.state, node.id, &node.branch_name, - &parent_branch_name, + &restack::RestackBaseTarget::local(&parent_branch_name), &node.parent, )?); diff --git a/src/core/reparent/apply.rs b/src/core/reparent/apply.rs index 8ecbbce..fb7c98c 100644 --- a/src/core/reparent/apply.rs +++ b/src/core/reparent/apply.rs @@ -53,7 +53,7 @@ pub(crate) fn apply(plan: &ReparentPlan) -> io::Result { node.id, &node.branch_name, ¤t_parent_branch_name, - &plan.parent_branch_name, + &restack::RestackBaseTarget::local(&plan.parent_branch_name), &plan.new_parent, )?; let restack_outcome = workflow::execute_resumable_restack_operation( diff --git a/src/core/reparent/plan.rs b/src/core/reparent/plan.rs index ae1666e..764aad5 100644 --- a/src/core/reparent/plan.rs +++ b/src/core/reparent/plan.rs @@ -127,7 +127,7 @@ pub(crate) fn plan(options: &ReparentOptions) -> io::Result { node.id, &node.branch_name, ¤t_parent_branch_name, - parent_branch_name, + &restack::RestackBaseTarget::local(parent_branch_name), &new_parent, )?); diff --git a/src/core/restack.rs b/src/core/restack.rs index fc72f6b..dd0ebcb 100644 --- a/src/core/restack.rs +++ b/src/core/restack.rs @@ -9,13 +9,48 @@ use crate::core::graph::BranchGraph; use crate::core::store::ParentRef; use crate::core::store::types::DigState; +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RestackBaseTarget { + #[serde(rename = "new_base_branch_name")] + pub branch_name: String, + #[serde( + rename = "new_base_ref", + default, + skip_serializing_if = "Option::is_none" + )] + pub rebase_ref: Option, +} + +impl RestackBaseTarget { + pub fn local(branch_name: impl Into) -> Self { + Self { + branch_name: branch_name.into(), + rebase_ref: None, + } + } + + pub fn with_rebase_ref(branch_name: impl Into, rebase_ref: impl Into) -> Self { + Self { + branch_name: branch_name.into(), + rebase_ref: Some(rebase_ref.into()), + } + } + + pub fn rebase_ref(&self) -> &str { + self.rebase_ref + .as_deref() + .unwrap_or(self.branch_name.as_str()) + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RestackAction { pub node_id: Uuid, pub branch_name: String, pub old_upstream_branch_name: String, pub old_upstream_oid: String, - pub new_base_branch_name: String, + #[serde(flatten)] + pub new_base: RestackBaseTarget, pub new_parent: Option, } @@ -47,7 +82,7 @@ pub fn plan_after_branch_detach( state: &DigState, detached_node_id: Uuid, detached_branch_name: &str, - new_parent_branch_name: &str, + new_parent_base: &RestackBaseTarget, new_parent: &ParentRef, ) -> io::Result> { let mut actions = Vec::new(); @@ -58,7 +93,7 @@ pub fn plan_after_branch_detach( state, child_id, detached_branch_name, - new_parent_branch_name, + new_parent_base, Some(new_parent.clone()), &mut actions, )?; @@ -82,7 +117,7 @@ pub fn plan_after_branch_advance( child_id, advanced_branch_name, old_head_oid, - advanced_branch_name, + &RestackBaseTarget::local(advanced_branch_name), &mut actions, )?; } @@ -96,16 +131,16 @@ pub fn plan_after_branch_rebase( rebased_branch_name: &str, old_upstream_oid: &str, old_head_oid: &str, - new_base_branch_name: &str, + new_base: &RestackBaseTarget, ) -> io::Result> { load_active_branch_node(state, rebased_node_id)?; let mut actions = vec![RestackAction { node_id: rebased_node_id, branch_name: rebased_branch_name.to_string(), - old_upstream_branch_name: new_base_branch_name.to_string(), + old_upstream_branch_name: new_base.branch_name.clone(), old_upstream_oid: old_upstream_oid.to_string(), - new_base_branch_name: new_base_branch_name.to_string(), + new_base: new_base.clone(), new_parent: None, }]; @@ -116,7 +151,7 @@ pub fn plan_after_branch_rebase( child_id, rebased_branch_name, old_head_oid, - rebased_branch_name, + &RestackBaseTarget::local(rebased_branch_name), &mut actions, )?; } @@ -129,7 +164,7 @@ pub fn plan_after_branch_reparent( node_id: Uuid, branch_name: &str, current_parent_branch_name: &str, - new_parent_branch_name: &str, + new_parent_base: &RestackBaseTarget, new_parent: &ParentRef, ) -> io::Result> { load_active_branch_node(state, node_id)?; @@ -141,7 +176,7 @@ pub fn plan_after_branch_reparent( branch_name: branch_name.to_string(), old_upstream_branch_name: current_parent_branch_name.to_string(), old_upstream_oid, - new_base_branch_name: new_parent_branch_name.to_string(), + new_base: new_parent_base.clone(), new_parent: Some(new_parent.clone()), }]; @@ -152,7 +187,7 @@ pub fn plan_after_branch_reparent( child_id, branch_name, &old_head_oid, - branch_name, + &RestackBaseTarget::local(branch_name), &mut actions, )?; } @@ -164,7 +199,7 @@ pub fn plan_after_deleted_branch( state: &DigState, deleted_node_id: Uuid, deleted_branch_name: &str, - new_parent_branch_name: &str, + new_parent_base: &RestackBaseTarget, new_parent: &ParentRef, ) -> io::Result> { let graph = BranchGraph::new(state); @@ -172,14 +207,14 @@ pub fn plan_after_deleted_branch( for child_id in graph.active_children_ids(deleted_node_id) { let child = load_active_branch_node(state, child_id)?; - let old_upstream_oid = git::merge_base(new_parent_branch_name, &child.branch_name)?; + let old_upstream_oid = git::merge_base(new_parent_base.rebase_ref(), &child.branch_name)?; let old_head_oid = git::ref_oid(&child.branch_name)?; actions.push(RestackAction { node_id: child_id, branch_name: child.branch_name.clone(), old_upstream_branch_name: deleted_branch_name.to_string(), old_upstream_oid, - new_base_branch_name: new_parent_branch_name.to_string(), + new_base: new_parent_base.clone(), new_parent: Some(new_parent.clone()), }); @@ -189,7 +224,7 @@ pub fn plan_after_deleted_branch( grandchild_id, &child.branch_name, &old_head_oid, - &child.branch_name, + &RestackBaseTarget::local(&child.branch_name), &mut actions, )?; } @@ -203,7 +238,7 @@ pub fn previews_for_actions(actions: &[RestackAction]) -> Vec { .iter() .map(|action| RestackPreview { branch_name: action.branch_name.clone(), - onto_branch: action.new_base_branch_name.clone(), + onto_branch: action.new_base.branch_name.clone(), parent_changed: action.new_parent.is_some(), }) .collect() @@ -218,7 +253,7 @@ where F: FnMut(RebaseProgress) -> io::Result<()>, { let result = git::rebase_onto_with_progress( - &action.new_base_branch_name, + action.new_base.rebase_ref(), &action.old_upstream_oid, &action.branch_name, on_progress, @@ -253,7 +288,7 @@ pub fn finalize_action( let (old_parent, old_base_ref) = state.reparent_branch( action.node_id, new_parent.clone(), - action.new_base_branch_name.clone(), + action.new_base.branch_name.clone(), )?; Ok(Some(ParentChange { @@ -262,7 +297,7 @@ pub fn finalize_action( old_parent, new_parent: new_parent.clone(), old_base_ref, - new_base_ref: action.new_base_branch_name.clone(), + new_base_ref: action.new_base.branch_name.clone(), })) } @@ -271,7 +306,7 @@ fn collect_branch_advance_actions( node_id: Uuid, old_upstream_branch_name: &str, old_upstream_oid: &str, - new_base_branch_name: &str, + new_base: &RestackBaseTarget, actions: &mut Vec, ) -> io::Result<()> { let node = load_active_branch_node(state, node_id)?; @@ -281,7 +316,7 @@ fn collect_branch_advance_actions( branch_name: branch_name.clone(), old_upstream_branch_name: old_upstream_branch_name.to_string(), old_upstream_oid: old_upstream_oid.to_string(), - new_base_branch_name: new_base_branch_name.to_string(), + new_base: new_base.clone(), new_parent: None, }); @@ -292,7 +327,7 @@ fn collect_branch_advance_actions( child_id, &branch_name, &branch_head_oid, - &branch_name, + &RestackBaseTarget::local(&branch_name), actions, )?; } @@ -304,7 +339,7 @@ fn collect_restack_actions( state: &DigState, node_id: Uuid, old_upstream_branch_name: &str, - new_base_branch_name: &str, + new_base: &RestackBaseTarget, new_parent: Option, actions: &mut Vec, ) -> io::Result<()> { @@ -316,12 +351,19 @@ fn collect_restack_actions( branch_name: branch_name.clone(), old_upstream_branch_name: old_upstream_branch_name.to_string(), old_upstream_oid, - new_base_branch_name: new_base_branch_name.to_string(), + new_base: new_base.clone(), new_parent, }); for child_id in BranchGraph::new(state).active_children_ids(node_id) { - collect_restack_actions(state, child_id, &branch_name, &branch_name, None, actions)?; + collect_restack_actions( + state, + child_id, + &branch_name, + &RestackBaseTarget::local(&branch_name), + None, + actions, + )?; } Ok(()) @@ -354,7 +396,8 @@ fn load_active_branch_node( #[cfg(test)] mod tests { use super::{ - RestackAction, plan_after_branch_advance, plan_after_branch_reparent, previews_for_actions, + RestackAction, RestackBaseTarget, plan_after_branch_advance, plan_after_branch_reparent, + previews_for_actions, }; use crate::core::git; use crate::core::store::types::DIG_STATE_VERSION; @@ -370,7 +413,7 @@ mod tests { branch_name: "feat/auth-api".into(), old_upstream_branch_name: "feat/auth".into(), old_upstream_oid: "abc123".into(), - new_base_branch_name: "main".into(), + new_base: RestackBaseTarget::local("main"), new_parent: Some(ParentRef::Trunk), }, RestackAction { @@ -378,7 +421,7 @@ mod tests { branch_name: "feat/auth-api-tests".into(), old_upstream_branch_name: "feat/auth-api".into(), old_upstream_oid: "def456".into(), - new_base_branch_name: "feat/auth-api".into(), + new_base: RestackBaseTarget::local("feat/auth-api"), new_parent: None, }, ]); @@ -391,6 +434,23 @@ mod tests { assert!(!previews[1].parent_changed); } + #[test] + fn keeps_logical_base_name_separate_from_rebase_ref() { + let action = RestackAction { + node_id: Uuid::new_v4(), + branch_name: "feat/auth-ui".into(), + old_upstream_branch_name: "feat/auth".into(), + old_upstream_oid: "abc123".into(), + new_base: RestackBaseTarget::with_rebase_ref("main", "origin/main"), + new_parent: Some(ParentRef::Trunk), + }; + + let previews = previews_for_actions(std::slice::from_ref(&action)); + + assert_eq!(previews[0].onto_branch, "main"); + assert_eq!(action.new_base.rebase_ref(), "origin/main"); + } + #[test] fn plans_restack_after_branch_advance_with_old_head_for_immediate_child() { with_temp_repo("dig-restack", |repo| { @@ -458,7 +518,7 @@ mod tests { assert_eq!(planned[0].branch_name, "feat/auth-api"); assert_eq!(planned[0].old_upstream_branch_name, "feat/auth"); assert_eq!(planned[0].old_upstream_oid, "old-parent-head-oid"); - assert_eq!(planned[0].new_base_branch_name, "feat/auth"); + assert_eq!(planned[0].new_base.branch_name, "feat/auth"); assert_eq!(planned[0].new_parent, None); assert_eq!(planned[1].node_id, grandchild_id); @@ -468,7 +528,7 @@ mod tests { planned[1].old_upstream_oid, git::ref_oid("feat/auth-api").unwrap() ); - assert_eq!(planned[1].new_base_branch_name, "feat/auth-api"); + assert_eq!(planned[1].new_base.branch_name, "feat/auth-api"); assert_eq!(planned[1].new_parent, None); }); } @@ -551,7 +611,7 @@ mod tests { api_id, "feat/auth-api", "feat/auth", - "feat/platform", + &RestackBaseTarget::local("feat/platform"), &ParentRef::Branch { node_id: platform_id, }, @@ -566,7 +626,7 @@ mod tests { planned[0].old_upstream_oid, git::merge_base("feat/auth", "feat/auth-api").unwrap() ); - assert_eq!(planned[0].new_base_branch_name, "feat/platform"); + assert_eq!(planned[0].new_base.branch_name, "feat/platform"); assert_eq!( planned[0].new_parent, Some(ParentRef::Branch { @@ -581,7 +641,7 @@ mod tests { planned[1].old_upstream_oid, git::ref_oid("feat/auth-api").unwrap() ); - assert_eq!(planned[1].new_base_branch_name, "feat/auth-api"); + assert_eq!(planned[1].new_base.branch_name, "feat/auth-api"); assert_eq!(planned[1].new_parent, None); }); } diff --git a/src/core/store/operation.rs b/src/core/store/operation.rs index 9fdf878..7765ffa 100644 --- a/src/core/store/operation.rs +++ b/src/core/store/operation.rs @@ -40,7 +40,7 @@ mod tests { use uuid::Uuid; use super::{clear_operation, load_operation, save_operation}; - use crate::core::restack::RestackAction; + use crate::core::restack::{RestackAction, RestackBaseTarget}; use crate::core::store::dig_paths; use crate::core::store::{ ParentRef, PendingCommitOperation, PendingOperationKind, PendingOperationState, @@ -64,7 +64,7 @@ mod tests { branch_name: "feat/auth-ui".into(), old_upstream_branch_name: "feat/auth".into(), old_upstream_oid: "old".into(), - new_base_branch_name: "feat/auth".into(), + new_base: RestackBaseTarget::local("feat/auth"), new_parent: Some(ParentRef::Trunk), }], ) @@ -94,7 +94,7 @@ mod tests { branch_name: "feat/auth-ui".into(), old_upstream_branch_name: "feat/auth".into(), old_upstream_oid: "old".into(), - new_base_branch_name: "feat/auth".into(), + new_base: RestackBaseTarget::local("feat/auth"), new_parent: Some(ParentRef::Trunk), }], ) @@ -117,6 +117,7 @@ mod tests { let operation = PendingOperationState::start( PendingOperationKind::Sync(PendingSyncOperation { original_branch: "feat/auth".into(), + remote_sync_enabled: true, deleted_branches: vec!["feat/missing".into()], restacked_branches: Vec::new(), phase: PendingSyncPhase::RestackOutdatedLocalStacks, @@ -127,7 +128,7 @@ mod tests { branch_name: "feat/auth".into(), old_upstream_branch_name: "main".into(), old_upstream_oid: "old".into(), - new_base_branch_name: "main".into(), + new_base: RestackBaseTarget::local("main"), new_parent: None, }], ) @@ -157,7 +158,7 @@ mod tests { branch_name: "feat/auth".into(), old_upstream_branch_name: "main".into(), old_upstream_oid: "old".into(), - new_base_branch_name: "feat/platform".into(), + new_base: RestackBaseTarget::local("feat/platform"), new_parent: Some(ParentRef::Trunk), }], ) diff --git a/src/core/store/types.rs b/src/core/store/types.rs index 34fcfa6..ea10384 100644 --- a/src/core/store/types.rs +++ b/src/core/store/types.rs @@ -4,7 +4,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use crate::core::restack::{RestackAction, RestackPreview}; +use crate::core::restack::{RestackAction, RestackBaseTarget, RestackPreview}; pub const DIG_STATE_VERSION: u32 = 1; pub const DIG_CONFIG_VERSION: u32 = 1; @@ -149,7 +149,7 @@ impl PendingOperationState { pub fn advance_after_success(mut self) -> (RestackPreview, Option) { let preview = RestackPreview { branch_name: self.restack.active_action.branch_name.clone(), - onto_branch: self.restack.active_action.new_base_branch_name.clone(), + onto_branch: self.restack.active_action.new_base.branch_name.clone(), parent_changed: self.restack.active_action.new_parent.is_some(), }; self.restack.completed_branches.push(preview.clone()); @@ -239,11 +239,11 @@ pub struct PendingCleanCandidate { pub kind: PendingCleanCandidateKind, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] pub enum PendingCleanCandidateKind { DeletedLocally, - IntegratedIntoParent, + IntegratedIntoParent { parent_base: RestackBaseTarget }, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -264,6 +264,7 @@ pub struct PendingReparentOperation { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct PendingSyncOperation { pub original_branch: String, + pub remote_sync_enabled: bool, pub deleted_branches: Vec, pub restacked_branches: Vec, pub phase: PendingSyncPhase, @@ -392,7 +393,7 @@ mod tests { PendingOrphanOperation, PendingReparentOperation, PendingSyncOperation, PendingSyncPhase, TrackedPullRequest, }; - use crate::core::restack::RestackAction; + use crate::core::restack::{RestackAction, RestackBaseTarget}; use uuid::Uuid; #[test] @@ -518,7 +519,7 @@ mod tests { branch_name: "feature/api-followup".into(), old_upstream_branch_name: "feature/api".into(), old_upstream_oid: "abc123".into(), - new_base_branch_name: "feature/api".into(), + new_base: RestackBaseTarget::local("feature/api"), new_parent: None, }; let second_action = RestackAction { @@ -526,7 +527,7 @@ mod tests { branch_name: "feature/api-tests".into(), old_upstream_branch_name: "feature/api-followup".into(), old_upstream_oid: "def456".into(), - new_base_branch_name: "feature/api-followup".into(), + new_base: RestackBaseTarget::local("feature/api-followup"), new_parent: Some(ParentRef::Trunk), }; let operation = PendingOperationState::start( @@ -569,6 +570,7 @@ mod tests { fn reports_sync_operation_command_name() { let operation = PendingOperationKind::Sync(PendingSyncOperation { original_branch: "feature/api".into(), + remote_sync_enabled: true, deleted_branches: vec!["feature/missing".into()], restacked_branches: Vec::new(), phase: PendingSyncPhase::ReconcileDeletedLocalBranches, diff --git a/src/core/sync.rs b/src/core/sync.rs index 1e6c883..fd904ce 100644 --- a/src/core/sync.rs +++ b/src/core/sync.rs @@ -1,7 +1,8 @@ +use std::collections::{BTreeSet, HashSet}; use std::io; use std::process::ExitStatus; -use crate::core::clean::{self, CleanOptions}; +use crate::core::clean::{self, CleanOptions, CleanPlanMode}; use crate::core::deleted_local; use crate::core::graph::BranchGraph; use crate::core::restack::{self, RestackAction, RestackPreview}; @@ -46,6 +47,32 @@ pub struct SyncOutcome { pub paused: bool, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RemotePushActionKind { + CreateRemoteBranch, + UpdateRemoteBranch, + ForceUpdateRemoteBranch, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemotePushAction { + pub target: git::BranchPushTarget, + pub kind: RemotePushActionKind, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct RemotePushPlan { + pub actions: Vec, +} + +#[derive(Debug)] +pub struct RemotePushOutcome { + pub status: ExitStatus, + pub pushed_actions: Vec, + pub failed_action: Option, + pub failure_output: Option, +} + #[derive(Debug, Default, Clone)] struct LocalSyncProgress { deleted_branches: Vec, @@ -55,6 +82,7 @@ struct LocalSyncProgress { #[derive(Debug)] struct LocalSyncOutcome { status: ExitStatus, + remote_sync_enabled: bool, deleted_branches: Vec, restacked_branches: Vec, failure_output: Option, @@ -182,9 +210,15 @@ fn run_full_sync() -> io::Result { let mut session = open_initialized("dig is not initialized; run 'dig init' first")?; workflow::ensure_ready_for_operation(&session.repo, "sync")?; workflow::ensure_no_pending_operation(&session.paths, "sync")?; + let remote_sync_enabled = fetch_sync_remotes(&session)?; let original_branch = git::current_branch_name()?; - let outcome = execute_local_sync(&mut session, original_branch, LocalSyncProgress::default())?; + let outcome = execute_local_sync( + &mut session, + original_branch, + LocalSyncProgress::default(), + remote_sync_enabled, + )?; finalize_full_sync_outcome(outcome) } @@ -211,6 +245,7 @@ fn resume_full_sync( if restack_outcome.paused { return Ok(LocalSyncOutcome { status: restack_outcome.status, + remote_sync_enabled: payload.remote_sync_enabled, deleted_branches: progress.deleted_branches, restacked_branches: progress.restacked_branches, failure_output: restack_outcome.failure_output, @@ -218,7 +253,12 @@ fn resume_full_sync( }); } - execute_local_sync(&mut session, payload.original_branch, progress) + execute_local_sync( + &mut session, + payload.original_branch, + progress, + payload.remote_sync_enabled, + ) } fn finalize_full_sync_outcome(outcome: LocalSyncOutcome) -> io::Result { @@ -231,7 +271,11 @@ fn finalize_full_sync_outcome(outcome: LocalSyncOutcome) -> io::Result io::Result { + let cleanup_mode = clean::mode_for_sync(remote_sync_enabled); + loop { if let Some(step) = plan_deleted_local_branch_step(session)? { - if let Some(outcome) = - apply_deleted_local_branch_step(session, &original_branch, &mut progress, step)? - { + if let Some(outcome) = apply_deleted_local_branch_step( + session, + &original_branch, + &mut progress, + step, + remote_sync_enabled, + )? { return Ok(outcome); } continue; } - if let Some(step) = plan_outdated_branch_step(session)? { - if let Some(outcome) = - apply_outdated_branch_step(session, &original_branch, &mut progress, step)? - { + if let Some(step) = plan_outdated_branch_step(session, cleanup_mode)? { + if let Some(outcome) = apply_outdated_branch_step( + session, + &original_branch, + &mut progress, + step, + remote_sync_enabled, + )? { return Ok(outcome); } continue; } - return finish_local_sync(&original_branch, progress); + return finish_local_sync(&original_branch, progress, remote_sync_enabled); } } fn finish_local_sync( original_branch: &str, progress: LocalSyncProgress, + remote_sync_enabled: bool, ) -> io::Result { let mut failure_output = None; let mut status = git::success_status()?; @@ -294,6 +350,7 @@ fn finish_local_sync( Ok(LocalSyncOutcome { status, + remote_sync_enabled, deleted_branches: progress.deleted_branches, restacked_branches: progress.restacked_branches, failure_output, @@ -312,6 +369,7 @@ fn apply_deleted_local_branch_step( original_branch: &str, progress: &mut LocalSyncProgress, step: deleted_local::DeletedLocalBranchStep, + remote_sync_enabled: bool, ) -> io::Result> { let restack_actions = deleted_local::restack_actions_for_step(&session.state, &step)?; @@ -325,11 +383,13 @@ fn apply_deleted_local_branch_step( PendingSyncPhase::ReconcileDeletedLocalBranches, &step.branch_name, &restack_actions, + remote_sync_enabled, ) } fn plan_outdated_branch_step( session: &crate::core::store::StoreSession, + cleanup_mode: CleanPlanMode, ) -> io::Result> { let graph = BranchGraph::new(&session.state); let mut candidates = session @@ -352,6 +412,17 @@ fn plan_outdated_branch_step( continue; } + if clean::cleanup_candidate_for_branch( + &session.state, + &session.config.trunk_branch, + &node, + cleanup_mode, + )? + .is_some() + { + continue; + } + let parent_branch_name = graph .parent_branch_name(&node, &session.config.trunk_branch) .ok_or_else(|| { @@ -385,7 +456,7 @@ fn plan_outdated_branch_step( &node.branch_name, &old_upstream_oid, &old_head_oid, - &parent_branch_name, + &restack::RestackBaseTarget::local(&parent_branch_name), )?; return Ok(Some(OutdatedBranchStep { @@ -402,6 +473,7 @@ fn apply_outdated_branch_step( original_branch: &str, progress: &mut LocalSyncProgress, step: OutdatedBranchStep, + remote_sync_enabled: bool, ) -> io::Result> { execute_sync_restack_step( session, @@ -410,6 +482,7 @@ fn apply_outdated_branch_step( PendingSyncPhase::RestackOutdatedLocalStacks, &step.branch_name, &step.actions, + remote_sync_enabled, ) } @@ -420,6 +493,7 @@ fn execute_sync_restack_step( phase: PendingSyncPhase, step_branch_name: &str, actions: &[RestackAction], + remote_sync_enabled: bool, ) -> io::Result> { if actions.is_empty() { return Ok(None); @@ -429,6 +503,7 @@ fn execute_sync_restack_step( session, PendingOperationKind::Sync(PendingSyncOperation { original_branch: original_branch.to_string(), + remote_sync_enabled, deleted_branches: progress.deleted_branches.clone(), restacked_branches: progress.restacked_branches.clone(), phase, @@ -444,6 +519,7 @@ fn execute_sync_restack_step( if restack_outcome.paused { return Ok(Some(LocalSyncOutcome { status: restack_outcome.status, + remote_sync_enabled, deleted_branches: progress.deleted_branches.clone(), restacked_branches: progress.restacked_branches.clone(), failure_output: restack_outcome.failure_output, @@ -453,3 +529,268 @@ fn execute_sync_restack_step( Ok(None) } + +fn fetch_sync_remotes(session: &crate::core::store::StoreSession) -> io::Result { + let mut remote_names = BTreeSet::new(); + + for node in session.state.nodes.iter().filter(|node| !node.archived) { + if !git::branch_exists(&node.branch_name)? { + continue; + } + + if let Some(target) = git::branch_push_target(&node.branch_name)? { + remote_names.insert(target.remote_name); + } + } + + if remote_names.is_empty() { + return Ok(false); + } + + for remote_name in remote_names { + let fetch_output = git::fetch_remote(&remote_name)?; + if !fetch_output.status.success() { + let combined_output = fetch_output.combined_output(); + return Err(io::Error::other(if combined_output.is_empty() { + format!("git fetch --prune '{remote_name}' failed") + } else { + format!("git fetch --prune '{remote_name}' failed: {combined_output}") + })); + } + } + + Ok(true) +} + +pub fn plan_remote_pushes( + restacked_branch_names: &[String], + excluded_branch_names: &[String], +) -> io::Result { + let session = open_initialized("dig is not initialized; run 'dig init' first")?; + let excluded_branch_names = excluded_branch_names + .iter() + .cloned() + .collect::>(); + let mut planned_branch_names = HashSet::new(); + let mut actions = Vec::new(); + + for branch_name in dedup_branch_names(restacked_branch_names) { + let Some(action) = plan_remote_push_action( + &branch_name, + &excluded_branch_names, + true, + &mut planned_branch_names, + )? + else { + continue; + }; + + actions.push(action); + } + + let mut active_branch_names = session + .state + .nodes + .iter() + .filter(|node| !node.archived) + .map(|node| node.branch_name.clone()) + .collect::>(); + active_branch_names.sort(); + + for branch_name in active_branch_names { + let Some(action) = plan_remote_push_action( + &branch_name, + &excluded_branch_names, + false, + &mut planned_branch_names, + )? + else { + continue; + }; + + actions.push(action); + } + + Ok(RemotePushPlan { actions }) +} + +pub fn execute_remote_push_plan(plan: &RemotePushPlan) -> io::Result { + let mut pushed_actions = Vec::new(); + + for action in &plan.actions { + let push_output = match action.kind { + RemotePushActionKind::CreateRemoteBranch | RemotePushActionKind::UpdateRemoteBranch => { + git::push_branch_to_remote(&action.target)? + } + RemotePushActionKind::ForceUpdateRemoteBranch => { + git::force_push_branch_to_remote_with_lease(&action.target)? + } + }; + + if !push_output.status.success() { + return Ok(RemotePushOutcome { + status: push_output.status, + pushed_actions, + failed_action: Some(action.clone()), + failure_output: Some(push_output.combined_output()), + }); + } + + pushed_actions.push(action.clone()); + } + + Ok(RemotePushOutcome { + status: git::success_status()?, + pushed_actions, + failed_action: None, + failure_output: None, + }) +} + +fn dedup_branch_names(branch_names: &[String]) -> Vec { + let mut seen = HashSet::new(); + let mut deduped = Vec::new(); + + for branch_name in branch_names { + if seen.insert(branch_name.clone()) { + deduped.push(branch_name.clone()); + } + } + + deduped +} + +fn plan_remote_push_action( + branch_name: &str, + excluded_branch_names: &HashSet, + allow_force_update: bool, + planned_branch_names: &mut HashSet, +) -> io::Result> { + if excluded_branch_names.contains(branch_name) + || !planned_branch_names.insert(branch_name.into()) + { + return Ok(None); + } + + if !git::branch_exists(branch_name)? { + return Ok(None); + } + + let Some(target) = git::branch_push_target(branch_name)? else { + return Ok(None); + }; + let Some(remote_oid) = + git::remote_tracking_branch_oid(&target.remote_name, &target.branch_name)? + else { + return Ok(Some(RemotePushAction { + target, + kind: RemotePushActionKind::CreateRemoteBranch, + })); + }; + + let local_oid = git::ref_oid(branch_name)?; + if remote_oid == local_oid { + return Ok(None); + } + + if allow_force_update { + return Ok(Some(RemotePushAction { + target, + kind: RemotePushActionKind::ForceUpdateRemoteBranch, + })); + } + + let remote_tracking_branch_ref = + git::remote_tracking_branch_ref(&target.remote_name, &target.branch_name); + if git::merge_base(&remote_tracking_branch_ref, branch_name)? == remote_oid { + return Ok(Some(RemotePushAction { + target, + kind: RemotePushActionKind::UpdateRemoteBranch, + })); + } + + Ok(None) +} + +#[cfg(test)] +mod tests { + use super::{RemotePushActionKind, plan_remote_pushes}; + use crate::core::test_support::{ + append_file, commit_file, create_tracked_branch, git_ok, initialize_main_repo, + with_temp_repo, + }; + + fn initialize_origin_remote(repo: &std::path::Path) { + git_ok(repo, &["init", "--bare", "origin.git"]); + git_ok(repo, &["remote", "add", "origin", "origin.git"]); + git_ok(repo, &["push", "-u", "origin", "main"]); + } + + #[test] + fn plans_force_pushes_and_missing_remote_branches_while_excluding_cleanup_candidates() { + with_temp_repo("dig-sync-core", |repo| { + initialize_main_repo(repo); + initialize_origin_remote(repo); + create_tracked_branch("feat/auth"); + commit_file(repo, "auth.txt", "auth\n", "feat: auth"); + git_ok(repo, &["push", "-u", "origin", "feat/auth"]); + create_tracked_branch("feat/auth-ui"); + commit_file(repo, "ui.txt", "ui\n", "feat: auth ui"); + git_ok(repo, &["checkout", "feat/auth"]); + append_file( + repo, + "auth.txt", + "auth local\n", + "feat: auth local follow-up", + ); + create_tracked_branch("feat/merged"); + commit_file(repo, "merged.txt", "merged\n", "feat: merged"); + + let plan = plan_remote_pushes(&["feat/auth".to_string()], &["feat/merged".to_string()]) + .unwrap(); + + assert_eq!(plan.actions.len(), 2); + assert_eq!(plan.actions[0].target.branch_name, "feat/auth"); + assert_eq!( + plan.actions[0].kind, + RemotePushActionKind::ForceUpdateRemoteBranch + ); + assert_eq!(plan.actions[1].target.branch_name, "feat/auth-ui"); + assert_eq!( + plan.actions[1].kind, + RemotePushActionKind::CreateRemoteBranch + ); + assert!( + plan.actions + .iter() + .all(|action| action.target.branch_name != "feat/merged") + ); + }); + } + + #[test] + fn plans_fast_forward_pushes_for_active_branches_ahead_of_remote() { + with_temp_repo("dig-sync-core", |repo| { + initialize_main_repo(repo); + initialize_origin_remote(repo); + create_tracked_branch("feat/auth"); + commit_file(repo, "auth.txt", "auth\n", "feat: auth"); + git_ok(repo, &["push", "-u", "origin", "feat/auth"]); + append_file( + repo, + "auth.txt", + "auth local\n", + "feat: auth local follow-up", + ); + + let plan = plan_remote_pushes(&[], &[]).unwrap(); + + assert_eq!(plan.actions.len(), 1); + assert_eq!(plan.actions[0].target.branch_name, "feat/auth"); + assert_eq!( + plan.actions[0].kind, + RemotePushActionKind::UpdateRemoteBranch + ); + }); + } +} diff --git a/tests/clean.rs b/tests/clean.rs index fa0f17c..4f40f44 100644 --- a/tests/clean.rs +++ b/tests/clean.rs @@ -164,7 +164,7 @@ fn clean_continues_paused_deleted_local_restack() { let operation = load_operation_json(repo).unwrap(); assert_eq!(operation["origin"]["type"].as_str(), Some("clean")); assert_eq!( - operation["origin"]["current_candidate"]["kind"].as_str(), + operation["origin"]["current_candidate"]["kind"]["kind"].as_str(), Some("deleted_locally") ); diff --git a/tests/sync.rs b/tests/sync.rs index 8b1a407..6b0279f 100644 --- a/tests/sync.rs +++ b/tests/sync.rs @@ -1,11 +1,83 @@ mod support; +use std::fs; +use std::path::{Path, PathBuf}; + +use serde_json::json; use support::{ active_rebase_head_name, commit_file, dig, dig_ok, dig_with_input, find_archived_node, find_node, git_ok, git_stdout, initialize_main_repo, load_events_json, load_operation_json, load_state_json, overwrite_file, strip_ansi, with_temp_repo, write_file, }; +fn initialize_origin_remote(repo: &Path) { + git_ok(repo, &["init", "--bare", ".git/origin.git"]); + git_ok(repo, &["remote", "add", "origin", ".git/origin.git"]); + git_ok(repo, &["push", "-u", "origin", "main"]); + git_ok( + repo, + &[ + "--git-dir=.git/origin.git", + "symbolic-ref", + "HEAD", + "refs/heads/main", + ], + ); +} + +fn clone_origin(repo: &Path, clone_name: &str) -> PathBuf { + let clone_dir = repo.join(".git").join(clone_name); + let clone_path = clone_dir.to_string_lossy().into_owned(); + git_ok(repo, &["clone", ".git/origin.git", &clone_path]); + git_ok(&clone_dir, &["config", "user.name", "Dig Remote"]); + git_ok(&clone_dir, &["config", "user.email", "remote@example.com"]); + git_ok(&clone_dir, &["config", "commit.gpgsign", "false"]); + clone_dir +} + +fn track_pull_request_number(repo: &Path, branch_name: &str, number: u64) { + let state_path = repo.join(".git/dig/state.json"); + let mut state = load_state_json(repo); + let nodes = state["nodes"].as_array_mut().unwrap(); + let node = nodes + .iter_mut() + .find(|node| { + node["branch_name"].as_str() == Some(branch_name) + && node["archived"].as_bool() == Some(false) + }) + .unwrap(); + node["pull_request"] = json!({ "number": number }); + fs::write(state_path, serde_json::to_string_pretty(&state).unwrap()).unwrap(); +} + +fn setup_remotely_merged_root_branch_with_local_trunk_advance(repo: &Path) { + initialize_main_repo(repo); + initialize_origin_remote(repo); + dig_ok(repo, &["init"]); + dig_ok(repo, &["branch", "feat/auth"]); + overwrite_file(repo, "shared.txt", "feature\n", "feat: auth"); + git_ok(repo, &["push", "-u", "origin", "feat/auth"]); + dig_ok(repo, &["branch", "feat/auth-ui"]); + commit_file(repo, "ui.txt", "ui\n", "feat: ui"); + git_ok(repo, &["checkout", "main"]); + overwrite_file( + repo, + "shared.txt", + "local trunk\n", + "feat: local trunk follow-up", + ); + + let remote_repo = clone_origin(repo, "origin-worktree"); + git_ok(&remote_repo, &["checkout", "main"]); + git_ok(&remote_repo, &["merge", "--squash", "origin/feat/auth"]); + git_ok( + &remote_repo, + &["commit", "--quiet", "-m", "feat: merge auth"], + ); + git_ok(&remote_repo, &["push", "origin", "main"]); + git_ok(&remote_repo, &["push", "origin", "--delete", "feat/auth"]); +} + #[test] fn sync_reports_noop_when_local_stacks_are_already_in_sync() { with_temp_repo("dig-sync-cli", |repo| { @@ -19,6 +91,57 @@ fn sync_reports_noop_when_local_stacks_are_already_in_sync() { }); } +#[test] +fn sync_cleans_branch_merged_by_tracked_pull_request_number_without_rebasing() { + with_temp_repo("dig-sync-cli", |repo| { + initialize_main_repo(repo); + dig_ok(repo, &["init"]); + dig_ok(repo, &["branch", "feat/auth"]); + commit_file(repo, "auth.txt", "auth\n", "feat: add GitHub PR workflows"); + commit_file( + repo, + "tree.txt", + "tree\n", + "fix(tree): show PR numbers in lineage views", + ); + track_pull_request_number(repo, "feat/auth", 2); + + git_ok(repo, &["checkout", "main"]); + git_ok(repo, &["merge", "--squash", "feat/auth"]); + git_ok( + repo, + &[ + "commit", + "--quiet", + "-m", + "feat: add GitHub PR workflows (#2)", + "-m", + "Adds GitHub PR creation and tracking support.", + ], + ); + + let output = dig_with_input(repo, &["sync"], "y\n"); + let stdout = strip_ansi(&String::from_utf8(output.stdout).unwrap()); + let stderr = String::from_utf8(output.stderr).unwrap(); + + assert!( + output.status.success(), + "stdout:\n{stdout}\nstderr:\n{stderr}" + ); + assert!(stdout.contains("Merged branches ready to clean:")); + assert!(stdout.contains("- feat/auth merged into main")); + assert!(stdout.contains("Delete 1 merged branch? [y/N]")); + assert!(stdout.contains("Deleted:")); + assert!(stdout.contains("- feat/auth")); + assert!(!stderr.contains("dig sync --continue")); + assert!(!git_stdout(repo, &["branch", "--list", "feat/auth"]).contains("feat/auth")); + assert!(load_operation_json(repo).is_none()); + let state = load_state_json(repo); + assert!(find_node(&state, "feat/auth").is_none()); + assert!(find_archived_node(&state, "feat/auth").is_some()); + }); +} + #[test] fn sync_restacks_root_stack_after_trunk_advances() { with_temp_repo("dig-sync-cli", |repo| { @@ -455,3 +578,280 @@ fn sync_clears_stale_operation_after_rebase_abort() { assert!(load_operation_json(repo).is_none()); }); } + +#[test] +fn sync_aborts_before_local_restack_when_fetch_fails() { + with_temp_repo("dig-sync-cli", |repo| { + initialize_main_repo(repo); + dig_ok(repo, &["init"]); + dig_ok(repo, &["branch", "feat/auth"]); + commit_file(repo, "auth.txt", "auth\n", "feat: auth"); + git_ok(repo, &["checkout", "main"]); + commit_file(repo, "README.md", "root\nmain\n", "feat: trunk follow-up"); + git_ok(repo, &["checkout", "feat/auth"]); + git_ok(repo, &["remote", "add", "origin", "/does/not/exist"]); + + let output = dig(repo, &["sync"]); + let stderr = String::from_utf8(output.stderr).unwrap(); + + assert!(!output.status.success()); + assert!(stderr.contains("git fetch --prune 'origin' failed")); + assert_eq!(git_stdout(repo, &["branch", "--show-current"]), "feat/auth"); + assert_ne!( + git_stdout(repo, &["merge-base", "main", "feat/auth"]), + git_stdout(repo, &["rev-parse", "main"]) + ); + assert!(load_operation_json(repo).is_none()); + }); +} + +#[test] +fn sync_cleans_root_branch_merged_remotely_and_restacks_child_onto_fetched_remote_parent() { + with_temp_repo("dig-sync-cli", |repo| { + setup_remotely_merged_root_branch_with_local_trunk_advance(repo); + + let output = dig_with_input(repo, &["sync"], "y\nn\n"); + let stdout = strip_ansi(&String::from_utf8(output.stdout).unwrap()); + + assert!(output.status.success()); + assert!(stdout.contains("Merged branches ready to clean:")); + assert!(stdout.contains("- feat/auth merged into main")); + assert!(stdout.contains("Delete 1 merged branch? [y/N]")); + assert!(stdout.contains("Deleted:")); + assert!(stdout.contains("- feat/auth")); + assert!(stdout.contains("Restacked:")); + assert!(!stdout.contains("- feat/auth onto main")); + assert!(stdout.contains("- feat/auth-ui onto main")); + assert!(stdout.contains("Remote branches to update:")); + assert!(stdout.contains("- create feat/auth-ui on origin")); + assert!(!stdout.contains("- create feat/auth on origin")); + assert!(stdout.contains("Push these remote updates? [y/N]")); + assert!(stdout.contains("Skipped remote updates.")); + assert_eq!( + git_stdout(repo, &["merge-base", "origin/main", "feat/auth-ui"]), + git_stdout(repo, &["rev-parse", "origin/main"]) + ); + assert_ne!( + git_stdout(repo, &["rev-parse", "main"]), + git_stdout(repo, &["rev-parse", "origin/main"]) + ); + + let state = load_state_json(repo); + let child = find_node(&state, "feat/auth-ui").unwrap(); + assert_eq!(child["base_ref"], "main"); + assert_eq!(child["parent"]["kind"], "trunk"); + assert!(find_node(&state, "feat/auth").is_none()); + assert!(find_archived_node(&state, "feat/auth").is_some()); + }); +} + +#[test] +fn sync_skips_recreating_remotely_merged_root_branch_when_cleanup_is_declined() { + with_temp_repo("dig-sync-cli", |repo| { + setup_remotely_merged_root_branch_with_local_trunk_advance(repo); + + let output = dig_with_input(repo, &["sync"], "n\nn\n"); + let stdout = strip_ansi(&String::from_utf8(output.stdout).unwrap()); + + assert!(output.status.success()); + assert!(stdout.contains("Merged branches ready to clean:")); + assert!(stdout.contains("- feat/auth merged into main")); + assert!(stdout.contains("Delete 1 merged branch? [y/N]")); + assert!(stdout.contains("Skipped cleanup.")); + assert!(stdout.contains("Remote branches to update:")); + assert!(stdout.contains("- create feat/auth-ui on origin")); + assert!(!stdout.contains("- create feat/auth on origin")); + assert!(!stdout.contains("- force-push feat/auth on origin")); + assert!(load_operation_json(repo).is_none()); + assert!(git_stdout(repo, &["branch", "--list", "feat/auth"]).contains("feat/auth")); + + let state = load_state_json(repo); + assert!(find_node(&state, "feat/auth").is_some()); + assert!(find_archived_node(&state, "feat/auth").is_none()); + }); +} + +#[test] +fn sync_cleans_middle_branch_merged_remotely_and_excludes_it_from_remote_pushes() { + with_temp_repo("dig-sync-cli", |repo| { + initialize_main_repo(repo); + initialize_origin_remote(repo); + dig_ok(repo, &["init"]); + dig_ok(repo, &["branch", "feat/auth"]); + commit_file(repo, "auth.txt", "auth\n", "feat: auth"); + git_ok(repo, &["push", "-u", "origin", "feat/auth"]); + dig_ok(repo, &["branch", "feat/auth-api"]); + commit_file(repo, "api.txt", "api\n", "feat: auth api"); + git_ok(repo, &["push", "-u", "origin", "feat/auth-api"]); + dig_ok(repo, &["branch", "feat/auth-api-tests"]); + commit_file(repo, "tests.txt", "tests\n", "feat: tests"); + + let remote_repo = clone_origin(repo, "origin-worktree"); + let tracking_ref = "origin/feat/auth"; + git_ok(&remote_repo, &["checkout", "-b", "feat/auth", tracking_ref]); + git_ok(&remote_repo, &["merge", "--squash", "origin/feat/auth-api"]); + git_ok( + &remote_repo, + &["commit", "--quiet", "-m", "feat: merge auth api"], + ); + git_ok(&remote_repo, &["push", "origin", "feat/auth"]); + + let output = dig_with_input(repo, &["sync"], "y\nn\n"); + let stdout = strip_ansi(&String::from_utf8(output.stdout).unwrap()); + + assert!(output.status.success()); + assert!(stdout.contains("- feat/auth-api merged into feat/auth")); + assert!(stdout.contains("- feat/auth-api-tests onto feat/auth")); + assert!(stdout.contains("Remote branches to update:")); + assert!(stdout.contains("- create feat/auth-api-tests on origin")); + assert!(!stdout.contains("- create feat/auth-api on origin")); + assert_eq!( + git_stdout( + repo, + &["merge-base", "origin/feat/auth", "feat/auth-api-tests"] + ), + git_stdout(repo, &["rev-parse", "origin/feat/auth"]) + ); + + let state = load_state_json(repo); + let tests_branch = find_node(&state, "feat/auth-api-tests").unwrap(); + let parent = find_node(&state, "feat/auth").unwrap(); + assert_eq!(tests_branch["base_ref"], "feat/auth"); + assert_eq!(tests_branch["parent"]["kind"], "branch"); + assert_eq!(tests_branch["parent"]["node_id"], parent["id"]); + assert!(find_node(&state, "feat/auth-api").is_none()); + assert!(find_archived_node(&state, "feat/auth-api").is_some()); + }); +} + +#[test] +fn sync_prompts_to_push_missing_remote_branch_after_local_sync() { + with_temp_repo("dig-sync-cli", |repo| { + initialize_main_repo(repo); + initialize_origin_remote(repo); + dig_ok(repo, &["init"]); + dig_ok(repo, &["branch", "feat/auth"]); + commit_file(repo, "auth.txt", "auth\n", "feat: auth"); + + let output = dig_with_input(repo, &["sync"], "y\n"); + let stdout = strip_ansi(&String::from_utf8(output.stdout).unwrap()); + + assert!(output.status.success()); + assert!(stdout.contains("Local stacks are already in sync.")); + assert!(stdout.contains("Remote branches to update:")); + assert!(stdout.contains("- create feat/auth on origin")); + assert!(stdout.contains("Push these remote updates? [y/N]")); + assert!(stdout.contains("Updated remote branches:")); + assert!(stdout.contains("- created feat/auth on origin")); + assert!( + git_stdout( + repo, + &["ls-remote", "--heads", "origin", "refs/heads/feat/auth"] + ) + .contains("refs/heads/feat/auth") + ); + }); +} + +#[test] +fn sync_prompts_to_push_active_branch_ahead_of_remote() { + with_temp_repo("dig-sync-cli", |repo| { + initialize_main_repo(repo); + initialize_origin_remote(repo); + dig_ok(repo, &["init"]); + dig_ok(repo, &["branch", "feat/auth"]); + commit_file(repo, "auth.txt", "auth\n", "feat: auth"); + git_ok(repo, &["push", "-u", "origin", "feat/auth"]); + commit_file(repo, "auth.txt", "auth v2\n", "feat: auth follow-up"); + + let output = dig_with_input(repo, &["sync"], "y\n"); + let stdout = strip_ansi(&String::from_utf8(output.stdout).unwrap()); + + assert!(output.status.success()); + assert!(stdout.contains("Local stacks are already in sync.")); + assert!(stdout.contains("Remote branches to update:")); + assert!(stdout.contains("- push feat/auth on origin")); + assert!(stdout.contains("Push these remote updates? [y/N]")); + assert!(stdout.contains("Updated remote branches:")); + assert!(stdout.contains("- pushed feat/auth on origin")); + assert_eq!( + git_stdout(repo, &["rev-parse", "feat/auth"]), + git_stdout(repo, &["rev-parse", "origin/feat/auth"]) + ); + }); +} + +#[test] +fn sync_continues_paused_remote_cleanup_with_stored_remote_rebase_target() { + with_temp_repo("dig-sync-cli", |repo| { + initialize_main_repo(repo); + initialize_origin_remote(repo); + dig_ok(repo, &["init"]); + dig_ok(repo, &["branch", "feat/auth"]); + commit_file(repo, "auth.txt", "auth\n", "feat: parent"); + git_ok(repo, &["push", "-u", "origin", "feat/auth"]); + dig_ok(repo, &["branch", "feat/auth-ui"]); + commit_file(repo, "conflict.txt", "child\n", "feat: child"); + + let remote_repo = clone_origin(repo, "origin-worktree"); + git_ok(&remote_repo, &["checkout", "main"]); + git_ok(&remote_repo, &["merge", "--squash", "origin/feat/auth"]); + git_ok( + &remote_repo, + &["commit", "--quiet", "-m", "feat: merge auth"], + ); + std::fs::write(remote_repo.join("conflict.txt"), "remote\n").unwrap(); + git_ok(&remote_repo, &["add", "conflict.txt"]); + git_ok( + &remote_repo, + &["commit", "--quiet", "-m", "feat: remote main follow-up"], + ); + git_ok(&remote_repo, &["push", "origin", "main"]); + + let paused = dig_with_input(repo, &["sync"], "y\n"); + let stderr = String::from_utf8(paused.stderr).unwrap(); + + assert!(!paused.status.success()); + assert!(stderr.contains("dig sync --continue")); + + let operation = load_operation_json(repo).unwrap(); + assert_eq!(operation["origin"]["type"].as_str(), Some("clean")); + assert_eq!( + operation["origin"]["current_candidate"]["kind"]["kind"].as_str(), + Some("integrated_into_parent") + ); + assert_eq!( + operation["origin"]["current_candidate"]["kind"]["parent_base"]["new_base_branch_name"] + .as_str(), + Some("main") + ); + assert_eq!( + operation["origin"]["current_candidate"]["kind"]["parent_base"]["new_base_ref"] + .as_str(), + Some("origin/main") + ); + + std::fs::write(repo.join("conflict.txt"), "resolved\n").unwrap(); + git_ok(repo, &["add", "conflict.txt"]); + + let resumed = dig_with_input(repo, &["sync", "--continue"], "n\n"); + let stdout = strip_ansi(&String::from_utf8(resumed.stdout).unwrap()); + + assert!(resumed.status.success()); + assert!(stdout.contains("Deleted:")); + assert!(stdout.contains("- feat/auth")); + assert!(stdout.contains("Restacked:")); + assert!(stdout.contains("- feat/auth-ui onto main")); + assert_eq!( + git_stdout(repo, &["merge-base", "origin/main", "feat/auth-ui"]), + git_stdout(repo, &["rev-parse", "origin/main"]) + ); + + let state = load_state_json(repo); + let child = find_node(&state, "feat/auth-ui").unwrap(); + assert_eq!(child["base_ref"], "main"); + assert_eq!(child["parent"]["kind"], "trunk"); + assert!(find_node(&state, "feat/auth").is_none()); + assert!(load_operation_json(repo).is_none()); + }); +}