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
79 changes: 69 additions & 10 deletions src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,21 @@ pub(crate) fn run_new(preflight: &env::PreflightContext, new_branch: &str) -> Ex
eprintln!("error: {message}");
return ExitCode::from(1);
}
} else {
let needs_push = match gitops::branch_needs_push(current_branch) {
Ok(needs_push) => needs_push,
Err(message) => {
eprintln!("error: {message}");
return ExitCode::from(1);
}
};
if needs_push {
println!("$ git push origin {}", current_branch);
if let Err(message) = gitops::push_branch(current_branch) {
eprintln!("error: {message}");
return ExitCode::from(1);
}
}
}

let current_has_pr = match github::pr_exists_for_head(current_branch) {
Expand Down Expand Up @@ -309,6 +324,21 @@ pub(crate) fn run_submit(
eprintln!("error: {message}");
return ExitCode::from(1);
}
} else {
let needs_push = match gitops::branch_needs_push(current_branch) {
Ok(needs_push) => needs_push,
Err(message) => {
eprintln!("error: {message}");
return ExitCode::from(1);
}
};
if needs_push {
println!("$ git push origin {}", current_branch);
if let Err(message) = gitops::push_branch(current_branch) {
eprintln!("error: {message}");
return ExitCode::from(1);
}
}
}

let current_has_pr = match github::pr_exists_for_head(current_branch) {
Expand Down Expand Up @@ -532,6 +562,11 @@ pub(crate) fn run_sync(
}
}

// Track branches rebased in this sync run so subsequent steps that depend
// on them use the just-updated local ref instead of the stale remote ref.
let mut rebased_in_this_sync: std::collections::HashSet<String> =
std::collections::HashSet::new();

for index in state.completed_steps..state.steps.len() {
let step = &state.steps[index];
let branch_ref = format!("refs/heads/{}", step.branch);
Expand All @@ -550,21 +585,44 @@ pub(crate) fn run_sync(
return ExitCode::from(1);
}
};
let onto_ref = if rebased_in_this_sync.contains(&step.new_base_ref) {
// Parent was rebased in a prior step of this sync; the local ref
// is up-to-date but the remote ref is stale (not yet pushed).
format!("refs/heads/{}", step.new_base_ref)
} else {
match gitops::resolve_onto_ref(&step.new_base_ref) {
Ok(r) => r,
Err(message) => {
eprintln!("error: {message}");
return ExitCode::from(1);
}
}
};

