Default sh cwd to workspace and drop ShOpts.cwd
Host mode always runs in the workspace; no concrete use case demands
a per-`(sh ...)` override yet. Dropping the field simplifies
`Cmd::run` to take a `&Path` and unconditionally chdir.
Assisted-by: Claude Opus 4.7 via Claude Code
diff --git a/src/ci/mirror.rs b/src/ci/mirror.rs
index 3115bac..aed4418 100644
--- a/src/ci/mirror.rs
+++ b/src/ci/mirror.rs
@@ -66,7 +66,6 @@ impl MirrorJob {
let git_opts = ShOpts {
env: HashMap::from([("GIT_DIR".to_string(), git_dir)]),
- cwd: None,
};
// Tag step.
@@ -92,11 +91,12 @@ impl MirrorJob {
// Run via `Cmd::run` rather than `rt.sh` — we don't want the
// encoded token landing in recorded outputs.
let token_pair = format!("x-access-token:{secret}");
- let encoded_output =
- Cmd::Shell("printf '%s' \"$T\" | base64 --wrap=0".to_string()).run(ShOpts {
+ let encoded_output = Cmd::Shell("printf '%s' \"$T\" | base64 --wrap=0".to_string()).run(
+ ShOpts {
env: HashMap::from([("T".to_string(), token_pair)]),
- cwd: None,
- })?;
+ },
+ rt.workspace(),
+ )?;
let auth_header = format!("Authorization: Basic {}", encoded_output.stdout.trim());
// Push the configured refs (or the trigger ref, if none) plus the tag.
@@ -441,7 +441,13 @@ mod tests {
RunFn::Rust(f) => f,
RunFn::Lua(_) => panic!("mirror should register a RunFn::Rust"),
};
- let runtime = Rc::new(Runtime::new(pipeline, secrets, meta, git_dir));
+ let runtime = Rc::new(Runtime::new(
+ pipeline,
+ secrets,
+ meta,
+ git_dir,
+ std::env::current_dir().expect("cwd"),
+ ));
let _ = RuntimeHandle(runtime.clone())
.into_lua(runtime.lua())
.expect("install runtime");
diff --git a/src/ci/run.rs b/src/ci/run.rs
index b10bbe4..6af0f8d 100644
--- a/src/ci/run.rs
+++ b/src/ci/run.rs
@@ -285,12 +285,18 @@ impl Run {
workspace: &std::path::Path,
executor: Executor,
) -> Result<HashMap<String, Vec<ShOutput>>> {
- // `workspace` and `executor` are not yet read; later tasks
- // wire them into the runtime and per-run container lifecycle.
- let _ = (workspace, executor);
+ // `executor` is not yet read; later tasks wire it into the
+ // per-run container lifecycle.
+ let _ = executor;
let meta = self.read_meta()?;
- let runtime = Rc::new(Runtime::new(pipeline, secrets, &meta, git_dir));
+ let runtime = Rc::new(Runtime::new(
+ pipeline,
+ secrets,
+ &meta,
+ git_dir,
+ workspace.to_path_buf(),
+ ));
let lua = runtime.lua();
let rt_value = RuntimeHandle(runtime.clone())
@@ -958,6 +964,39 @@ mod tests {
super::super::pipeline::compile(source, "ci.fnl").expect("compile should succeed")
}
+ #[test]
+ fn host_mode_runs_sh_in_workspace() {
+ let (_dir, quire) = tmp_quire();
+ let runs = test_runs(&quire);
+ let run = runs.create(&test_meta()).expect("create");
+
+ let workspace = quire.base_dir().join("ws");
+ fs_err::create_dir_all(&workspace).expect("mkdir ws");
+ fs_err::write(workspace.join("marker"), "x").expect("write marker");
+
+ let pipeline = load(
+ r#"(local ci (require :quire.ci))
+(ci.job :pwd [:quire/push] (fn [{: sh}] (sh ["ls"])))"#,
+ );
+
+ let outputs = run
+ .execute(
+ pipeline,
+ HashMap::new(),
+ std::path::Path::new("."),
+ &workspace,
+ Executor::Host,
+ )
+ .expect("execute");
+ let pwd = &outputs["pwd"];
+ assert_eq!(pwd.len(), 1);
+ assert!(
+ pwd[0].stdout.contains("marker"),
+ "expected workspace ls to include marker, got: {:?}",
+ pwd[0].stdout,
+ );
+ }
+
#[test]
fn execute_records_outputs_per_job() {
let (_dir, quire) = tmp_quire();
diff --git a/src/ci/runtime.rs b/src/ci/runtime.rs
index e049054..8763a99 100644
--- a/src/ci/runtime.rs
+++ b/src/ci/runtime.rs
@@ -43,6 +43,9 @@ pub(super) struct Runtime {
pub(super) inputs: HashMap<String, HashMap<String, Option<mlua::Value>>>,
pub(super) current_job: RefCell<Option<String>>,
pub(super) outputs: RefCell<HashMap<String, Vec<ShOutput>>>,
+ /// The materialized workspace for this run. Every `(sh …)` call
+ /// runs here.
+ workspace: std::path::PathBuf,
}
impl Runtime {
@@ -62,6 +65,7 @@ impl Runtime {
secrets: HashMap<String, SecretString>,
meta: &RunMeta,
git_dir: &std::path::Path,
+ workspace: std::path::PathBuf,
) -> Self {
let transitive = pipeline.transitive_inputs();
let lua = pipeline.fennel().lua();
@@ -100,6 +104,7 @@ impl Runtime {
inputs,
current_job: RefCell::new(None),
outputs: RefCell::new(HashMap::new()),
+ workspace,
}
}
@@ -118,6 +123,11 @@ impl Runtime {
self.pipeline.job(id)
}
+ /// Borrow the run's materialized workspace path.
+ pub(super) fn workspace(&self) -> &std::path::Path {
+ &self.workspace
+ }
+
/// Mark `id` as the currently executing job. `(sh …)` invocations
/// from this job's `run_fn` will record output under `id`, and
/// `(jobs …)` lookups will resolve against `id`'s view.
@@ -158,7 +168,7 @@ impl Runtime {
/// current job (if one is active). Non-zero exits come back in
/// `:exit`, not as `Err`.
pub(super) fn sh(&self, cmd: Cmd, opts: ShOpts) -> crate::Result<ShOutput> {
- let output = cmd.run(opts)?;
+ let output = cmd.run(opts, &self.workspace)?;
if let Some(job) = self.current_job.borrow().as_ref() {
self.outputs
.borrow_mut()
@@ -173,7 +183,8 @@ impl Runtime {
#[cfg(test)]
impl Runtime {
/// Minimal constructor for tests — no source outputs, just
- /// secrets and the pipeline's VM.
+ /// secrets and the pipeline's VM. Defaults the workspace to the
+ /// process CWD so tests that don't care about cwd keep working.
fn for_test(pipeline: Pipeline, secrets: HashMap<String, SecretString>) -> Self {
Self {
pipeline,
@@ -181,6 +192,7 @@ impl Runtime {
inputs: HashMap::new(),
current_job: RefCell::new(None),
outputs: RefCell::new(HashMap::new()),
+ workspace: std::env::current_dir().expect("cwd"),
}
}
}
@@ -310,7 +322,8 @@ impl From<Cmd> for std::process::Command {
impl Cmd {
/// Spawn this command with the given options, blocking until exit,
/// and capture the result. Inherits the runner's env with
- /// `opts.env` merged on top.
+ /// `opts.env` merged on top. `cwd` becomes the child's working
+ /// directory unconditionally.
//
// TODO: stream stdout/stderr live instead of buffering. `output()`
// captures the full child output in memory and only returns at exit,
@@ -321,15 +334,13 @@ impl Cmd {
// Also revisit the `from_utf8_lossy` calls below — non-UTF-8 bytes
// are silently replaced with U+FFFD and `:stdout` / `:stderr` end
// up as mojibake with no signal that anything was lost.
- pub(super) fn run(self, opts: ShOpts) -> std::io::Result<ShOutput> {
+ pub(super) fn run(self, opts: ShOpts, cwd: &std::path::Path) -> std::io::Result<ShOutput> {
let cmd_str = format!("{self}");
let mut command: std::process::Command = self.into();
for (k, v) in opts.env {
command.env(k, v);
}
- if let Some(cwd) = opts.cwd {
- command.current_dir(cwd);
- }
+ command.current_dir(cwd);
let output = command.output()?;
// Signal-killed processes have no exit code; collapse them to -1
// for now. Surfacing the signal as a separate field is future work.
@@ -382,7 +393,6 @@ impl mlua::FromLua for Cmd {
#[serde(default, deny_unknown_fields)]
pub(super) struct ShOpts {
pub(super) env: HashMap<String, String>,
- pub(super) cwd: Option<String>,
}
impl mlua::FromLua for ShOpts {
@@ -524,21 +534,6 @@ mod tests {
assert_eq!(r.stdout, "from-parent from-opts\n");
}
- #[test]
- fn sh_honors_cwd() {
- let dir = tempfile::tempdir().expect("tempdir");
- // Resolve symlinks (macOS /tmp → /private/tmp) so the assertion holds.
- let canonical = fs_err::canonicalize(dir.path()).expect("canonicalize");
- let source = format!(
- r#"(local ci (require :quire.ci))
-(ci.job :go [:quire/push] (fn [{{: sh}}] (sh "pwd" {{:cwd "{}"}})))"#,
- canonical.display()
- );
- let r = run_sh_via_job(&source);
- assert_eq!(r.exit, 0);
- assert_eq!(r.stdout.trim(), canonical.to_string_lossy());
- }
-
#[test]
fn sh_rejects_unknown_opt_key() {
let (runtime, run_fn) = rt(
@@ -553,7 +548,7 @@ mod tests {
let msg = err.to_string();
assert!(
msg.contains("unknown field") && msg.contains("cwdir"),
- "expected unknown-field error mentioning the typo, got: {msg}"
+ "expected unknown-field error mentioning the unknown key, got: {msg}"
);
}