diff --git a/USAGE.md b/USAGE.md index 1e474bc..0804743 100644 --- a/USAGE.md +++ b/USAGE.md @@ -116,8 +116,9 @@ stck push `push` applies remote changes for the last computed stack state: -- pushes stack branches with `--force-with-lease`, -- creates a missing remote branch while keeping the ancestry guard for existing remote branches, +- pushes rewritten stack branches with exact remote-tip leases captured by `sync`, +- keeps the ancestry guard for branches without a matching sync plan, +- creates a missing remote branch only while it remains absent, - applies pending PR base retarget operations, - reports summary and remaining work on failure. diff --git a/docs/sync-recovery.md b/docs/sync-recovery.md index f62185c..91607c3 100644 --- a/docs/sync-recovery.md +++ b/docs/sync-recovery.md @@ -13,7 +13,8 @@ A fresh `stck sync`: 1. refuses to start while a native Git rebase is already in progress, 2. fetches `origin` and computes the current stack plan, -3. saves that plan under `.git/stck/` before running its first rebase, +3. saves that plan and the fetched remote tips it may rewrite under `.git/stck/` + before running its first rebase, 4. records progress after every completed step. If a rebase fails, `stck` records the failed step and the branch head at which @@ -61,9 +62,14 @@ so `git rebase --abort` must finish first. - In-flight sync progress lives in `.git/stck/last-plan.json`. - `stck push` is blocked while sync state remains unresolved. - A successful sync clears in-flight state and saves a stack-scoped retarget - plan in `.git/stck/last-sync-plan.json`. + plan plus the pre-rebase remote tips in `.git/stck/last-sync-plan.json`. - `stck push` reuses that cached plan only when its repository and exact stack metadata still match. +- Rewritten branches use the sync-time tips as exact force-with-lease + expectations. Sync refuses to authorize a rewrite when the fetched remote + already has commits missing locally. If a remote changes after sync, push + stops instead of overwriting that change; integrate it locally and rerun + sync. - A no-op sync clears any stale cached retarget plan. - Sync recovery never pushes branches or mutates pull requests; `stck push` remains the explicit remote mutation step. diff --git a/src/commands.rs b/src/commands.rs index 8fc6346..9ce3985 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -6,7 +6,9 @@ use crate::env; use crate::github; use crate::gitops; use crate::stack; -use crate::sync_state::{self, LastSyncPlan, PushState, SyncPlanScope, SyncState}; +use crate::sync_state::{ + self, LastSyncPlan, PushState, RemoteBranchLease, SyncPlanScope, SyncState, +}; /// Print the detected stack, its PR state, and any local follow-up actions. pub(crate) fn run_status(preflight: &env::PreflightContext) -> ExitCode { @@ -408,6 +410,31 @@ pub(crate) fn run_submit( ExitCode::SUCCESS } +fn capture_remote_branch_leases(branches: &[String]) -> Result, String> { + branches + .iter() + .map(|branch| { + let expected_remote_head = gitops::remote_branch_head(branch)?; + if expected_remote_head.is_some() + && !gitops::is_ancestor( + &format!("refs/remotes/origin/{branch}"), + &format!("refs/heads/{branch}"), + )? + { + return Err(format!( + "remote branch `origin/{branch}` has commits not in local `{branch}`; \ + pull or rebase to integrate remote changes before syncing" + )); + } + + Ok(RemoteBranchLease { + branch: branch.clone(), + expected_remote_head, + }) + }) + .collect() +} + /// Rebase the current stacked branch and its descendants onto the correct bases. /// /// This command supports resumable operation state via `sync_state`, including @@ -515,12 +542,27 @@ pub(crate) fn run_sync( return ExitCode::SUCCESS; } + let lease_branches = steps + .iter() + .map(|step| step.branch.clone()) + .collect::>(); + let push_leases = match capture_remote_branch_leases(&lease_branches) { + Ok(push_leases) => push_leases, + Err(message) => { + eprintln!("error: {message}"); + return ExitCode::from(1); + } + }; let state = SyncState { steps, completed_steps: 0, failed_step: None, failed_step_branch_head: None, - plan_scope: Some(SyncPlanScope::new(&preflight.repository, &stack)), + plan_scope: Some(SyncPlanScope::new( + &preflight.repository, + &stack, + push_leases, + )), }; if let Err(message) = sync_state::save_sync(&state) { eprintln!("error: {message}"); @@ -777,18 +819,25 @@ pub(crate) fn run_push(preflight: &env::PreflightContext) -> ExitCode { return ExitCode::from(1); } }; - let retargets = if let Some(plan) = cached_plan { + let (retargets, sync_push_leases) = if let Some(plan) = cached_plan { if plan.matches(&preflight.repository, &preflight.default_branch, &stack) { - plan.retargets + let push_leases = plan.push_leases().to_vec(); + (plan.retargets, push_leases) } else { if let Err(message) = sync_state::clear_last_sync_plan() { eprintln!("error: {message}"); return ExitCode::from(1); } - stack::build_push_retargets(&stack, &preflight.default_branch) + ( + stack::build_push_retargets(&stack, &preflight.default_branch), + Vec::new(), + ) } } else { - stack::build_push_retargets(&stack, &preflight.default_branch) + ( + stack::build_push_retargets(&stack, &preflight.default_branch), + Vec::new(), + ) }; let retargets = stack::filter_pending_retargets(retargets, &stack); let mut push_branches = Vec::new(); @@ -809,6 +858,7 @@ pub(crate) fn run_push(preflight: &env::PreflightContext) -> ExitCode { let state = PushState { push_branches, completed_pushes: 0, + sync_push_leases, retargets, completed_retargets: 0, }; @@ -819,41 +869,87 @@ pub(crate) fn run_push(preflight: &env::PreflightContext) -> ExitCode { state } }; - let starting_completed_pushes = state.completed_pushes; let starting_completed_retargets = state.completed_retargets; + let mut pushed_this_run = 0; for index in state.completed_pushes..state.push_branches.len() { - let branch = &state.push_branches[index]; - + let branch = state.push_branches[index].clone(); let remote_ref = format!("refs/remotes/origin/{branch}"); let local_ref = format!("refs/heads/{branch}"); - let remote_exists = match gitops::remote_branch_exists(branch) { - Ok(exists) => exists, + let local_head = match gitops::resolve_ref(&local_ref) { + Ok(head) => head, + Err(message) => { + eprintln!("error: {message}"); + return ExitCode::from(1); + } + }; + let remote_head = match gitops::remote_branch_head(&branch) { + Ok(head) => head, Err(message) => { eprintln!("error: {message}"); return ExitCode::from(1); } }; - if remote_exists { - 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}"); + + if remote_head.as_deref() == Some(local_head.as_str()) { + println!("Branch {branch} already matches origin; skipping."); + state.completed_pushes = index + 1; + if let Err(message) = sync_state::save_push(&state) { + eprintln!("error: {message}"); + return ExitCode::from(1); + } + continue; + } + + let sync_expected_remote_head = state + .sync_push_leases + .iter() + .find(|lease| lease.branch == branch) + .map(|lease| lease.expected_remote_head.clone()); + let expected_remote_head = match sync_expected_remote_head { + Some(expected_remote_head) => { + if remote_head != expected_remote_head { + if let Err(clear_error) = sync_state::clear() { + eprintln!("error: {clear_error}"); + return ExitCode::from(1); + } + if let Err(clear_error) = sync_state::clear_last_sync_plan() { + eprintln!("error: {clear_error}"); return ExitCode::from(1); } + let expected = expected_remote_head.as_deref().unwrap_or("missing"); + let found = remote_head.as_deref().unwrap_or("missing"); eprintln!( - "error: remote branch `origin/{branch}` has commits not in local `{branch}`; \ - pull or rebase to integrate remote changes before pushing" + "error: remote branch `origin/{branch}` changed since sync; expected {expected}, found {found}; integrate the remote changes locally, then rerun `stck sync` before pushing" ); return ExitCode::from(1); } - Err(message) => { - eprintln!("error: {message}"); - return ExitCode::from(1); + expected_remote_head + } + None => { + if remote_head.is_some() { + 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); + } + } } + remote_head } - } + }; println!( "Pushing branch {}/{}: {}", @@ -861,8 +957,14 @@ pub(crate) fn run_push(preflight: &env::PreflightContext) -> ExitCode { state.push_branches.len(), branch ); - println!("$ git push --force-with-lease origin {branch}"); - if let Err(message) = gitops::push_force_with_lease(branch) { + let lease = format!( + "--force-with-lease=refs/heads/{branch}:{}", + expected_remote_head.as_deref().unwrap_or_default() + ); + println!("$ git push {lease} origin {branch}"); + if let Err(message) = + gitops::push_force_with_lease(&branch, expected_remote_head.as_deref()) + { if let Err(save_error) = sync_state::save_push(&state) { eprintln!("error: {save_error}"); return ExitCode::from(1); @@ -874,6 +976,7 @@ pub(crate) fn run_push(preflight: &env::PreflightContext) -> ExitCode { } state.completed_pushes = index + 1; + pushed_this_run += 1; if let Err(message) = sync_state::save_push(&state) { eprintln!("error: {message}"); return ExitCode::from(1); @@ -919,9 +1022,6 @@ pub(crate) fn run_push(preflight: &env::PreflightContext) -> ExitCode { eprintln!("error: {message}"); return ExitCode::from(1); } - let pushed_this_run = state - .completed_pushes - .saturating_sub(starting_completed_pushes); let retargeted_this_run = state .completed_retargets .saturating_sub(starting_completed_retargets); diff --git a/src/gitops.rs b/src/gitops.rs index 9279cfc..13e5126 100644 --- a/src/gitops.rs +++ b/src/gitops.rs @@ -274,10 +274,21 @@ pub fn push_branch(branch: &str) -> Result<(), String> { } } -/// Push `branch` to `origin` with `--force-with-lease`. -pub fn push_force_with_lease(branch: &str) -> Result<(), String> { +/// Push `branch` to `origin` with an explicit expected remote branch tip. +/// +/// `None` means the remote branch must still be absent. Pinning the lease to a +/// specific expected state prevents an intervening fetch from weakening the +/// overwrite protection. +pub fn push_force_with_lease( + branch: &str, + expected_remote_head: Option<&str>, +) -> Result<(), String> { + let lease = format!( + "--force-with-lease=refs/heads/{branch}:{}", + expected_remote_head.unwrap_or_default() + ); let status = Command::new("git") - .args(["push", "--force-with-lease", "origin", branch]) + .args(["push", &lease, "origin", branch]) .stderr(Stdio::inherit()) .status() .map_err(|_| "failed to run `git push`; ensure this is a git repository".to_string())?; @@ -320,6 +331,15 @@ pub fn remote_branch_exists(branch: &str) -> Result { ref_exists(&format!("refs/remotes/origin/{branch}")) } +/// Resolve the fetched `origin/` tip, or return `None` when absent. +pub fn remote_branch_head(branch: &str) -> Result, String> { + let remote_ref = format!("refs/remotes/origin/{branch}"); + if !ref_exists(&remote_ref)? { + return Ok(None); + } + resolve_ref(&remote_ref).map(Some) +} + /// Push `branch` to `origin` and configure it as the upstream branch. pub fn push_set_upstream(branch: &str) -> Result<(), String> { let output = Command::new("git") diff --git a/src/sync_state.rs b/src/sync_state.rs index 4e9c45f..37b5201 100644 --- a/src/sync_state.rs +++ b/src/sync_state.rs @@ -31,6 +31,9 @@ pub struct PushState { pub push_branches: Vec, /// Number of branch pushes that completed successfully. pub completed_pushes: usize, + /// Remote branch tips captured by the sync that authorized rewritten pushes. + #[serde(default)] + pub(crate) sync_push_leases: Vec, /// PR base retarget operations to run after the branch pushes succeed. pub retargets: Vec, /// Number of retarget operations that completed successfully. @@ -49,19 +52,35 @@ pub struct LastSyncPlan { pub retargets: Vec, } +/// Expected remote state captured before sync rewrites a local branch. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct RemoteBranchLease { + /// Local branch whose rewritten history may be pushed. + pub(crate) branch: String, + /// Fetched remote tip expected at push time, or `None` when it was absent. + pub(crate) expected_remote_head: Option, +} + /// Identity required before a cached sync plan can be reused by `stck push`. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub(crate) struct SyncPlanScope { repository: String, stack: Vec, + #[serde(default)] + push_leases: Vec, } impl SyncPlanScope { - /// Capture the repository and exact ordered PR metadata for a sync plan. - pub(crate) fn new(repository: &str, stack: &[PullRequest]) -> Self { + /// Capture the repository, ordered PR metadata, and remote tips for a sync plan. + pub(crate) fn new( + repository: &str, + stack: &[PullRequest], + push_leases: Vec, + ) -> Self { Self { repository: repository.to_string(), stack: stack.to_vec(), + push_leases, } } @@ -69,6 +88,11 @@ impl SyncPlanScope { pub(crate) fn matches(&self, repository: &str, stack: &[PullRequest]) -> bool { self.repository == repository && self.stack == stack } + + /// Return the remote tips that must still match before rewritten pushes. + pub(crate) fn push_leases(&self) -> &[RemoteBranchLease] { + &self.push_leases + } } impl LastSyncPlan { @@ -85,6 +109,14 @@ impl LastSyncPlan { .as_ref() .is_some_and(|scope| scope.matches(repository, stack)) } + + /// Return the sync-time remote tips associated with this cached plan. + pub(crate) fn push_leases(&self) -> &[RemoteBranchLease] { + self.scope + .as_ref() + .map(SyncPlanScope::push_leases) + .unwrap_or_default() + } } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -244,7 +276,20 @@ mod tests { } fn scope() -> SyncPlanScope { - SyncPlanScope::new("example/stck", &stack()) + SyncPlanScope::new( + "example/stck", + &stack(), + vec![ + RemoteBranchLease { + branch: "feature-b".to_string(), + expected_remote_head: Some("bbbb1234".to_string()), + }, + RemoteBranchLease { + branch: "feature-c".to_string(), + expected_remote_head: None, + }, + ], + ) } #[test] @@ -292,6 +337,7 @@ mod tests { let state = PushState { push_branches: vec!["feature-b".to_string(), "feature-c".to_string()], completed_pushes: 1, + sync_push_leases: scope().push_leases().to_vec(), retargets: vec![RetargetStep { branch: "feature-b".to_string(), new_base_ref: "main".to_string(), @@ -308,6 +354,7 @@ mod tests { LastPlanState::Push(p) => { assert_eq!(p.push_branches, vec!["feature-b", "feature-c"]); assert_eq!(p.completed_pushes, 1); + assert_eq!(p.sync_push_leases, scope().push_leases()); assert_eq!(p.retargets.len(), 1); assert_eq!(p.retargets[0].branch, "feature-b"); assert_eq!(p.completed_retargets, 0); @@ -356,6 +403,7 @@ mod tests { let push = LastPlanState::Push(PushState { push_branches: vec![], completed_pushes: 0, + sync_push_leases: vec![], retargets: vec![], completed_retargets: 0, }); diff --git a/tests/harness/mod.rs b/tests/harness/mod.rs index be7e6b4..7d79acb 100644 --- a/tests/harness/mod.rs +++ b/tests/harness/mod.rs @@ -333,7 +333,7 @@ if [[ "${1:-}" == "rebase" && "${2:-}" == "--onto" ]]; then exit 0 fi -if [[ "${1:-}" == "push" && "${2:-}" == "--force-with-lease" && "${3:-}" == "origin" ]]; then +if [[ "${1:-}" == "push" && "${2:-}" == --force-with-lease=* && "${3:-}" == "origin" ]]; then branch="${4:-}" if [[ -n "${STCK_TEST_LOG:-}" ]]; then echo "$*" >> "${STCK_TEST_LOG}" diff --git a/tests/push.rs b/tests/push.rs index cd99951..bb40911 100644 --- a/tests/push.rs +++ b/tests/push.rs @@ -18,10 +18,10 @@ fn push_executes_pushes_before_retargets_and_prints_summary() { cmd.assert() .success() .stdout(predicate::str::contains( - "$ git push --force-with-lease origin feature-branch", + "$ git push --force-with-lease=refs/heads/feature-branch:ffffffffffffffffffffffffffffffffffffffff origin feature-branch", )) .stdout(predicate::str::contains( - "$ git push --force-with-lease origin feature-child", + "$ git push --force-with-lease=refs/heads/feature-child:ffffffffffffffffffffffffffffffffffffffff origin feature-child", )) .stdout(predicate::str::contains( "$ gh pr edit feature-branch --base main", @@ -33,7 +33,7 @@ fn push_executes_pushes_before_retargets_and_prints_summary() { let log = fs::read_to_string(&log_path).expect("push log should exist"); let push_idx = log - .find("push --force-with-lease origin feature-child") + .find("push --force-with-lease=refs/heads/feature-child:ffffffffffffffffffffffffffffffffffffffff origin feature-child") .expect("second push command missing"); let retarget_idx = log .find("pr edit feature-branch --base main") @@ -138,8 +138,8 @@ fn push_resumes_after_partial_retarget_failure() { )); let log = fs::read_to_string(&log_path).expect("push log should exist"); - let push_a = "push --force-with-lease origin feature-branch"; - let push_b = "push --force-with-lease origin feature-child"; + let push_a = "push --force-with-lease=refs/heads/feature-branch:ffffffffffffffffffffffffffffffffffffffff origin feature-branch"; + let push_b = "push --force-with-lease=refs/heads/feature-child:ffffffffffffffffffffffffffffffffffffffff origin feature-child"; let retarget_a = "pr edit feature-branch --base main"; let retarget_b = "pr edit feature-child --base feature-branch"; assert_eq!(log.matches(push_a).count(), 1); @@ -343,7 +343,7 @@ fn push_publishes_a_branch_when_its_remote_ref_is_missing() { cmd.assert() .success() .stdout(predicate::str::contains( - "$ git push --force-with-lease origin feature-child", + "$ git push --force-with-lease=refs/heads/feature-child: origin feature-child", )) .stdout(predicate::str::contains( "Push succeeded. Pushed 1 branch(es) and applied 0 PR base update(s) in this run.", @@ -351,7 +351,7 @@ fn push_publishes_a_branch_when_its_remote_ref_is_missing() { let log = fs::read_to_string(&log_path).expect("push log should exist"); assert!( - log.contains("push --force-with-lease origin feature-child"), + log.contains("push --force-with-lease=refs/heads/feature-child: origin feature-child"), "push should publish a branch whose remote ref is absent" ); } @@ -375,7 +375,7 @@ fn push_fails_closed_when_remote_ref_lookup_errors() { let log = fs::read_to_string(&log_path).unwrap_or_default(); assert!( - !log.contains("push --force-with-lease origin feature-child"), + !log.contains("push --force-with-lease"), "push must not treat a ref lookup failure as a missing remote branch" ); } @@ -399,10 +399,20 @@ fn sync_then_push_after_squash_merge_produces_correct_retargets() { "STCK_TEST_NEEDS_PUSH_BRANCHES", "feature-branch,feature-child", ); + push.env( + "STCK_TEST_NOT_ANCESTOR_PAIRS", + "feature-branch:feature-branch,feature-child:feature-child", + ); push.arg("push"); push.assert() .success() + .stdout(predicate::str::contains( + "$ git push --force-with-lease=refs/heads/feature-branch:ffffffffffffffffffffffffffffffffffffffff origin feature-branch", + )) + .stdout(predicate::str::contains( + "$ git push --force-with-lease=refs/heads/feature-child:ffffffffffffffffffffffffffffffffffffffff origin feature-child", + )) .stdout(predicate::str::contains( "$ gh pr edit feature-branch --base main", )) @@ -412,6 +422,53 @@ fn sync_then_push_after_squash_merge_produces_correct_retargets() { )); } +#[test] +fn push_aborts_when_a_remote_branch_changes_after_sync() { + let (temp, mut sync) = stck_cmd_with_stubbed_tools(); + let log_path = log_path(&temp, "sync-push-remote-drift.log"); + + sync.env("STCK_TEST_LOG", log_path.as_os_str()); + sync.env("STCK_TEST_NEEDS_PUSH_BRANCH", "feature-branch"); + sync.arg("sync"); + sync.assert().success(); + + let mut push = stck_cmd_for_temp(&temp); + push.env("STCK_TEST_LOG", log_path.as_os_str()); + push.env( + "STCK_TEST_FEATURE_BRANCH_HEAD", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + ); + push.arg("push"); + + push.assert().code(1).stderr(predicate::str::contains( + "error: remote branch `origin/feature-branch` changed since sync; expected ffffffffffffffffffffffffffffffffffffffff, found 2222222222222222222222222222222222222222; integrate the remote changes locally, then rerun `stck sync` before pushing", + )); + + let log = fs::read_to_string(&log_path).expect("push log should exist"); + assert!( + !log.contains("push --force-with-lease"), + "push must not overwrite a remote branch that changed after sync" + ); + assert!( + !temp + .path() + .join("git-dir") + .join("stck") + .join("last-plan.json") + .exists(), + "remote drift should clear push state so sync can be recomputed" + ); + assert!( + !temp + .path() + .join("git-dir") + .join("stck") + .join("last-sync-plan.json") + .exists(), + "remote drift should clear the stale cached sync plan" + ); +} + #[test] fn push_aborts_when_remote_has_commits_not_in_local_branch() { let (temp, mut cmd) = stck_cmd_with_stubbed_tools(); @@ -434,7 +491,7 @@ fn push_aborts_when_remote_has_commits_not_in_local_branch() { 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"), + !log.contains("push --force-with-lease"), "push should not force-push a branch whose remote has diverged" ); } diff --git a/tests/real_git.rs b/tests/real_git.rs index aa891e2..a4463bd 100644 --- a/tests/real_git.rs +++ b/tests/real_git.rs @@ -132,7 +132,7 @@ fn status_and_push_handle_a_missing_remote_branch() { push.assert() .success() .stdout(predicate::str::contains( - "$ git push --force-with-lease origin feature-missing-remote", + "$ git push --force-with-lease=refs/heads/feature-missing-remote: origin feature-missing-remote", )) .stdout(predicate::str::contains( "Push succeeded. Pushed 1 branch(es) and applied 0 PR base update(s) in this run.", @@ -145,7 +145,7 @@ fn status_and_push_handle_a_missing_remote_branch() { } #[test] -fn sync_rebases_a_real_linear_stack_after_main_advances() { +fn sync_then_push_rewrites_a_real_linear_stack_with_captured_leases() { let repo = RealGitRepo::new(); repo.create_branch("feature-base"); @@ -199,6 +199,29 @@ fn sync_rebases_a_real_linear_stack_after_main_advances() { assert_ne!(repo.local_sha("refs/heads/feature-child"), old_child_sha); assert_eq!(repo.remote_sha("feature-base"), old_base_sha); assert_eq!(repo.remote_sha("feature-child"), old_child_sha); + + let mut push = repo.stck_cmd(); + push.arg("push"); + push.assert() + .success() + .stdout(predicate::str::contains(format!( + "$ git push --force-with-lease=refs/heads/feature-base:{old_base_sha} origin feature-base" + ))) + .stdout(predicate::str::contains(format!( + "$ git push --force-with-lease=refs/heads/feature-child:{old_child_sha} origin feature-child" + ))) + .stdout(predicate::str::contains( + "Push succeeded. Pushed 2 branch(es) and applied 0 PR base update(s) in this run.", + )); + + assert_eq!( + repo.remote_sha("feature-base"), + repo.local_sha("refs/heads/feature-base") + ); + assert_eq!( + repo.remote_sha("feature-child"), + repo.local_sha("refs/heads/feature-child") + ); } #[test] diff --git a/tests/sync.rs b/tests/sync.rs index e02c29e..76dd5ac 100644 --- a/tests/sync.rs +++ b/tests/sync.rs @@ -60,6 +60,28 @@ fn sync_uses_remote_old_base_when_local_old_base_is_missing() { )); } +#[test] +fn sync_refuses_to_authorize_rewriting_remote_only_commits() { + let (temp, mut cmd) = stck_cmd_with_stubbed_tools(); + let log_path = log_path(&temp, "stck-sync-diverged-remote.log"); + cmd.env("STCK_TEST_LOG", log_path.as_os_str()); + cmd.env( + "STCK_TEST_NOT_ANCESTOR_PAIRS", + "feature-branch:feature-branch", + ); + cmd.arg("sync"); + + cmd.assert().code(1).stderr(predicate::str::contains( + "error: remote branch `origin/feature-branch` has commits not in local `feature-branch`; pull or rebase to integrate remote changes before syncing", + )); + + let log = fs::read_to_string(&log_path).unwrap_or_default(); + assert!( + !log.contains("rebase --onto"), + "sync must not rewrite a branch whose remote contains unintegrated commits" + ); +} + #[test] fn sync_surfaces_rebase_failure_with_guidance() { let (_temp, mut cmd) = stck_cmd_with_stubbed_tools();