let total_steps = state.steps.len();
println!(
"Step {}/{}: rebasing {} onto {} (from {})",
index + 1,
total_steps,
step.branch,
step.new_base_ref,
step.old_base_ref
);
if step.old_base_ref == step.new_base_ref {
println!(
"Step {}/{}: rebasing {} onto {} (dropping already-upstream commits)",
index + 1,
total_steps,
step.branch,
step.new_base_ref
);
} else {
println!(
"Step {}/{}: rebasing {} onto {} (from {})",
index + 1,
total_steps,
step.branch,
step.new_base_ref,
step.old_base_ref
);
}
println!(
"$ git rebase --onto {} {} {}",
step.new_base_ref, old_base_sha, step.branch
onto_ref, old_base_sha, step.branch
);
if let Err(message) = gitops::rebase_onto(&step.new_base_ref, &old_base_sha, &step.branch) {
if let Err(message) = gitops::rebase_onto(&onto_ref, &old_base_sha, &step.branch) {
state.failed_step = Some(index);
state.failed_step_branch_head = Some(branch_head);
if let Err(save_error) = sync_state::save_sync(&state) {
Expand All @@ -582,6 +640,7 @@ pub(crate) fn run_sync(
return ExitCode::from(1);
}

rebased_in_this_sync.insert(step.branch.clone());
state.completed_steps = index + 1;
state.failed_step = None;
state.failed_step_branch_head = None;
Expand Down
41 changes: 36 additions & 5 deletions src/gitops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ pub fn resolve_ref(reference: &str) -> Result<String, String> {
rev_parse(reference)
}

/// Resolve the `--onto` target ref for a rebase, preferring `origin/<branch>`
/// over the local ref so the rebase target reflects the fetched remote state.
pub fn resolve_onto_ref(base_branch: &str) -> Result<String, String> {
resolve_base_ref(base_branch)
}

/// Resolve the fork point to use as the old base for `git rebase --onto`.
///
/// This prefers the merge-base between `branch` and `base_branch` so sync can
Expand All @@ -66,16 +72,20 @@ pub fn resolve_old_base_for_rebase(base_branch: &str, branch: &str) -> Result<St
}

fn resolve_base_ref(base_branch: &str) -> Result<String, String> {
let local_ref = format!("refs/heads/{base_branch}");
if ref_exists(&local_ref)? {
return Ok(local_ref);
}

// Prefer the remote ref because `stck sync` fetches before planning.
// Using the local ref for shared branches like the default branch can
// produce a stale merge-base when the remote has advanced (e.g. after a
// PR merge on GitHub while local main has not been pulled).
let remote_ref = format!("refs/remotes/origin/{base_branch}");
if ref_exists(&remote_ref)? {
return Ok(remote_ref);
}

let local_ref = format!("refs/heads/{base_branch}");
if ref_exists(&local_ref)? {
return Ok(local_ref);
}

Err(format!(
"could not resolve old base branch `{base_branch}` locally or on origin; fetch and/or restore the branch, then rerun `stck sync`"
))
Expand Down Expand Up @@ -192,6 +202,27 @@ pub fn rebase_onto(new_base: &str, old_base: &str, branch: &str) -> Result<(), S
}
}

/// Push `branch` to `origin` as a regular (fast-forward) push.
///
/// Unlike [`push_force_with_lease`] this does **not** rewrite remote history.
/// A non-fast-forward push will fail, which is the desired safety behaviour
/// when the caller simply wants to publish new local commits.
pub fn push_branch(branch: &str) -> Result<(), String> {
let status = Command::new("git")
.args(["push", "origin", branch])
.stderr(Stdio::inherit())
.status()
.map_err(|_| "failed to run `git push`; ensure this is a git repository".to_string())?;

if status.success() {
Ok(())
} else {
Err(format!(
"push failed for branch {branch}; fix the push error and retry"
))
}
}

/// Push `branch` to `origin` with `--force-with-lease`.
pub fn push_force_with_lease(branch: &str) -> Result<(), String> {
let status = Command::new("git")
Expand Down
40 changes: 38 additions & 2 deletions tests/harness/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,13 @@ if [[ "${1:-}" == "rev-parse" && "${2:-}" == "--verify" ]]; then
fi
;;
feature-child) echo "3333333333333333333333333333333333333333" ;;
main)
if [[ -n "${STCK_TEST_LOCAL_MAIN_SHA:-}" ]]; then
echo "${STCK_TEST_LOCAL_MAIN_SHA}"
else
echo "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
fi
;;
*) echo "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" ;;
esac
exit 0
Expand Down Expand Up @@ -115,6 +122,13 @@ if [[ "${1:-}" == "rev-parse" && "${2:-}" == "--verify" ]]; then
;;
feature-branch) echo "2222222222222222222222222222222222222222" ;;
feature-child) echo "3333333333333333333333333333333333333333" ;;
main)
if [[ -n "${STCK_TEST_REMOTE_MAIN_SHA:-}" ]]; then
echo "${STCK_TEST_REMOTE_MAIN_SHA}"
else
echo "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
fi
;;
*) echo "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" ;;
esac
exit 0
Expand Down Expand Up @@ -209,8 +223,19 @@ if [[ "${1:-}" == "merge-base" && "${2:-}" != "--is-ancestor" ]]; then
refs/remotes/origin/feature-branch)
echo "2222222222222222222222222222222222222222"
;;
refs/heads/main|refs/remotes/origin/main)
echo "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
refs/heads/main)
if [[ -n "${STCK_TEST_LOCAL_MAIN_SHA:-}" ]]; then
echo "${STCK_TEST_LOCAL_MAIN_SHA}"
else
echo "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
fi
;;
refs/remotes/origin/main)
if [[ -n "${STCK_TEST_REMOTE_MAIN_SHA:-}" ]]; then
echo "${STCK_TEST_REMOTE_MAIN_SHA}"
else
echo "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
fi
;;
*) echo "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" ;;
esac
Expand Down Expand Up @@ -289,6 +314,17 @@ if [[ "${1:-}" == "push" && "${2:-}" == "--force-with-lease" && "${3:-}" == "ori
exit 0
fi

if [[ "${1:-}" == "push" && "${2:-}" == "origin" ]]; then
branch="${3:-}"
if [[ -n "${STCK_TEST_LOG:-}" ]]; then
echo "$*" >> "${STCK_TEST_LOG}"
fi
if [[ "${STCK_TEST_PUSH_FAIL_BRANCH:-}" == "${branch}" ]]; then
exit 1
fi
exit 0
fi

if [[ "${1:-}" == "push" && "${2:-}" == "-u" && "${3:-}" == "origin" ]]; then
branch="${4:-}"
if [[ -n "${STCK_TEST_LOG:-}" ]]; then
Expand Down
48 changes: 48 additions & 0 deletions tests/new.rs
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,54 @@ fn new_rejects_invalid_branch_name() {
));
}

