diff --git a/src/commands.rs b/src/commands.rs index 37cabc2..7d7a219 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -733,6 +733,28 @@ pub(crate) fn run_push(preflight: &env::PreflightContext) -> ExitCode { for index in state.completed_pushes..state.push_branches.len() { let branch = &state.push_branches[index]; + + let remote_ref = format!("refs/remotes/origin/{branch}"); + let local_ref = format!("refs/heads/{branch}"); + match gitops::is_ancestor(&remote_ref, &local_ref) { + Ok(true) => {} + Ok(false) => { + if let Err(save_error) = sync_state::save_push(&state) { + eprintln!("error: {save_error}"); + return ExitCode::from(1); + } + eprintln!( + "error: remote branch `origin/{branch}` has commits not in local `{branch}`; \ + pull or rebase to integrate remote changes before pushing" + ); + return ExitCode::from(1); + } + Err(message) => { + eprintln!("error: {message}"); + return ExitCode::from(1); + } + } + println!( "Pushing branch {}/{}: {}", index + 1, diff --git a/tests/harness/mod.rs b/tests/harness/mod.rs index 618259a..d7d5467 100644 --- a/tests/harness/mod.rs +++ b/tests/harness/mod.rs @@ -228,6 +228,16 @@ if [[ "${1:-}" == "merge-base" && "${2:-}" == "--is-ancestor" ]]; then descendant_branch="${descendant#refs/heads/}" descendant_branch="${descendant_branch#refs/remotes/origin/}" + if [[ -n "${STCK_TEST_NOT_ANCESTOR_PAIRS:-}" ]]; then + IFS=',' read -ra pairs <<< "${STCK_TEST_NOT_ANCESTOR_PAIRS}" + for pair in "${pairs[@]}"; do + IFS=':' read -r pa pd <<< "${pair}" + if [[ "${ancestor_branch}" == "${pa}" && "${descendant_branch}" == "${pd}" ]]; then + exit 1 + fi + done + fi + if [[ -n "${STCK_TEST_ANCESTOR_PAIRS:-}" ]]; then IFS=',' read -ra pairs <<< "${STCK_TEST_ANCESTOR_PAIRS}" for pair in "${pairs[@]}"; do @@ -238,6 +248,10 @@ if [[ "${1:-}" == "merge-base" && "${2:-}" == "--is-ancestor" ]]; then done fi + if [[ "${ancestor_branch}" == "${descendant_branch}" ]]; then + exit 0 + fi + case "${ancestor_branch}:${descendant_branch}" in main:feature-base|main:feature-branch|main:feature-child) exit 0 ;; feature-base:feature-branch|feature-base:feature-child) exit 0 ;; diff --git a/tests/push.rs b/tests/push.rs index c736df5..504dceb 100644 --- a/tests/push.rs +++ b/tests/push.rs @@ -364,6 +364,34 @@ fn sync_then_push_after_squash_merge_produces_correct_retargets() { )); } +#[test] +fn push_aborts_when_remote_has_commits_not_in_local_branch() { + let (temp, mut cmd) = stck_cmd_with_stubbed_tools(); + let log_path = log_path(&temp, "stck-push-diverged.log"); + cmd.env("STCK_TEST_LOG", log_path.as_os_str()); + cmd.env("STCK_TEST_NEEDS_PUSH_BRANCHES", "feature-branch"); + // Simulate remote/feature-branch NOT being an ancestor of local feature-branch, + // meaning the remote has commits that would be lost on force push. + cmd.env( + "STCK_TEST_NOT_ANCESTOR_PAIRS", + "feature-branch:feature-branch", + ); + cmd.arg("push"); + + cmd.assert() + .code(1) + .stderr(predicate::str::contains("feature-branch")); + + // The force push should NOT have been executed. + if log_path.exists() { + let log = fs::read_to_string(&log_path).expect("push log should be readable"); + assert!( + !log.contains("push --force-with-lease origin feature-branch"), + "push should not force-push a branch whose remote has diverged" + ); + } +} + #[test] fn push_blocked_while_sync_state_exists() { let (temp, mut sync) = stck_cmd_with_stubbed_tools();