Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
10 changes: 8 additions & 2 deletions docs/sync-recovery.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
156 changes: 128 additions & 28 deletions src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -408,6 +410,31 @@ pub(crate) fn run_submit(
ExitCode::SUCCESS
}

fn capture_remote_branch_leases(branches: &[String]) -> Result<Vec<RemoteBranchLease>, 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
Expand Down Expand Up @@ -515,12 +542,27 @@ pub(crate) fn run_sync(
return ExitCode::SUCCESS;
}

let lease_branches = steps
.iter()
.map(|step| step.branch.clone())
.collect::<Vec<_>>();
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}");
Expand Down Expand Up @@ -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();
Expand All @@ -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,
};
Expand All @@ -819,50 +869,102 @@ 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 {}/{}: {}",
index + 1,
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);
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
26 changes: 23 additions & 3 deletions src/gitops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())?;
Expand Down Expand Up @@ -320,6 +331,15 @@ pub fn remote_branch_exists(branch: &str) -> Result<bool, String> {
ref_exists(&format!("refs/remotes/origin/{branch}"))
}

/// Resolve the fetched `origin/<branch>` tip, or return `None` when absent.
pub fn remote_branch_head(branch: &str) -> Result<Option<String>, 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")
Expand Down
Loading
Loading