Skip to content
Open
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
1 change: 1 addition & 0 deletions apps/staged/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2478,6 +2478,7 @@ pub fn run() {
prs::commands::pull_or_queue_branch,
prs::commands::rebase_branch,
prs::commands::squash_commits,
prs::commands::revert_commit,
prs::commands::clear_branch_pr_status,
pr_detection::recover_branch_pr,
pr_detection::detect_branch_pr,
Expand Down
22 changes: 22 additions & 0 deletions apps/staged/src-tauri/src/prs/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ use super::pull::pull_or_queue_branch_for_branch;
use super::push::{
has_unpushed_commits_impl, start_or_queue_push_pipeline_for_branch, PushVariant,
};
use super::revert::revert_commit_for_branch;

fn get_store(store: &tauri::State<'_, Mutex<Option<Arc<Store>>>>) -> Result<Arc<Store>, String> {
store
Expand Down Expand Up @@ -297,3 +298,24 @@ pub async fn squash_commits(
)
.await
}

/// Revert a completed commit by creating a new signed-off revert commit.
#[tauri::command(rename_all = "camelCase")]
pub async fn revert_commit(
store: tauri::State<'_, Mutex<Option<Arc<Store>>>>,
registry: tauri::State<'_, Arc<session_runner::SessionRegistry>>,
app_handle: tauri::AppHandle,
branch_id: String,
commit_sha: String,
provider: Option<String>,
) -> Result<String, String> {
let store = get_store(&store)?;
revert_commit_for_branch(
store,
Arc::clone(&registry),
app_handle,
branch_id,
commit_sha,
provider,
)
}
27 changes: 26 additions & 1 deletion apps/staged/src-tauri/src/prs/commit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -191,9 +191,32 @@ pub(super) fn insert_running_commit_pipeline_session(
if let Some(target) = rebase_target {
pipeline = pipeline.with_rebase_target(target);
}
insert_running_pending_commit_session(
store,
registry,
ctx,
pipeline,
prompt.to_string(),
provider,
false,
)
}

/// The rows every commit-producing run-now pipeline inserts: a `running`
/// session on the branch, under a liveness hold taken before the write, and
/// the pending commit it will fill in. Same locking contract as
/// [`insert_running_commit_pipeline_session`].
pub(super) fn insert_running_pending_commit_session(
store: &Arc<Store>,
registry: &Arc<session_runner::SessionRegistry>,
ctx: &BranchPipelineContext,
pipeline: PipelineExecution,
prompt: String,
provider: Option<&str>,
capture_pre_head: bool,
) -> Result<RunningPipelineSession, String> {
let mut session =
store::Session::new_running(prompt, &ctx.working_dir).with_branch(&ctx.branch.id);
store::Session::new_running(&prompt, &ctx.working_dir).with_branch(&ctx.branch.id);
if let Some(p) = provider {
session = session.with_provider(p);
}
Expand All @@ -208,6 +231,7 @@ pub(super) fn insert_running_commit_pipeline_session(
session_id: session.id,
pipeline,
prompt,
capture_pre_head,
_liveness: liveness,
})
}
Expand Down Expand Up @@ -580,6 +604,7 @@ pub(crate) async fn start_queued_commit_pipeline_for_branch(
pipeline,
working_dir: ctx.working_dir,
pre_head_sha: None,
capture_pre_head: false,
provider: effective_provider,
workspace_name: ctx.workspace_name,
remote_working_dir: ctx.remote_working_dir,
Expand Down
3 changes: 2 additions & 1 deletion apps/staged/src-tauri/src/prs/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ pub(crate) struct BranchPipelineContext {

/// Resolve branch, project, and working directory for a pipeline command.
///
/// All pipeline commands (create_pr, push_branch, rebase_branch, squash_commits)
/// All pipeline commands (create_pr, push_branch, rebase_branch, squash_commits,
/// revert_commit)
/// share the same setup: look up the branch and project, resolve the working
/// directory (local worktree vs remote clone), and compute the remote working
/// directory for workspace-based branches.
Expand Down
1 change: 1 addition & 0 deletions apps/staged/src-tauri/src/prs/git_pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ pub(crate) async fn start_queued_git_pipeline_for_branch(
pipeline,
working_dir: ctx.working_dir,
pre_head_sha: None,
capture_pre_head: false,
provider: effective_provider,
workspace_name: ctx.workspace_name,
remote_working_dir: ctx.remote_working_dir,
Expand Down
4 changes: 4 additions & 0 deletions apps/staged/src-tauri/src/prs/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
//! and the force push "Rebase and force push" queues behind its rebase.
//! - [`push`] is the push pipeline and its variants ([`PushVariant`]).
//! - [`pull`] fast-forwards the branch to origin, now or queued.
//! - [`revert`] reverts a completed commit with a new signed-off commit, run
//! now and refused while the branch is busy.
//! - [`git_pipeline`] rebuilds a queued push or pull when it is dequeued.
//! - [`deferred`] is "Push after current work": a push armed on the branch
//! row that fires once the branch and its project are idle.
Expand All @@ -37,6 +39,7 @@ mod pipeline;
mod pr_status;
mod pull;
mod push;
mod revert;
#[cfg(test)]
mod tests;

Expand All @@ -62,3 +65,4 @@ pub(crate) use push::build_push_pipeline_steps;
pub(crate) use push::{
has_unpushed_commits_impl, start_or_queue_push_pipeline_for_branch, PushVariant,
};
pub(crate) use revert::revert_commit_for_branch;
23 changes: 16 additions & 7 deletions apps/staged/src-tauri/src/prs/pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ pub(super) fn start_pipeline_for_branch(
pipeline,
working_dir: ctx.working_dir,
pre_head_sha: None,
capture_pre_head: false,
provider,
workspace_name: ctx.workspace_name,
remote_working_dir: ctx.remote_working_dir,
Expand Down Expand Up @@ -185,6 +186,12 @@ pub(crate) enum BranchWorkInFlight {

/// What the branch is busy with, if anything.
///
/// A queued row that no longer resolves to anything (`BranchSessionLink::Unlinked`:
/// its commit, note or review was deleted out from under it) does not count,
/// the same as for a session start. The drain steps over it, so a row queued
/// behind it alone would wait for a kick that never comes, and a revert, which
/// refuses rather than queues, would stay refused on the branch for good.
///
/// Callers must hold the branch launch lock: the answer is only meaningful for
/// as long as no session can start or be enqueued underneath them.
pub(crate) fn branch_work_in_flight(
Expand All @@ -197,11 +204,7 @@ pub(crate) fn branch_work_in_flight(
{
return Ok(BranchWorkInFlight::Running);
}
if !store
.get_queued_sessions_for_branch(branch_id)
.map_err(|e| e.to_string())?
.is_empty()
{
if crate::session_commands::has_queued_branch_session(store, branch_id)? {
return Ok(BranchWorkInFlight::Queued);
}
Ok(BranchWorkInFlight::None)
Expand All @@ -228,7 +231,11 @@ pub(super) fn branch_has_work_in_flight(
pub(super) struct RunningPipelineSession {
pub(super) session_id: String,
pub(super) pipeline: PipelineExecution,
pub(super) prompt: &'static str,
pub(super) prompt: String,
/// Passed on as [`session_runner::PipelineConfig::capture_pre_head`]: set
/// for a revert, whose pipeline has no kind that would make the runner
/// read HEAD at start the way it does for a rebase.
pub(super) capture_pre_head: bool,
pub(super) _liveness: session_runner::LivenessHold,
}

Expand Down Expand Up @@ -262,16 +269,18 @@ pub(super) fn launch_running_pipeline_session(
session_id,
pipeline,
prompt,
capture_pre_head,
_liveness: liveness,
} = running;
let started = session_runner::start_pipeline_session(
session_runner::PipelineConfig {
session_id: session_id.clone(),
prompt: prompt.to_string(),
prompt,
steps,
pipeline,
working_dir: ctx.working_dir,
pre_head_sha: None,
capture_pre_head,
provider,
workspace_name: ctx.workspace_name,
remote_working_dir: ctx.remote_working_dir,
Expand Down
3 changes: 2 additions & 1 deletion apps/staged/src-tauri/src/prs/push.rs
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,8 @@ pub(super) fn insert_running_push_pipeline_session(
Ok(RunningPipelineSession {
session_id: session.id,
pipeline,
prompt,
prompt: prompt.to_string(),
capture_pre_head: false,
_liveness: liveness,
})
}
Expand Down
156 changes: 156 additions & 0 deletions apps/staged/src-tauri/src/prs/revert.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
//! Revert: a run-now pipeline that creates a signed-off revert commit for a
//! completed commit on the branch.
//!
//! Unlike rebase and squash it never queues: it is refused while the branch
//! has work in flight, and runs `git revert --signoff --no-edit <sha>` with an
//! AI handoff only if git stops on a conflict. A pending-commit stub is linked
//! to the session so completion can record the revert commit's SHA.

use std::sync::Arc;

use crate::session_runner;
use crate::store::{FailureStrategy, PipelineExecution, PipelineStep, Store};

use super::commit::insert_running_pending_commit_session;
use super::context::{resolve_branch_pipeline_context, BranchPipelineContext};
use super::pipeline::{
branch_has_work_in_flight, launch_running_pipeline_session, RunningPipelineSession,
};

pub(super) fn validate_commit_sha(commit_sha: &str) -> Result<String, String> {
let sha = commit_sha.trim();
if (7..=64).contains(&sha.len()) && sha.chars().all(|c| c.is_ascii_hexdigit()) {
Ok(sha.to_string())
} else {
Err("Commit SHA must be 7-64 hexadecimal characters".to_string())
}
}

fn revert_handoff_prompt(commit_sha: &str) -> String {
format!(
r#"<action>
`git revert --signoff --no-edit {commit_sha}` failed.

Inspect the git state and command output, then complete the revert safely.

Requirements:
- Resolve conflicts if present, preserving unrelated local changes.
- If git is in a revert sequence and the resolution is correct, run `git revert --continue`.
- Use the user's global git identity for author and committer.
- Ensure the resulting revert commit is signed off.
- Do not push the branch.
- If the revert should not continue, explain why and leave the repository in a clear state.

Context from prior steps:

{{step_outputs}}
</action>"#
)
}

pub(super) fn build_revert_pipeline_steps(commit_sha: &str) -> Result<Vec<PipelineStep>, String> {
let commit_sha = validate_commit_sha(commit_sha)?;
Ok(vec![PipelineStep::Command {
label: "Revert commit".to_string(),
command: format!("git revert --signoff --no-edit {commit_sha}"),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Apply the user's global identity to the deterministic revert

When a repository has a local Git identity different from the user's global identity, a conflict-free revert is authored, committed, and signed off using that local identity. The deterministic runner does not apply git_identity_env_from_global_config(); that override is only applied during AI handoff. Consequently, the same Revert action uses different identities depending on whether it encounters conflicts, and the successful path bypasses the global-identity requirement explicitly included in revert_handoff_prompt. As confirmed by git revert -h, --signoff only means “add a Signed-off-by trailer”; it does not select the identity. Apply the global author/committer environment to the deterministic command as well.

Useful? React with 👍 / 👎.

on_failure: FailureStrategy::HandoffToAi {
prompt_template: revert_handoff_prompt(&commit_sha),
},
}])
}

pub(super) const REVERT_BRANCH_BUSY: &str =
"Revert is unavailable while this branch has queued or running work";
pub(super) const REVERT_WORKTREE_BUSY: &str =
"Revert is unavailable while this branch is being reset or its changes discarded";

/// Insert the rows for a revert that runs now, or refuse it on a busy branch.
///
/// A revert is never queued: a queued row would have to carry the target SHA
/// for the drain to rebuild its step from. So where a rebase or squash would
/// queue behind work in flight, this errors — under the branch launch lock,
/// with the insert in the same critical section, so two rapid clicks (or a
/// click racing a session start) cannot both observe an idle branch. This
/// stays synchronous on purpose: the lock must not be held across an await.
///
/// A reset-to-origin or discard has no session row for that check to find,
/// so the marker they set is read here as well, the way the forced start reads
/// it: a revert started under one would race its `git reset --hard`, and if it
/// lost on `index.lock` the AI handoff would be asked to finish reverting a
/// commit the reset may have just removed from the branch.
///
/// HEAD is not read here. The runner reads it before the revert step (see
/// [`session_runner::PipelineConfig::capture_pre_head`]), by which point this
/// row has made the branch busy, so nothing that goes through the branch queue
/// can move HEAD in between. Reset-to-origin, discard and delete-commit run
/// outside the queue: the marker above only stops a revert starting under a
/// reset or discard, only the frontend's busy-branch gates keep those two from
/// starting beside a running revert, and nothing gates a delete-commit then.
pub(super) fn insert_running_revert_pipeline_session_if_idle(
store: &Arc<Store>,
registry: &Arc<session_runner::SessionRegistry>,
ctx: &BranchPipelineContext,
steps: &[PipelineStep],
commit_sha: &str,
provider: Option<&str>,
) -> Result<RunningPipelineSession, String> {
let launch_lock = crate::session_commands::branch_session_launch_lock_for(&ctx.branch.id);
let _guard = launch_lock.lock().unwrap();
if crate::session_commands::branch_worktree_operation_in_flight(&ctx.branch.id) {
return Err(REVERT_WORKTREE_BUSY.to_string());
}
if branch_has_work_in_flight(store, &ctx.branch.id)? {
return Err(REVERT_BRANCH_BUSY.to_string());
}

let prompt = format!("Revert commit {}", &commit_sha[..7.min(commit_sha.len())]);
insert_running_pending_commit_session(
store,
registry,
ctx,
PipelineExecution::from_steps(steps),
prompt,
provider,
true,
)
}

/// Revert a completed commit by creating a new signed-off revert commit.
///
/// Runs `git revert --signoff --no-edit <sha>` directly. If git needs conflict
/// resolution, the pipeline hands off to AI with the pending commit row still
/// linked so completion can record the final HEAD.
///
/// Shared by the Tauri command and the web dispatch arm so both gates behave
/// identically.
pub(crate) fn revert_commit_for_branch(
store: Arc<Store>,
registry: Arc<session_runner::SessionRegistry>,
app_handle: tauri::AppHandle,
branch_id: String,
commit_sha: String,
provider: Option<String>,
) -> Result<String, String> {
let commit_sha = validate_commit_sha(&commit_sha)?;
let steps = build_revert_pipeline_steps(&commit_sha)?;
let ctx = resolve_branch_pipeline_context(&store, &branch_id)?;

let running = insert_running_revert_pipeline_session_if_idle(
&store,
&registry,
&ctx,
&steps,
&commit_sha,
provider.as_deref(),
)?;
launch_running_pipeline_session(
ctx,
running,
steps,
"commit",
provider,
store,
&app_handle,
&registry,
)
}
9 changes: 7 additions & 2 deletions apps/staged/src-tauri/src/prs/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ use super::deferred::{
};
use super::git_pipeline::rebuild_queued_git_pipeline;
use super::pipeline::{
pipeline_prompt, BranchPipelineResponse, FORCE_PUSH_PROMPT, NO_VERIFY_FORCE_PUSH_PROMPT,
NO_VERIFY_PUSH_PROMPT, PULL_PROMPT, PUSH_PROMPT,
branch_has_work_in_flight, pipeline_prompt, BranchPipelineResponse, FORCE_PUSH_PROMPT,
NO_VERIFY_FORCE_PUSH_PROMPT, NO_VERIFY_PUSH_PROMPT, PULL_PROMPT, PUSH_PROMPT,
};
use super::pr_status::{collect_branch_refresh_results, BranchRefresh};
use super::pull::{
Expand All @@ -29,6 +29,10 @@ use super::push::{
build_push_pipeline_steps, insert_running_push_pipeline_session,
queue_push_pipeline_if_branch_busy, queue_push_pipeline_locked, PushVariant,
};
use super::revert::{
build_revert_pipeline_steps, insert_running_revert_pipeline_session_if_idle,
REVERT_BRANCH_BUSY, REVERT_WORKTREE_BUSY,
};
use crate::git;
use crate::session_commands::BranchSessionLaunchStatus;
use crate::session_runner;
Expand All @@ -43,6 +47,7 @@ mod git_pipeline;
mod pr_status;
mod pull;
mod push;
mod revert;

fn command_at(steps: &[PipelineStep], index: usize) -> (&str, &str, &FailureStrategy) {
match &steps[index] {
Expand Down
Loading
Loading