Derive git metadata from --git-dir for local runs; add --local flag
Replace --sha and --git-ref CLI args with --local flag in quire-ci.
When --local is passed, quire-ci runs git rev-parse HEAD and
git symbolic-ref HEAD to derive commit SHA and ref from the git dir,
falling back to "@" for detached HEAD.

Remove meta from execute() signature since local runs no longer need
it forwarded; the server still creates the run with meta for its own
DB record.

Add bail!/ensure! preference note to AGENTS.md.

https://claude.ai/code/session_01Ngf4zbFf87zJFUR5ee2Y8b
change
commit 04506be2d0d8a9cadea294d174606e04dfd61f7a
author Claude <noreply@anthropic.com>
date
parent 01ff292b
diff --git a/AGENTS.md b/AGENTS.md
index ebbb145..bc837d9 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -10,6 +10,13 @@ When the user asks to add a task, run `ranger task create` (plus `ranger tag add
 
 The backlog shifts between sessions and even within a session — tasks get reordered, retitled, moved between states, closed, or added by the user without notice. Before acting on a task you remember (or one referenced earlier in the conversation), re-run `ranger task show <key>` and confirm state, ordering, and description against ground truth. Do not trust earlier `ranger task list` output to still be accurate; refetch when placement matters (e.g. moving to top/back of ready).
 
+## Error handling
+
+Prefer `bail!` and `ensure!` from miette over constructing errors manually with
+`miette::miette!()` and `.ok_or_else()`. Use `bail!` for early returns and
+`ensure!` for precondition checks. `IntoDiagnostic` (via `.into_diagnostic()`)
+is the right bridge for non-miette error types.
+
 ## Before committing
 
 Always run `just all` and verify everything passes before committing. No exceptions — this is not optional. If you commit without running it, you will break the build.
diff --git a/quire-ci/src/main.rs b/quire-ci/src/main.rs
index e19de27..d213310 100644
--- a/quire-ci/src/main.rs
+++ b/quire-ci/src/main.rs
@@ -106,20 +106,17 @@ enum Commands {
         #[facet(args::named, default)]
         out_dir: Option<PathBuf>,
 
-        /// Path to the bare git repo for this run. Required for local
-        /// runs (no `QUIRE__SERVER_URL`); server-dispatched runs
-        /// receive this via the bootstrap API instead.
+        /// Run in local mode. Derives commit SHA and ref from `--git-dir`
+        /// instead of fetching bootstrap data from the server. Pass
+        /// `QUIRE__SERVER_URL` to use the server API instead.
         #[facet(args::named, default)]
-        git_dir: Option<PathBuf>,
-
-        /// Commit SHA for this run. Required for local runs.
-        #[facet(args::named, default)]
-        sha: Option<String>,
+        local: bool,
 
-        /// Git ref for this run (e.g. `refs/heads/main`). Required for
-        /// local runs.
+        /// Path to the bare git repo for this run. Required when
+        /// `--local` is set; server-dispatched runs receive this via the
+        /// bootstrap API instead.
         #[facet(args::named, default)]
-        git_ref: Option<String>,
+        git_dir: Option<PathBuf>,
     },
 }
 
@@ -284,9 +281,8 @@ fn main() -> Result<()> {
         Commands::Run {
             events,
             out_dir,
+            local,
             git_dir,
-            sha,
-            git_ref,
         } => {
             let sink: Box<dyn EventSink> = match events.parse::<EventsTarget>().unwrap() {
                 EventsTarget::Null => Box::new(NullSink),
@@ -325,12 +321,11 @@ fn main() -> Result<()> {
             };
             let client = RunClient::new(session.clone());
 
-            let (git_dir, meta, sentry_trace_id) = if session.server_url.is_empty() {
+            let (git_dir, meta, sentry_trace_id) = if local {
                 let git_dir = git_dir
                     .ok_or_else(|| miette::miette!("--git-dir is required for local runs"))?;
-                let sha = sha.ok_or_else(|| miette::miette!("--sha is required for local runs"))?;
-                let git_ref = git_ref
-                    .ok_or_else(|| miette::miette!("--git-ref is required for local runs"))?;
+                let sha = git_rev_parse(&git_dir, "HEAD")?;
+                let git_ref = git_symbolic_ref(&git_dir).unwrap_or_else(|_| "@".to_string());
                 let meta = RunMeta {
                     sha,
                     r#ref: git_ref,
@@ -403,6 +398,35 @@ fn init_sentry(dsn: &str, trace_id: Option<&str>, meta: &RunMeta) -> sentry::Cli
     guard
 }
 
+fn git_rev_parse(git_dir: &std::path::Path, rev: &str) -> Result<String> {
+    let out = std::process::Command::new("git")
+        .arg("--git-dir")
+        .arg(git_dir)
+        .arg("rev-parse")
+        .arg(rev)
+        .output()
+        .into_diagnostic()?;
+    if !out.status.success() {
+        let stderr = String::from_utf8_lossy(&out.stderr);
+        miette::bail!("git rev-parse {rev} failed: {stderr}");
+    }
+    Ok(String::from_utf8(out.stdout).into_diagnostic()?.trim().to_string())
+}
+
+fn git_symbolic_ref(git_dir: &std::path::Path) -> Result<String> {
+    let out = std::process::Command::new("git")
+        .arg("--git-dir")
+        .arg(git_dir)
+        .arg("symbolic-ref")
+        .arg("HEAD")
+        .output()
+        .into_diagnostic()?;
+    if !out.status.success() {
+        miette::bail!("HEAD is detached");
+    }
+    Ok(String::from_utf8(out.stdout).into_diagnostic()?.trim().to_string())
+}
+
 fn validate(workspace: PathBuf) -> Result<()> {
     let pipeline = compile_at(&workspace)?;
 
diff --git a/quire-core/src/ci/bootstrap.rs b/quire-core/src/ci/bootstrap.rs
index ed6b049..d581e0f 100644
--- a/quire-core/src/ci/bootstrap.rs
+++ b/quire-core/src/ci/bootstrap.rs
@@ -2,8 +2,8 @@
 //!
 //! The orchestrator stores a [`Bootstrap`] in the database; `quire-ci`
 //! fetches it via `GET /api/run/bootstrap` using the per-run bearer
-//! token. Local runs receive bootstrap data directly via CLI flags
-//! (`--git-dir`, `--sha`, `--git-ref`) instead.
+//! token. Local runs pass `--local --git-dir <path>` and derive the
+//! commit SHA and ref directly from the git dir.
 
 use std::path::PathBuf;
 
diff --git a/quire-server/src/bin/quire/commands/ci.rs b/quire-server/src/bin/quire/commands/ci.rs
index cea5410..4455273 100644
--- a/quire-server/src/bin/quire/commands/ci.rs
+++ b/quire-server/src/bin/quire/commands/ci.rs
@@ -85,7 +85,7 @@ pub async fn run(quire: &Quire, maybe_sha: Option<&str>) -> Result<()> {
     let workspace = tmp.path().join("workspace");
     quire::ci::materialize_workspace(&repo_path.join(".git"), &commit.sha, &workspace)
         .into_diagnostic()?;
-    let exec_result = run.execute(&repo_path.join(".git"), &workspace, &meta, None, None, None);
+    let exec_result = run.execute(&repo_path.join(".git"), &workspace, None, None, None);
 
     // Print the combined quire-ci log regardless of outcome.
     let log_path = tmp.path().join(&run_id).join("quire-ci.log");
diff --git a/quire-server/src/ci/mod.rs b/quire-server/src/ci/mod.rs
index 153df02..afc86f5 100644
--- a/quire-server/src/ci/mod.rs
+++ b/quire-server/src/ci/mod.rs
@@ -254,7 +254,6 @@ fn run_ref_inner(
             run.execute(
                 &ctx.repo.path(),
                 &workspace,
-                &meta,
                 sentry_trace_id,
                 sentry_dsn,
                 Some(transport),
diff --git a/quire-server/src/ci/run.rs b/quire-server/src/ci/run.rs
index d6dfe69..284efc4 100644
--- a/quire-server/src/ci/run.rs
+++ b/quire-server/src/ci/run.rs
@@ -298,7 +298,6 @@ impl Run {
         mut self,
         git_dir: &Path,
         workspace: &Path,
-        meta: &RunMeta,
         sentry_trace_id: Option<&str>,
         sentry_dsn: Option<&str>,
         transport: Option<&Transport>,
@@ -341,12 +340,7 @@ impl Run {
 
         match transport {
             None => {
-                cmd.arg("--git-dir")
-                    .arg(git_dir)
-                    .arg("--sha")
-                    .arg(&meta.sha)
-                    .arg("--git-ref")
-                    .arg(&meta.r#ref);
+                cmd.arg("--local").arg("--git-dir").arg(git_dir);
             }
             Some(t) => {
                 self.store_bootstrap_data(git_dir, sentry_trace_id)?;