#[test]
fn new_auto_pushes_when_upstream_exists_but_branch_needs_push() {
let (temp, mut cmd) = stck_cmd_with_stubbed_tools();
let log_path = log_path(&temp, "stck-new-auto-push.log");
cmd.env("STCK_TEST_LOG", log_path.as_os_str());
cmd.env("STCK_TEST_HAS_UPSTREAM", "1");
cmd.env("STCK_TEST_NEEDS_PUSH_BRANCH", "feature-branch");
cmd.env("STCK_TEST_NEW_BRANCH_HAS_COMMITS", "1");
cmd.args(["new", "feature-next"]);

cmd.assert()
.success()
.stdout(predicate::str::contains("$ git push origin feature-branch"));

let log = fs::read_to_string(&log_path).expect("new log should exist");
assert!(
log.contains("push origin feature-branch"),
"new should auto-push current branch when it has upstream but needs push"
);
assert!(
!log.contains("push -u origin feature-branch"),
"new should use regular push, not push -u, when upstream already exists"
);
}

#[test]
fn new_skips_push_when_upstream_exists_and_branch_is_up_to_date() {
let (temp, mut cmd) = stck_cmd_with_stubbed_tools();
let log_path = log_path(&temp, "stck-new-no-push.log");
cmd.env("STCK_TEST_LOG", log_path.as_os_str());
cmd.env("STCK_TEST_HAS_UPSTREAM", "1");
cmd.env("STCK_TEST_HAS_CURRENT_PR", "1");
cmd.env("STCK_TEST_NEW_BRANCH_HAS_COMMITS", "1");
cmd.args(["new", "feature-next"]);

cmd.assert().success();

let log = fs::read_to_string(&log_path).expect("new log should exist");
assert!(
!log.contains("push origin feature-branch"),
"new should not push when branch is already up to date with remote"
);
assert!(
!log.contains("push -u origin feature-branch"),
"new should not push -u when upstream already exists"
);
}

#[test]
fn new_fails_when_pr_presence_check_errors() {
let (temp, mut cmd) = stck_cmd_with_stubbed_tools();
Expand Down
24 changes: 24 additions & 0 deletions tests/submit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,30 @@ fn submit_noops_when_pr_exists() {
));
}

#[test]
fn submit_auto_pushes_when_upstream_exists_but_branch_needs_push() {
let (temp, mut cmd) = stck_cmd_with_stubbed_tools();
let log_path = log_path(&temp, "stck-submit-auto-push.log");
cmd.env("STCK_TEST_LOG", log_path.as_os_str());
cmd.env("STCK_TEST_HAS_UPSTREAM", "1");
cmd.env("STCK_TEST_NEEDS_PUSH_BRANCH", "feature-branch");
cmd.args(["submit", "--base", "main"]);

cmd.assert()
.success()
.stdout(predicate::str::contains("$ git push origin feature-branch"));

let log = fs::read_to_string(&log_path).expect("submit log should exist");
assert!(
log.contains("push origin feature-branch"),
"submit should auto-push current branch when it has upstream but needs push"
);
assert!(
!log.contains("push -u origin feature-branch"),
"submit should use regular push, not push -u, when upstream already exists"
);
}

#[test]
fn submit_rejects_default_branch() {
let (_temp, mut cmd) = stck_cmd_with_stubbed_tools();
Expand Down
Loading
Loading