From a3eef30849bdd32d0da5f515d1d5a035e8efc8c9 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 26 Mar 2026 15:24:26 +1100 Subject: [PATCH 1/4] perf(staged): pre-compute git context before create-PR agent session Run `git log`, `git diff --stat`, and `git diff` in parallel on the Rust side before the agent session starts, then inject the results into the prompt. This lets the agent skip straight to pushing and creating the PR instead of spending time on deterministic git commands. Falls back to the original prompt (agent runs git itself) if any pre-computation command fails. Large diffs are truncated at 50k chars. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/staged/src-tauri/src/git/mod.rs | 2 +- apps/staged/src-tauri/src/prs.rs | 148 +++++++++++++++++++++++++-- 2 files changed, 142 insertions(+), 8 deletions(-) diff --git a/apps/staged/src-tauri/src/git/mod.rs b/apps/staged/src-tauri/src/git/mod.rs index a9f65f18..aab29e86 100644 --- a/apps/staged/src-tauri/src/git/mod.rs +++ b/apps/staged/src-tauri/src/git/mod.rs @@ -1,4 +1,4 @@ -mod cli; +pub(crate) mod cli; mod commit; mod diff; mod files; diff --git a/apps/staged/src-tauri/src/prs.rs b/apps/staged/src-tauri/src/prs.rs index c8837b5d..0d5f31cb 100644 --- a/apps/staged/src-tauri/src/prs.rs +++ b/apps/staged/src-tauri/src/prs.rs @@ -44,6 +44,90 @@ struct PrStatusEvent { pr_head_sha: Option, } +/// Maximum character length for the full diff output before truncation. +const DIFF_TRUNCATION_LIMIT: usize = 50_000; + +struct GitContext { + log: String, + stat: String, + diff: String, +} + +/// Run the three deterministic git analysis commands in parallel and return +/// their output. Returns `None` if any command fails so the caller can fall +/// back to letting the agent run them itself. +fn pre_compute_git_context( + is_remote: bool, + working_dir: &Path, + workspace_name: Option<&str>, + store: &Arc, + branch: &store::Branch, + base_branch: &str, +) -> Option { + let log_range = format!("origin/{}..HEAD", base_branch); + let diff_range = format!("origin/{}...HEAD", base_branch); + + if is_remote { + let ws_name = workspace_name?; + let repo_subpath = crate::branches::resolve_branch_workspace_subpath(store, branch) + .ok() + .flatten(); + let sp = repo_subpath.as_deref(); + + // For remote branches, run_workspace_git goes over SSH and cannot + // benefit from std::thread::scope parallelism (the SSH transport + // serialises anyway), so run them sequentially. + let log_output = + crate::branches::run_workspace_git(ws_name, sp, &["log", "--oneline", &log_range]) + .ok()?; + let stat_output = + crate::branches::run_workspace_git(ws_name, sp, &["diff", &diff_range, "--stat"]) + .ok()?; + let diff_output = + crate::branches::run_workspace_git(ws_name, sp, &["diff", &diff_range]).ok()?; + + Some(GitContext { + log: log_output, + stat: stat_output, + diff: truncate_diff(diff_output), + }) + } else { + let (log_result, stat_result, diff_result) = std::thread::scope(|s| { + let log = s.spawn(|| git::cli::run(working_dir, &["log", "--oneline", &log_range])); + let stat = s.spawn(|| git::cli::run(working_dir, &["diff", &diff_range, "--stat"])); + let diff = s.spawn(|| git::cli::run(working_dir, &["diff", &diff_range])); + ( + log.join().unwrap(), + stat.join().unwrap(), + diff.join().unwrap(), + ) + }); + + Some(GitContext { + log: log_result.ok()?, + stat: stat_result.ok()?, + diff: truncate_diff(diff_result.ok()?), + }) + } +} + +/// Truncate a diff to `DIFF_TRUNCATION_LIMIT` characters, appending a note +/// about the omitted content. +fn truncate_diff(diff: String) -> String { + if diff.len() <= DIFF_TRUNCATION_LIMIT { + return diff; + } + let truncated = &diff[..DIFF_TRUNCATION_LIMIT]; + // Try to cut at a newline boundary for cleaner output. + let cut = truncated.rfind('\n').unwrap_or(DIFF_TRUNCATION_LIMIT); + let remaining_lines = diff[cut..].lines().count(); + format!( + "{}\n\n(truncated, ~{} more lines — run the command yourself to see the full diff)", + &diff[..cut], + remaining_lines, + ) +} + /// Create a pull request for a branch by kicking off an agent session. #[tauri::command(rename_all = "camelCase")] pub fn create_pr( @@ -105,8 +189,57 @@ pub fn create_pr( "pull request" }; - let prompt = format!( - r#" + // Pre-compute git context in parallel so the agent can skip straight to + // pushing and creating the PR instead of running these deterministic + // commands itself. + let git_context = pre_compute_git_context( + is_remote, + &working_dir, + workspace_name.as_deref(), + &store, + &branch, + base_branch, + ); + + let prompt = if let Some(ctx) = git_context { + format!( + r#" +Create a {pr_type} for the current branch. + +The initial analysis has already been done: + +$ git log --oneline origin/{base_branch}..HEAD +{log_output} + +$ git diff origin/{base_branch}...HEAD --stat +{stat_output} + +$ git diff origin/{base_branch}...HEAD +{diff_output} + +Steps: +1. Push the current branch to the remote: `git push -u origin {branch_name}` +2. Create a PR using the GitHub CLI: `gh pr create --base {base_branch} --fill-first{draft_flag}` + - Title MUST use conventional commit style (e.g., "feat: add user authentication", "fix: resolve null pointer in parser", "refactor: extract validation logic") + - Choose the most appropriate conventional commit type (feat, fix, refactor, docs, style, test, chore, perf, ci, build) based on the actual changes + - The body should be a concise summary of the changes + +IMPORTANT: After creating the PR, you MUST output the PR URL on its own line in this exact format: +PR_URL: https://github.com/... + +This is critical - the application parses this to link the PR. +"#, + pr_type = pr_type, + base_branch = base_branch, + branch_name = branch.branch_name, + draft_flag = draft_flag, + log_output = ctx.log, + stat_output = ctx.stat, + diff_output = ctx.diff, + ) + } else { + format!( + r#" Create a {pr_type} for the current branch. Steps: @@ -122,11 +255,12 @@ PR_URL: https://github.com/... This is critical - the application parses this to link the PR. "#, - pr_type = pr_type, - base_branch = base_branch, - branch_name = branch.branch_name, - draft_flag = draft_flag, - ); + pr_type = pr_type, + base_branch = base_branch, + branch_name = branch.branch_name, + draft_flag = draft_flag, + ) + }; let mut session = store::Session::new_running(&prompt, &working_dir); if let Some(ref p) = provider { From 297508fe1de7a83560066cdcfa1df4bf492d3edd Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 26 Mar 2026 15:29:59 +1100 Subject: [PATCH 2/4] fix(staged): fix user message rendering when diff contains XML tags The `parseContentSegments` regex used non-greedy matching (`*?`), causing it to match nested `` tags inside pre-computed diffs (e.g. diffs of prs.rs itself) instead of the outermost wrapper. Switch to greedy matching so the first opening tag pairs with the last matching closing tag. Also fix a potential panic in `truncate_diff` when slicing at a byte index that falls mid-character in multi-byte UTF-8 by using `floor_char_boundary`. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/staged/src-tauri/src/prs.rs | 9 ++++++--- .../staged/src/lib/features/sessions/SessionModal.svelte | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/staged/src-tauri/src/prs.rs b/apps/staged/src-tauri/src/prs.rs index 0d5f31cb..aae84cf4 100644 --- a/apps/staged/src-tauri/src/prs.rs +++ b/apps/staged/src-tauri/src/prs.rs @@ -111,15 +111,18 @@ fn pre_compute_git_context( } } -/// Truncate a diff to `DIFF_TRUNCATION_LIMIT` characters, appending a note +/// Truncate a diff to `DIFF_TRUNCATION_LIMIT` bytes, appending a note /// about the omitted content. fn truncate_diff(diff: String) -> String { if diff.len() <= DIFF_TRUNCATION_LIMIT { return diff; } - let truncated = &diff[..DIFF_TRUNCATION_LIMIT]; + // Find the nearest char boundary at or before the limit to avoid + // panicking on multi-byte UTF-8 sequences. + let safe_limit = diff.floor_char_boundary(DIFF_TRUNCATION_LIMIT); + let truncated = &diff[..safe_limit]; // Try to cut at a newline boundary for cleaner output. - let cut = truncated.rfind('\n').unwrap_or(DIFF_TRUNCATION_LIMIT); + let cut = truncated.rfind('\n').unwrap_or(safe_limit); let remaining_lines = diff[cut..].lines().count(); format!( "{}\n\n(truncated, ~{} more lines — run the command yourself to see the full diff)", diff --git a/apps/staged/src/lib/features/sessions/SessionModal.svelte b/apps/staged/src/lib/features/sessions/SessionModal.svelte index 077dbb46..eb117949 100644 --- a/apps/staged/src/lib/features/sessions/SessionModal.svelte +++ b/apps/staged/src/lib/features/sessions/SessionModal.svelte @@ -593,7 +593,7 @@ const segments: ContentSegment[] = []; let remaining = content; - const tagPattern = /<(action|branch-history)>([\s\S]*?)<\/\1>/g; + const tagPattern = /<(action|branch-history)>([\s\S]*)<\/\1>/g; let lastIndex = 0; let match: RegExpExecArray | null; From c6a3db0df444082ea11237061bf61930120eb2d9 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 26 Mar 2026 15:37:24 +1100 Subject: [PATCH 3/4] perf(staged): drop full diff from pre-computed create-PR context Remove the `git diff origin/{base}...HEAD` pre-computation and the `truncate_diff` helper. The commit log and diff stat are sufficient for the agent to write a good PR title and body, and skipping the full diff avoids large payloads and speeds up the pre-computation. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/staged/src-tauri/src/prs.rs | 41 ++------------------------------ 1 file changed, 2 insertions(+), 39 deletions(-) diff --git a/apps/staged/src-tauri/src/prs.rs b/apps/staged/src-tauri/src/prs.rs index aae84cf4..fada59b1 100644 --- a/apps/staged/src-tauri/src/prs.rs +++ b/apps/staged/src-tauri/src/prs.rs @@ -44,13 +44,9 @@ struct PrStatusEvent { pr_head_sha: Option, } -/// Maximum character length for the full diff output before truncation. -const DIFF_TRUNCATION_LIMIT: usize = 50_000; - struct GitContext { log: String, stat: String, - diff: String, } /// Run the three deterministic git analysis commands in parallel and return @@ -83,54 +79,25 @@ fn pre_compute_git_context( let stat_output = crate::branches::run_workspace_git(ws_name, sp, &["diff", &diff_range, "--stat"]) .ok()?; - let diff_output = - crate::branches::run_workspace_git(ws_name, sp, &["diff", &diff_range]).ok()?; Some(GitContext { log: log_output, stat: stat_output, - diff: truncate_diff(diff_output), }) } else { - let (log_result, stat_result, diff_result) = std::thread::scope(|s| { + let (log_result, stat_result) = std::thread::scope(|s| { let log = s.spawn(|| git::cli::run(working_dir, &["log", "--oneline", &log_range])); let stat = s.spawn(|| git::cli::run(working_dir, &["diff", &diff_range, "--stat"])); - let diff = s.spawn(|| git::cli::run(working_dir, &["diff", &diff_range])); - ( - log.join().unwrap(), - stat.join().unwrap(), - diff.join().unwrap(), - ) + (log.join().unwrap(), stat.join().unwrap()) }); Some(GitContext { log: log_result.ok()?, stat: stat_result.ok()?, - diff: truncate_diff(diff_result.ok()?), }) } } -/// Truncate a diff to `DIFF_TRUNCATION_LIMIT` bytes, appending a note -/// about the omitted content. -fn truncate_diff(diff: String) -> String { - if diff.len() <= DIFF_TRUNCATION_LIMIT { - return diff; - } - // Find the nearest char boundary at or before the limit to avoid - // panicking on multi-byte UTF-8 sequences. - let safe_limit = diff.floor_char_boundary(DIFF_TRUNCATION_LIMIT); - let truncated = &diff[..safe_limit]; - // Try to cut at a newline boundary for cleaner output. - let cut = truncated.rfind('\n').unwrap_or(safe_limit); - let remaining_lines = diff[cut..].lines().count(); - format!( - "{}\n\n(truncated, ~{} more lines — run the command yourself to see the full diff)", - &diff[..cut], - remaining_lines, - ) -} - /// Create a pull request for a branch by kicking off an agent session. #[tauri::command(rename_all = "camelCase")] pub fn create_pr( @@ -217,9 +184,6 @@ $ git log --oneline origin/{base_branch}..HEAD $ git diff origin/{base_branch}...HEAD --stat {stat_output} -$ git diff origin/{base_branch}...HEAD -{diff_output} - Steps: 1. Push the current branch to the remote: `git push -u origin {branch_name}` 2. Create a PR using the GitHub CLI: `gh pr create --base {base_branch} --fill-first{draft_flag}` @@ -238,7 +202,6 @@ This is critical - the application parses this to link the PR. draft_flag = draft_flag, log_output = ctx.log, stat_output = ctx.stat, - diff_output = ctx.diff, ) } else { format!( From 05e28286559075d49eb67b6b764e1242dec76119 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 26 Mar 2026 15:57:28 +1100 Subject: [PATCH 4/4] fix(staged): resolve code review comments on create-PR pre-computation Fix doc comment saying "three" commands when only two are run. Propagate errors from `resolve_branch_workspace_subpath` to trigger the fallback path instead of silently running git in the repo root. Switch header title regex from non-greedy to greedy matching to stay consistent with the `parseContentSegments` fix for nested XML tags. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/staged/src-tauri/src/prs.rs | 9 +++++---- .../staged/src/lib/features/sessions/SessionModal.svelte | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/staged/src-tauri/src/prs.rs b/apps/staged/src-tauri/src/prs.rs index fada59b1..c1588025 100644 --- a/apps/staged/src-tauri/src/prs.rs +++ b/apps/staged/src-tauri/src/prs.rs @@ -49,7 +49,7 @@ struct GitContext { stat: String, } -/// Run the three deterministic git analysis commands in parallel and return +/// Run the two deterministic git analysis commands in parallel and return /// their output. Returns `None` if any command fails so the caller can fall /// back to letting the agent run them itself. fn pre_compute_git_context( @@ -65,9 +65,10 @@ fn pre_compute_git_context( if is_remote { let ws_name = workspace_name?; - let repo_subpath = crate::branches::resolve_branch_workspace_subpath(store, branch) - .ok() - .flatten(); + let repo_subpath = match crate::branches::resolve_branch_workspace_subpath(store, branch) { + Ok(sp) => sp, + Err(_) => return None, + }; let sp = repo_subpath.as_deref(); // For remote branches, run_workspace_git goes over SSH and cannot diff --git a/apps/staged/src/lib/features/sessions/SessionModal.svelte b/apps/staged/src/lib/features/sessions/SessionModal.svelte index eb117949..1fce9dc6 100644 --- a/apps/staged/src/lib/features/sessions/SessionModal.svelte +++ b/apps/staged/src/lib/features/sessions/SessionModal.svelte @@ -840,7 +840,7 @@
{session?.prompt - ? session.prompt.replace(/<(action|branch-history)>[\s\S]*?<\/\1>/g, '').trim() || + ? session.prompt.replace(/<(action|branch-history)>[\s\S]*<\/\1>/g, '').trim() || 'Session' : 'Session'}