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
2 changes: 1 addition & 1 deletion apps/staged/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ dirs = "6.0"
tauri-plugin-clipboard-manager = "2.3.2"
tauri-plugin-window-state = "2.4.1"
reqwest = { version = "0.13.1", features = ["json"] }
tokio = { version = "1.50.0", features = ["sync", "process", "io-util", "macros", "rt-multi-thread", "time"] }
tokio = { version = "1.50.0", features = ["sync", "process", "io-util", "macros", "rt-multi-thread", "time", "fs"] }
tauri-plugin-opener = "2"
tauri-plugin-dialog = "2"
tauri-plugin-process = "2"
Expand Down
33 changes: 32 additions & 1 deletion apps/staged/src-tauri/src/git/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ pub fn run(repo: &Path, args: &[&str]) -> Result<String, GitError> {

let mut command = Command::new("git");
command.args(["-C", repo_str]).args(args);
strip_git_env(&mut command);
apply_shell_env(&mut command, repo);

let output = command.output().map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
Expand All @@ -50,3 +50,34 @@ pub fn run(repo: &Path, args: &[&str]) -> Result<String, GitError> {

String::from_utf8(output.stdout).map_err(|_| GitError::InvalidUtf8)
}

/// Replace the spawned git's environment with the project's cached
/// interactive-login-shell snapshot so Hermit-managed `git`, LFS filters,
/// credential helpers, and any binaries invoked by git hooks see the same
/// PATH/env that a user's terminal sees.
///
/// On capture failure (e.g. `$SHELL` unset, init script exits non-zero),
/// falls back to the parent process env with `GIT_*` variables stripped —
/// matching the pre-cache behaviour.
///
/// Gated behind `cfg(not(test))` because unit tests run in fresh tempdirs
/// where shell init has no project context and the per-test spawn would
/// add ~hundreds of ms of overhead with no test value.
#[cfg(not(test))]
fn apply_shell_env(command: &mut Command, repo: &Path) {
match crate::session_runner::shell_env_cache().get_blocking(repo) {
Ok(env) => env.apply_to_std(command),
Err(e) => {
log::warn!(
"Failed to capture shell env for {}: {e}; falling back to inherited env",
repo.display()
);
strip_git_env(command);
}
}
}

#[cfg(test)]
fn apply_shell_env(command: &mut Command, _repo: &Path) {
strip_git_env(command);
}
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 @@ -21,6 +21,7 @@ pub mod prs;
pub mod review_commands;
pub mod session_commands;
pub mod session_runner;
pub mod shell_env;
pub mod store;
pub(crate) mod terminal_output;
pub mod timeline;
Expand Down
212 changes: 210 additions & 2 deletions apps/staged/src-tauri/src/session_runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ use std::collections::HashMap;
use std::io;
use std::path::PathBuf;
use std::process::{Command, Output, Stdio};
use std::sync::Arc;
use std::sync::{Arc, OnceLock};
use std::time::Duration;

use serde::{Deserialize, Serialize};
Expand All @@ -50,6 +50,7 @@ use acp_client::{McpServer, McpServerHttp};
use crate::actions::{ActionExecutor, ActionRegistry};
use crate::agent::{AcpDriver, AgentDriver, MessageWriter};
use crate::git::Span;
use crate::shell_env::ShellEnvCache;
use crate::store::{
Comment, CommentAuthor, CommentType, CompletionReason, FailureStrategy, MessageRole,
PipelineExecution, PipelineKind, PipelineStep, SessionStatus, StepStatus, StepType, Store,
Expand Down Expand Up @@ -1282,19 +1283,63 @@ async fn run_pipeline(
PipelineOutcome::CompletedWithoutAi
}

/// Shared cache of interactive-login-shell env snapshots, keyed by working
/// directory. Spawning `$SHELL -ils` to capture `.zshrc`-driven PATH (e.g.
/// Hermit) on every pipeline step costs ~50–500 ms; this amortises it to
/// once per project per TTL window.
pub fn shell_env_cache() -> &'static Arc<ShellEnvCache> {
static CACHE: OnceLock<Arc<ShellEnvCache>> = OnceLock::new();
CACHE.get_or_init(|| Arc::new(ShellEnvCache::new()))
}

async fn run_pipeline_command(
command: &str,
working_dir: &PathBuf,
cancel_token: &CancellationToken,
) -> io::Result<PipelineCommandResult> {
run_pipeline_command_with_cache(shell_env_cache(), command, working_dir, cancel_token).await
}

/// Same as [`run_pipeline_command`] but lets the caller pass an explicit cache.
/// Used by tests to pre-seed snapshots or point at a hermetic fake `$SHELL`.
async fn run_pipeline_command_with_cache(
cache: &ShellEnvCache,
command: &str,
working_dir: &PathBuf,
cancel_token: &CancellationToken,
) -> io::Result<PipelineCommandResult> {
// Apply the cached interactive-login-shell env so Hermit-managed
// binaries are on PATH (matters for git hooks invoked by pipeline
// steps). On capture failure fall back to `sh -lc`, which at least
// sources `/etc/profile`/`~/.profile`.
let snapshot = match cache.get(working_dir).await {
Ok(env) => Some(env),
Err(e) => {
log::warn!(
"Failed to capture shell env for {}: {e}; falling back to sh -lc",
working_dir.display()
);
None
}
};

let mut cmd = tokio::process::Command::new("sh");
cmd.args(["-lc", command])
let sh_args: &[&str] = if snapshot.is_some() {
&["-c", command]
} else {
&["-lc", command]
};
cmd.args(sh_args)
.current_dir(working_dir)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);

if let Some(snapshot) = &snapshot {
snapshot.apply_to(&mut cmd);
}

#[cfg(unix)]
cmd.process_group(0);

Expand Down Expand Up @@ -2344,6 +2389,14 @@ mod tests {
#[cfg(unix)]
#[tokio::test]
async fn pipeline_command_cancellation_stops_current_step() {
// Pre-warm the global cache so the elapsed-time assertion measures pure
// cancellation latency rather than first-time shell-env capture (which
// can take seconds under parallel-test load).
let _ = shell_env_cache()
.get(&std::env::temp_dir())
.await
.expect("warm cache");

let cancel_token = CancellationToken::new();
let cancel_after_start = cancel_token.clone();
tokio::spawn(async move {
Expand All @@ -2362,6 +2415,161 @@ mod tests {
assert!(started.elapsed() < Duration::from_secs(4));
}

// ---------------------------------------------------------------------
// Integration: `run_pipeline_command_with_cache` snapshot/fallback paths
//
// These tests inject a hermetic `ShellEnvCache` via the test seam so
// pipeline behaviour can be exercised without depending on the
// developer's `$SHELL` or `.zshrc`.
// ---------------------------------------------------------------------

/// Write `content` to a 0755 tempfile suitable for use as `$SHELL`.
#[cfg(unix)]
fn write_fake_shell(content: &str) -> tempfile::NamedTempFile {
use std::io::Write as _;
use std::os::unix::fs::PermissionsExt;
let mut file = tempfile::Builder::new()
.prefix("staged-fake-shell-")
.suffix(".sh")
.tempfile()
.expect("create fake shell tempfile");
file.write_all(content.as_bytes()).expect("write script");
file.flush().expect("flush script");
let mut perms = std::fs::metadata(file.path()).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(file.path(), perms).expect("chmod 755");
file
}

/// G20: When the cache produces a snapshot, its env vars reach the child
/// process spawned for the pipeline step.
#[cfg(unix)]
#[tokio::test]
async fn snapshot_path_cached_env_reaches_child() {
let shell = write_fake_shell(
"#!/bin/sh\nPATH=/usr/bin:/bin\nPIPELINE_TEST_TOKEN=snapshot-marker-abc\nexport PATH PIPELINE_TEST_TOKEN\nexec /bin/sh -s\n",
);
let cache = ShellEnvCache::with_shell_and_ttl(
shell.path().to_path_buf(),
Duration::from_secs(3600),
);
let dir = tempfile::tempdir().expect("tempdir");

let cancel = CancellationToken::new();
let result = run_pipeline_command_with_cache(
&cache,
"echo $PIPELINE_TEST_TOKEN",
&dir.path().to_path_buf(),
&cancel,
)
.await
.expect("run_pipeline_command_with_cache should succeed");

let output = match result {
PipelineCommandResult::Completed(o) => o,
PipelineCommandResult::Cancelled { .. } => panic!("unexpected cancellation"),
};
assert!(output.status.success(), "command should succeed");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("snapshot-marker-abc"),
"child must see PIPELINE_TEST_TOKEN from the snapshot; stdout={stdout:?}"
);
}

/// G21: When the cache returns `Err`, `run_pipeline_command_with_cache`
/// falls back to `sh -lc` and the command still runs.
#[cfg(unix)]
#[tokio::test]
async fn fallback_path_when_cache_returns_err() {
let shell = write_fake_shell("#!/bin/sh\nexit 1\n");
let cache = ShellEnvCache::with_shell_and_ttl(
shell.path().to_path_buf(),
Duration::from_secs(3600),
);
let dir = std::env::temp_dir();

let cancel = CancellationToken::new();
let result = run_pipeline_command_with_cache(&cache, "echo fallback-ok", &dir, &cancel)
.await
.expect("fallback path should still spawn and run");

let output = match result {
PipelineCommandResult::Completed(o) => o,
PipelineCommandResult::Cancelled { .. } => panic!("unexpected cancellation"),
};
assert!(output.status.success(), "fallback sh -lc should succeed");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("fallback-ok"),
"fallback should still produce the echo output; stdout={stdout:?}"
);
}

/// G22: Cancellation still terminates the child even after a snapshot is
/// applied — guards against a future refactor that loses `kill_on_drop`
/// or the cancellation `select!` arm.
#[cfg(unix)]
#[tokio::test]
async fn cancellation_under_snapshot_branch() {
let shell =
write_fake_shell("#!/bin/sh\nPATH=/usr/bin:/bin\nexport PATH\nexec /bin/sh -s\n");
let cache = ShellEnvCache::with_shell_and_ttl(
shell.path().to_path_buf(),
Duration::from_secs(3600),
);
let dir = std::env::temp_dir();

let cancel_token = CancellationToken::new();
let cancel_after_start = cancel_token.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(100)).await;
cancel_after_start.cancel();
});

let started = std::time::Instant::now();
let result =
run_pipeline_command_with_cache(&cache, "sleep 5 & wait", &dir, &cancel_token).await;
assert!(matches!(
result,
Ok(PipelineCommandResult::Cancelled { .. })
));
assert!(started.elapsed() < Duration::from_secs(4));
}

