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
93 changes: 88 additions & 5 deletions apps/staged/src-tauri/src/branches.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,8 @@ pub(crate) fn to_branch_with_workdir(
pr_head_sha: branch.pr_head_sha,
pr_title: branch.pr_title,
setup_complete: branch.setup_complete,
deferred_push: branch.deferred_push.map(|d| d.as_column_name()),
deferred_push_requested_at: branch.deferred_push.map(|d| d.requested_at),
worktree_path: workdir_path,
created_at: branch.created_at,
updated_at: branch.updated_at,
Expand Down Expand Up @@ -4932,7 +4934,11 @@ mod upstream_tracking_tests {
git::upstream_branch_name(Path::new(&path), "feature-x", "main"),
"feature-x"
);
let steps = crate::prs::build_push_pipeline_steps("feature-x", "feature-x", false);
let steps = crate::prs::build_push_pipeline_steps(
"feature-x",
"feature-x",
crate::prs::PushVariant::default(),
);
let store::PipelineStep::Command { command, .. } = &steps[0] else {
panic!("expected a push command step");
};
Expand Down Expand Up @@ -4963,8 +4969,11 @@ mod upstream_tracking_tests {

let stored = store.get_branch(&branch.id).unwrap().unwrap();
let remote_branch = resolve_remote_branch_sync(&store, &stored);
let steps =
crate::prs::build_push_pipeline_steps(&stored.branch_name, &remote_branch, false);
let steps = crate::prs::build_push_pipeline_steps(
&stored.branch_name,
&remote_branch,
crate::prs::PushVariant::default(),
);
let store::PipelineStep::Command { command, .. } = &steps[0] else {
panic!("expected a push command step");
};
Expand Down Expand Up @@ -5009,8 +5018,11 @@ mod upstream_tracking_tests {

let stored = store.get_branch(&branch.id).unwrap().unwrap();
let remote_branch = resolve_remote_branch_sync(&store, &stored);
let steps =
crate::prs::build_push_pipeline_steps(&stored.branch_name, &remote_branch, false);
let steps = crate::prs::build_push_pipeline_steps(
&stored.branch_name,
&remote_branch,
crate::prs::PushVariant::default(),
);
let store::PipelineStep::Command { command, .. } = &steps[0] else {
panic!("expected a push command step");
};
Expand Down Expand Up @@ -5054,6 +5066,77 @@ mod upstream_tracking_tests {
assert!(git::cli_run(clone.path(), &["fetch", "origin"]).is_ok());
}

/// "Push without verifying" against a real `pre-push` hook that always
/// fails and counts its runs. The verifying push fails and runs the hook
/// twice, because the HTTPS fallback retries on any failure; the no-verify
/// push lands without running it at all.
#[test]
fn no_verify_push_skips_a_failing_pre_push_hook() {
use std::os::unix::fs::PermissionsExt;

let (origin, clone, _) = clone_of_origin(false);
let path = clone.path();
git(path, &["checkout", "-b", "feature-x", "origin/feature-x"]);
std::fs::write(path.join("file.txt"), "base\nfeature\nmore\n").unwrap();
git(path, &["commit", "-am", "more"]);
let head = git(path, &["rev-parse", "HEAD"]);
let origin_before = git(origin.path(), &["rev-parse", "refs/heads/feature-x"]);

// A repo-local hooksPath, so neither the test helpers' per-command
// `core.hooksPath=/dev/null` nor a global hooksPath hides the hook.
let hooks = TempDir::new("hooks");
let runs = hooks.path().join("runs");
let hook = hooks.path().join("pre-push");
std::fs::write(
&hook,
format!("#!/bin/sh\necho run >> '{}'\nexit 1\n", runs.display()),
)
.unwrap();
std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap();
git(
path,
&["config", "core.hooksPath", hooks.path().to_str().unwrap()],
);
let hook_runs = || {
std::fs::read_to_string(&runs)
.map(|runs| runs.lines().count())
.unwrap_or(0)
};
let run_push = |variant: crate::prs::PushVariant| {
let steps = crate::prs::build_push_pipeline_steps("feature-x", "feature-x", variant);
let store::PipelineStep::Command { command, .. } = &steps[0] else {
panic!("expected a push command step");
};
let mut push = std::process::Command::new("sh");
push.arg("-c").arg(command).current_dir(path);
git::strip_git_env(&mut push);
push.output().unwrap()
};

let verifying = run_push(crate::prs::PushVariant::default());
assert!(!verifying.status.success());
assert_eq!(hook_runs(), 2);
assert_eq!(
git(origin.path(), &["rev-parse", "refs/heads/feature-x"]),
origin_before
);

let no_verify = run_push(crate::prs::PushVariant {
force: false,
no_verify: true,
});
assert!(
no_verify.status.success(),
"{}",
String::from_utf8_lossy(&no_verify.stderr)
);
assert_eq!(hook_runs(), 2);
assert_eq!(
git(origin.path(), &["rev-parse", "refs/heads/feature-x"]),
head
);
}

/// A local branch the user already had, with no worktree, is only given a
/// worktree here: an upstream they configured — on another remote, say —
/// is theirs, even when origin has a branch of the same name. A branch
Expand Down
99 changes: 99 additions & 0 deletions apps/staged/src-tauri/src/launch_resume/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,10 @@ pub struct LaunchResumeState {
offer: Mutex<Option<LaunchRecovery>>,
offered_through: Mutex<Option<i64>>,
last_run: Mutex<Option<LaunchResumeSummary>>,
/// Branches with a deferred push the startup pass skipped because the
/// offer names a session of theirs or of their project; kicked when the
/// offer is answered (see [`kick_deferred_pushes_at_launch`]).
deferred_push_holds: Mutex<Vec<String>>,
}

impl LaunchResumeState {
Expand All @@ -128,6 +132,17 @@ impl LaunchResumeState {
*self.offer.lock().unwrap() = None;
self.offered_through.lock().unwrap().take()
}

/// The branches whose deferred push waits on the offer, taken once.
pub(crate) fn take_deferred_push_holds(&self) -> Vec<String> {
std::mem::take(&mut *self.deferred_push_holds.lock().unwrap())
}

/// The branches whose deferred push is waiting on the offer.
#[cfg(test)]
pub(crate) fn deferred_push_holds(&self) -> Vec<String> {
self.deferred_push_holds.lock().unwrap().clone()
}
}

// =============================================================================
Expand Down Expand Up @@ -244,6 +259,71 @@ pub(crate) fn detect_at_launch<R: tauri::Runtime>(app_handle: &tauri::AppHandle<
}
}

/// Kick the branches left with a deferred push ("Push after current work")
/// armed by the last run, except those the offer holds.
///
/// A branch whose own session, or whose project's session, the last quit
/// interrupted is not kicked yet: the push would fire before the user
/// resumes, and the resumed session's commits would go unpushed. Those
/// branches are parked in the state and kicked when the offer is answered —
/// after the resumed rows are back in place, so the drain's idle check sees
/// them (`resume_interrupted_sessions`). Runs right after [`detect_at_launch`].
pub(crate) fn kick_deferred_pushes_at_launch<R: tauri::Runtime>(
app_handle: &tauri::AppHandle<R>,
store: &Store,
) {
let armed = match store.branch_ids_with_deferred_push(None) {
Ok(ids) => ids,
Err(e) => {
log::warn!("[launch_resume] could not list branches with a deferred push: {e}");
return;
}
};
if armed.is_empty() {
return;
}
let state = app_handle.state::<LaunchResumeState>();
let offer = state.offer();
let mut held = Vec::new();
for branch_id in armed {
if offer
.as_ref()
.is_some_and(|offer| offer_holds_branch(offer, store, &branch_id))
{
log::info!(
"[launch_resume] deferred push on branch {branch_id} waits for the launch offer"
);
held.push(branch_id);
} else {
crate::session_queue::kick(
app_handle,
&branch_id,
crate::session_queue::KickSource::Startup,
);
}
}
*state.deferred_push_holds.lock().unwrap() = held;
}

/// Whether the offer names a session on `branch_id`, or a project session of
/// its project.
fn offer_holds_branch(offer: &LaunchRecovery, store: &Store, branch_id: &str) -> bool {
if offer
.repo_sessions
.iter()
.any(|s| s.branch_id.as_deref() == Some(branch_id))
{
return true;
}
let Ok(Some(branch)) = store.get_branch(branch_id) else {
return false;
};
offer
.project_sessions
.iter()
.any(|s| s.project_id.as_deref() == Some(branch.project_id.as_str()))
}

// =============================================================================
// Commands
// =============================================================================
Expand Down Expand Up @@ -280,7 +360,12 @@ pub fn resume_interrupted_sessions(
log::warn!("[launch_resume] could not record the handled marker: {e}");
}
}
// The deferred pushes the startup pass held for this offer. On a
// dismissal nothing comes back, so they go now; otherwise after the run,
// once the resumed rows are in place for the drain's idle check.
let held_deferred_pushes = state.take_deferred_push_holds();
if session_ids.is_empty() {
kick_held_deferred_pushes(&app_handle, held_deferred_pushes);
return Ok(());
}
let deps = run::ResumeDeps {
Expand All @@ -292,9 +377,23 @@ pub fn resume_interrupted_sessions(
};
tauri::async_runtime::spawn(async move {
let summary = run::run_launch_resume(deps, session_ids).await;
kick_held_deferred_pushes(&app_handle, held_deferred_pushes);
if let Some(state) = app_handle.try_state::<LaunchResumeState>() {
*state.last_run.lock().unwrap() = Some(summary);
}
});
Ok(())
}

fn kick_held_deferred_pushes<R: tauri::Runtime>(
app_handle: &tauri::AppHandle<R>,
branch_ids: Vec<String>,
) {
for branch_id in branch_ids {
crate::session_queue::kick(
app_handle,
&branch_id,
crate::session_queue::KickSource::LaunchResume,
);
}
}
101 changes: 101 additions & 0 deletions apps/staged/src-tauri/src/launch_resume/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,107 @@ fn state_holds_the_offer_until_it_is_answered() {
assert!(state.take_handled().is_none());
}

fn arm_deferred_push(store: &Store, branch_id: &str) {
store
.set_branch_deferred_push(
branch_id,
Some(store::DeferredPush {
force: false,
no_verify: false,
requested_at: 1,
}),
)
.unwrap();
}

/// The startup pass over armed deferred pushes skips a branch the offer may
/// put work back on — one with an interrupted session of its own, or in a
/// project with an interrupted project session — and kicks the rest. The
/// held branches are kicked when the offer is answered, whichever way.
#[test]
fn launch_holds_deferred_pushes_the_offer_names_and_kicks_the_rest() {
let (store, project, branch) = store_with_branch();
let sibling = store::Branch::new(&project.id, "sibling", "main");
store.create_branch(&sibling).unwrap();
let other_project = store::Project::new("other/repo");
store.create_project(&other_project).unwrap();
let elsewhere = store::Branch::new(&other_project.id, "elsewhere", "main");
store.create_branch(&elsewhere).unwrap();
for id in [&branch.id, &sibling.id, &elsewhere.id] {
arm_deferred_push(&store, id);
}
interrupted_commit(&store, &branch.id, 10, Some("acp-1"));

let kicker = Arc::new(crate::session_queue::BranchQueueKicker::recording());
let app = mock_app();
app.manage(Arc::clone(&kicker));
app.manage(LaunchResumeState::default());
super::detect_at_launch(app.handle(), &store);
super::kick_deferred_pushes_at_launch(app.handle(), &store);

// Only the interrupted commit's own branch waits; its sibling is free.
let mut kicked = kicker.kicked_branches();
kicked.sort();
let mut expected = vec![sibling.id.clone(), elsewhere.id.clone()];
expected.sort();
assert_eq!(kicked, expected);
let state = app.state::<LaunchResumeState>();
assert_eq!(state.deferred_push_holds(), vec![branch.id.clone()]);
assert_eq!(state.take_deferred_push_holds(), vec![branch.id.clone()]);
assert!(state.take_deferred_push_holds().is_empty());

// An interrupted project session holds every armed branch of its project.
let (store, project, branch) = store_with_branch();
let sibling = store::Branch::new(&project.id, "sibling", "main");
store.create_branch(&sibling).unwrap();
let other_project = store::Project::new("other/repo");
store.create_project(&other_project).unwrap();
let elsewhere = store::Branch::new(&other_project.id, "elsewhere", "main");
store.create_branch(&elsewhere).unwrap();
for id in [&branch.id, &sibling.id, &elsewhere.id] {
arm_deferred_push(&store, id);
}
interrupted_project_session(&store, &project.id, 10, Some("acp-2"));

let kicker = Arc::new(crate::session_queue::BranchQueueKicker::recording());
let app = mock_app();
app.manage(Arc::clone(&kicker));
app.manage(LaunchResumeState::default());
super::detect_at_launch(app.handle(), &store);
super::kick_deferred_pushes_at_launch(app.handle(), &store);

assert_eq!(kicker.kicked_branches(), vec![elsewhere.id.clone()]);
let mut held = app.state::<LaunchResumeState>().deferred_push_holds();
held.sort();
let mut expected = vec![branch.id.clone(), sibling.id.clone()];
expected.sort();
assert_eq!(held, expected);
}

/// With nothing interrupted there is no offer to wait on: every armed branch
/// is kicked at startup and nothing is held.
#[test]
fn launch_kicks_every_deferred_push_when_nothing_was_interrupted() {
let (store, _project, branch) = store_with_branch();
arm_deferred_push(&store, &branch.id);

let kicker = Arc::new(crate::session_queue::BranchQueueKicker::recording());
let app = mock_app();
app.manage(Arc::clone(&kicker));
app.manage(LaunchResumeState::default());
super::detect_at_launch(app.handle(), &store);
super::kick_deferred_pushes_at_launch(app.handle(), &store);

assert_eq!(
kicker.kicked(),
vec![(branch.id.clone(), crate::session_queue::KickSource::Startup)]
);
assert!(app
.state::<LaunchResumeState>()
.deferred_push_holds()
.is_empty());
}

/// A launch with nothing interrupted offers nothing and has no marker.
#[test]
fn state_is_empty_when_nothing_was_interrupted() {
Expand Down
Loading
Loading