/// G23: `current_dir` survives `apply_to` — `pwd` reports the directory
/// passed to `run_pipeline_command_with_cache`, not the test's cwd.
#[cfg(unix)]
#[tokio::test]
async fn current_dir_survives_apply_to() {
let shell =
write_fake_shell("#!/bin/sh\nPATH=/usr/bin:/bin\nexport PATH\nexec /bin/sh -s\n");
let cache = ShellEnvCache::with_shell_and_ttl(
shell.path().to_path_buf(),
Duration::from_secs(3600),
);
let dir = tempfile::tempdir().expect("tempdir");
let resolved = std::fs::canonicalize(dir.path()).unwrap_or_else(|_| dir.path().to_owned());

let cancel = CancellationToken::new();
let result = run_pipeline_command_with_cache(&cache, "pwd", &resolved, &cancel)
.await
.expect("pwd should succeed");
let output = match result {
PipelineCommandResult::Completed(o) => o,
PipelineCommandResult::Cancelled { .. } => panic!("unexpected cancellation"),
};
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
let reported = stdout.trim();
let reported_path = std::fs::canonicalize(PathBuf::from(reported))
.unwrap_or_else(|_| PathBuf::from(reported));
assert_eq!(
reported_path, resolved,
"child should run in the requested working_dir; pwd reported {reported:?}"
);
}

#[test]
fn pipeline_command_output_collapses_progress_for_prompt() {
let output = combine_normalized_command_output(b"10%\r20%\rdone\n", b"");
Expand Down
Loading
Loading