fix(delivery): four failures from the #55 trace — auth, prompts, truncation, retry

All four were surfaced while tracing #55 and left open. Each one on its own is
small; together they are why a two-line git rejection took hours to read.

**1. `with_ambient_auth` failed open.** It matched one literal prefix,
`https://git.redclaw.dev/`, and returned the URL unchanged for everything else
with no log line. An `http://` remote, an explicit port, a different case in the
host, an ssh remote, a URL that already carried userinfo — all came back
unauthenticated and looked identical to success. It now returns `Authed`, which
carries the URL AND why no credential reached it, and recognises the forge in
every shape a remote can be written (host parsed with userinfo stripped BEFORE
the port, or `oauth2:token@host` reports its username as the host — the first
version of this function did exactly that and failed its own test).

**2. Nothing set `GIT_TERMINAL_PROMPT=0`.** So a credential-less URL did not
fail — git opened `/dev/tty`, and in a server container that surfaces as
`No such device or address`, several layers from the missing token. Now set on
every git invocation that can reach the network. And `push_url_for` refuses
outright when the URL is on OUR forge and unauthenticated: that push cannot
succeed, and letting it proceed only buys a symptom that looks like something
else.

**3. The truncation fix went to the wrong path.** e31688b clamped the caller,
but a rejected push comes back as `Ok(Publish { error })` — the string was
already cut to 300 head chars inside `git()`, so the reject reason had been
dropped before the both-ends clamp ever saw it. Clamped where the output is
produced, and redacted there too.

**4. #55: a mission that re-clones can never push.** The branch name is
deterministic per (mission, phase, iteration), so a checkout rebuilt after a
retry, a container teardown or disk loss produces divergent history against its
own branch, and git rejects it — leaving the work on a local branch in a
directory the sweeper deletes. Reachable in normal operation, not just by
deleting a checkout by hand.

The escape is a NEW ref, not `--force`: forcing would overwrite whatever the
earlier attempt pushed, which may be the only copy of that work, to make this
attempt look tidy. The retry lands on `<branch>-<sha8>` — deterministic,
self-describing in a branch list, and collision-free since divergent history is
by definition a different sha. "Never force" stays a rule.

NEGATIVE CONTROL, run rather than assumed: with the rescue arm disabled,
`diverged_history_lands_on_a_new_branch_instead_of_being_lost` FAILS with git's
real `! [rejected] ... (fetch first)` — which also demonstrates fix 3, since that
whole message now survives to the assertion. The test asserts the earlier
attempt's ref is byte-identical afterwards.

Also measured, not read off docs: which hooks fire under `claude -p` (2.1.222,
via `--settings`). SessionStart, UserPromptSubmit, PreToolUse, PostToolUse,
SubagentStop and Stop fire; TaskCreated, TaskCompleted, TeammateIdle, SessionEnd,
Notification and PreCompact do not. So the agent-teams hooks Slice 3 deferred are
inert on our path by construction, and `Stop` is the seam that could move
`done_when` into the agent's own loop.

507 tests pass, clippy clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-08-06 13:15:15 -07:00
co-authored by Claude Opus 5
parent 12147a1e01
commit 1d554396f4
4 changed files with 472 additions and 56 deletions
+20 -8
View File
@@ -93,9 +93,13 @@ pub async fn clone_vault(clone_url: &str, work_root: &Path) -> Result<PathBuf, S
.map_err(|e| format!("mkdir {}: {e}", work_root.display()))?; .map_err(|e| format!("mkdir {}: {e}", work_root.display()))?;
let auth = mission_workspace::with_ambient_auth(clone_url); let auth = mission_workspace::with_ambient_auth(clone_url);
let out = tokio::process::Command::new("git") if let Some(why) = &auth.unauthenticated {
.args(["clone", "--quiet", "--depth", "1", &auth]) eprintln!("library: cloning the vault WITHOUT credentials — {why}");
.arg(&path) }
let mut cmd = tokio::process::Command::new("git");
cmd.args(["clone", "--quiet", "--depth", "1", &auth.url])
.arg(&path);
let out = mission_workspace::no_terminal_prompt(&mut cmd)
.output() .output()
.await .await
.map_err(|e| format!("spawn git clone: {e}"))?; .map_err(|e| format!("spawn git clone: {e}"))?;
@@ -103,16 +107,15 @@ pub async fn clone_vault(clone_url: &str, work_root: &Path) -> Result<PathBuf, S
return Err(format!( return Err(format!(
"clone vault → {}: {}", "clone vault → {}: {}",
out.status, out.status,
mission_workspace::redact_token(&String::from_utf8_lossy(&out.stderr)) crate::evaluator_tools::clamp_output(&mission_workspace::redact_token(
.chars() &String::from_utf8_lossy(&out.stderr)
.take(300) ))
.collect::<String>()
)); ));
} }
// The token must not stay in .git/config: the checkout may be handed to a // The token must not stay in .git/config: the checkout may be handed to a
// container later, and a credential in a file an agent can read is a // container later, and a credential in a file an agent can read is a
// credential an agent has. // credential an agent has.
mission_workspace::scrub_remote_credentials(&path, &auth); mission_workspace::scrub_remote_credentials(&path, &auth.url);
Ok(path) Ok(path)
} }
@@ -186,6 +189,15 @@ pub async fn run_to_vault(
git(&vault, &["commit", "--no-verify", "-m", &message]).await?; git(&vault, &["commit", "--no-verify", "-m", &message]).await?;
let auth = mission_workspace::with_ambient_auth(clone_url); let auth = mission_workspace::with_ambient_auth(clone_url);
if let Some(why) = &auth.unauthenticated {
if auth.is_forge() {
// Not fatal here — the push below reports its own failure — but the
// reason belongs in the log next to the attempt, not inferred from a
// tty error two layers down.
eprintln!("library: pushing to the forge WITHOUT credentials — {why}");
}
}
let auth = auth.url;
let refspec = format!("HEAD:refs/heads/{branch}"); let refspec = format!("HEAD:refs/heads/{branch}");
match git(&vault, &["push", &auth, &refspec]).await { match git(&vault, &["push", &auth, &refspec]).await {
Ok(_) => { Ok(_) => {
+124 -7
View File
@@ -511,8 +511,9 @@ async fn git(repo: &Path, args: &[&str]) -> Result<String, String> {
]; ];
full.extend(args.iter().map(|a| (*a).to_string())); full.extend(args.iter().map(|a| (*a).to_string()));
let (name, email) = commit_identity(); let (name, email) = commit_identity();
let out = tokio::process::Command::new("git") let mut cmd = tokio::process::Command::new("git");
.args(&full) cmd.args(&full);
let out = crate::mission_workspace::no_terminal_prompt(&mut cmd)
// The server container has no git identity — `git config --global // The server container has no git identity — `git config --global
// user.email` exits 1 — so `git commit` fails with "Author identity // user.email` exits 1 — so `git commit` fails with "Author identity
// unknown" unless one is supplied. Mission `019fc450` lost its first // unknown" unless one is supplied. Mission `019fc450` lost its first
@@ -541,10 +542,15 @@ async fn git(repo: &Path, args: &[&str]) -> Result<String, String> {
"git {} → {}: {}", "git {} → {}: {}",
args.first().copied().unwrap_or("?"), args.first().copied().unwrap_or("?"),
out.status, out.status,
String::from_utf8_lossy(&out.stderr) // Both ends, never a head-only clamp. THIS is where the reason was
.chars() // being lost: `publish_phase_branch` returns a rejected push as
.take(300) // `Ok(Publish { error })`, so the string it carries was already
.collect::<String>() // truncated here — 300 chars of auth noise, with "non-fast-forward"
// cut off — before the caller's own both-ends clamp ever saw it.
// Clamping the caller fixed the path that was already fine.
crate::mission_workspace::redact_token(&crate::evaluator_tools::clamp_output(
&String::from_utf8_lossy(&out.stderr)
))
)); ));
} }
Ok(String::from_utf8_lossy(&out.stdout).into_owned()) Ok(String::from_utf8_lossy(&out.stdout).into_owned())
@@ -779,7 +785,38 @@ async fn push_url_for(pool: &sqlx::PgPool, mission_id: Uuid) -> Result<Option<St
// "nothing to push to" — the shape that made `commit_error` necessary. // "nothing to push to" — the shape that made `commit_error` necessary.
.map_err(|e| format!("query push URL: {e}"))? .map_err(|e| format!("query push URL: {e}"))?
.flatten(); .flatten();
Ok(url.map(|u| mission_workspace::with_ambient_auth(&u))) let Some(url) = url else { return Ok(None) };
let auth = mission_workspace::with_ambient_auth(&url);
// Fail here, not at the tty. An unauthenticated URL to OUR forge cannot
// push, and every second it survives past this point is spent producing a
// symptom that looks like something else: git asking for a username, then
// `/dev/tty: No such device or address`, then a `push_error` about auth that
// sent #55's investigation after credentials which were never the problem.
// A third-party host is left alone — ssh keys and .netrc are legitimate.
if let Some(why) = &auth.unauthenticated {
if auth.is_forge() {
return Err(format!(
"cannot authenticate the push URL for this mission — {why}. The work \
is committed locally; fix the credential and re-run delivery."
));
}
eprintln!("mission_delivery: pushing to a non-forge remote unauthenticated — {why}");
}
Ok(Some(auth.url))
}
/// Did the forge reject this push because our history diverged from the ref?
///
/// Git says this several ways depending on version and refspec, and all of them
/// mean the same thing here: the branch already exists with commits ours does not
/// contain.
fn is_non_fast_forward(err: &str) -> bool {
let e = err.to_ascii_lowercase();
e.contains("non-fast-forward")
|| e.contains("fetch first")
|| e.contains("updates were rejected")
|| (e.contains("[rejected]") && !e.contains("stale info"))
} }
/// Run the gate, then push the branch if the gate allows it. /// Run the gate, then push the branch if the gate allows it.
@@ -822,6 +859,54 @@ pub async fn publish_phase_branch(
error: None, error: None,
}) })
} }
// #55: a mission whose checkout was re-cloned — a retry, a container
// teardown, disk loss — builds divergent history against its OWN
// deterministic branch, and every push it ever attempts is rejected.
// Before this, that was terminal: the work stayed on a local branch in a
// directory that gets reaped.
//
// The escape is a NEW ref, not `--force`. Forcing would overwrite
// whatever the earlier attempt pushed — which may be the only copy of
// that work — to make this attempt look tidy. Suffixing with the commit
// sha is deterministic (the same history always lands on the same ref),
// self-describing in a branch list, and cannot collide, since divergent
// history is by definition a different sha.
Err(e) if is_non_fast_forward(&e) => {
let sha = git(repo, &["rev-parse", "HEAD"])
.await
.map(|s| s.trim().to_string())
.unwrap_or_default();
let Some(short) = sha.get(..8) else {
eprintln!("mission_delivery: push of {target} rejected and HEAD unreadable: {e}");
return Ok(Publish {
branch: target,
pushed: false,
error: Some(e),
});
};
let alt = format!("{target}-{short}");
eprintln!(
"mission_delivery: {target} exists on the forge with history this \
checkout does not contain — pushing to {alt} instead of forcing. \
Original: {e}"
);
git(repo, &["branch", "-f", &alt, "HEAD"]).await?;
match git(repo, &["push", push_url, &format!("HEAD:refs/heads/{alt}")]).await {
Ok(_) => Ok(Publish {
branch: alt,
pushed: true,
// Not an error — the work reached the forge — but the
// redirect is a fact the operator needs, or two branches for
// one phase look like a bug rather than a rescue.
error: None,
}),
Err(e2) => Ok(Publish {
branch: alt,
pushed: false,
error: Some(format!("{e}\n\nand the diverged-history retry also failed: {e2}")),
}),
}
}
Err(e) => { Err(e) => {
// Redacted by `git`'s error path already; the patch and the local // Redacted by `git`'s error path already; the patch and the local
// branch both survive, so this is a degraded success. // branch both survive, so this is a degraded success.
@@ -1041,6 +1126,38 @@ fn untrusted_empty_reason(empty: bool, diff_error: Option<&str>) -> Option<Strin
mod tests { mod tests {
use super::*; use super::*;
/// Git says "your history diverged" several ways, and the one production
/// actually produced (`! [rejected] ... (fetch first)`) is not the phrase
/// anyone reaches for first. Missing a phrasing means the rescue does not
/// fire and the work stays on a local branch in a directory that gets
/// reaped — silently, since the push failure is a degraded success.
#[test]
fn every_way_git_says_diverged_is_recognised() {
for e in [
"git push → exit 1: ! [rejected] HEAD -> b (fetch first)\nhint: …",
" ! [rejected] HEAD -> b (non-fast-forward)",
"hint: Updates were rejected because the remote contains work that you \
do not have locally.",
] {
assert!(is_non_fast_forward(e), "not recognised: {e}");
}
}
/// And it must not fire on failures a new branch cannot fix. Retrying a
/// permissions or network error onto a second ref just produces a second
/// failure and a confusing branch name.
#[test]
fn other_push_failures_are_not_mistaken_for_divergence() {
for e in [
"fatal: repository 'https://forge/x.git' not found",
"remote: error: GH006: Protected branch update failed",
"fatal: could not read Username for 'https://forge': terminal prompts disabled",
" ! [rejected] (stale info)",
] {
assert!(!is_non_fast_forward(e), "wrongly recognised: {e}");
}
}
// ── The gate ─────────────────────────────────────────────────────── // ── The gate ───────────────────────────────────────────────────────
/// Three recipes have declared `commit_policy` since they were written and /// Three recipes have declared `commit_policy` since they were written and
+215 -31
View File
@@ -65,7 +65,19 @@ pub async fn ensure_checkout(
.map_err(|e| format!("mkdir {}: {e}", parent.display()))?; .map_err(|e| format!("mkdir {}: {e}", parent.display()))?;
} }
let auth_url = with_ambient_auth(clone_url); let auth = with_ambient_auth(clone_url);
// Said once, here, where the checkout is created: every later git call uses
// a URL built the same way, so an unauthenticated forge URL is a fact worth
// one line now rather than a `/dev/tty` error later.
if let Some(why) = &auth.unauthenticated {
if auth.is_forge() {
eprintln!(
"mission_workspace: mission {mission_id} will talk to the forge \
WITHOUT credentials — {why}"
);
}
}
let auth_url = auth.url;
if path.join(".git").exists() { if path.join(".git").exists() {
// Checkouts cloned before this setting existed get it on reuse. It // Checkouts cloned before this setting existed get it on reuse. It
// governs objects created from now on, which is what delivery needs. // governs objects created from now on, which is what delivery needs.
@@ -92,21 +104,120 @@ pub async fn ensure_checkout(
Ok(Some(path)) Ok(Some(path))
} }
/// If the URL points at git.redclaw.dev AND GITEA_TOKEN is set in the /// The forge whose URLs the ambient `GITEA_TOKEN` can authenticate.
/// environment, rewrite it to include the token as basic-auth. Returns const FORGE_HOST: &str = "git.redclaw.dev";
/// the URL unchanged otherwise. The token is never logged (we only
/// pass the rewritten URL into `git clone` via argv). /// A URL, and whether a credential actually reached it.
pub(crate) fn with_ambient_auth(url: &str) -> String { ///
let Ok(token) = std::env::var("GITEA_TOKEN") else { /// The second field is the whole point. This used to be a bare `String`: an
return url.to_string(); /// unmatched URL — an ssh remote, `http://` instead of `https://`, an explicit
/// port, a different case in the host — silently came back unauthenticated, and
/// the first symptom was git opening `/dev/tty` several layers later. Tracing
/// #55 cost hours to a failure whose cause was one unlogged early return.
pub(crate) struct Authed {
pub url: String,
/// `None` when the token was applied; otherwise WHY it was not.
pub unauthenticated: Option<String>,
/// Whether the URL names the forge our token is for. Recorded from the
/// ORIGINAL url, not re-derived from `url` — an authenticated URL carries
/// userinfo, and parsing that back out is how the answer goes wrong.
forge: bool,
}
impl Authed {
/// Is this URL on the forge our token is for? An unauthenticated URL to a
/// third-party host is normal (public repos, ssh remotes with a key); an
/// unauthenticated URL to OUR forge is a fault, and only the caller knows
/// how much it costs.
pub fn is_forge(&self) -> bool {
self.forge
}
}
/// The host component of a URL, for the scp-like and scheme forms git accepts.
///
/// Deliberately tolerant, because the point is to RECOGNISE our forge in every
/// shape it can be written, not to validate URLs: `https://`, `http://`, an
/// explicit `:port`, `user@host`, `ssh://`, and `git@host:path`.
fn host_of(url: &str) -> Option<&str> {
let rest = match url.split_once("://") {
Some((_, rest)) => rest,
// scp-like: `git@host:path/to.git`, which has no scheme.
None => url,
}; };
if token.is_empty() { // Userinfo FIRST, then the port. The other order splits
return url.to_string(); // `oauth2:token@host` at the credential's colon and reports the username as
// the host — which is exactly how the first version of this function decided
// an authenticated forge URL was not the forge.
let authority = rest.split('/').next().filter(|s| !s.is_empty())?;
let hostport = authority.rsplit_once('@').map_or(authority, |(_, h)| h);
Some(hostport.split(':').next().unwrap_or(hostport)).filter(|h| !h.is_empty())
} }
if let Some(rest) = url.strip_prefix("https://git.redclaw.dev/") {
return format!("https://oauth2:{token}@git.redclaw.dev/{rest}"); /// Rewrite a forge URL to carry the ambient `GITEA_TOKEN` as basic-auth.
///
/// Returns the reason instead of the credential whenever it cannot: a missing
/// token, a host that is not ours, or a shape a token cannot be injected into.
/// The token is never logged — only the rewritten URL is passed to git, via
/// argv.
pub(crate) fn with_ambient_auth(url: &str) -> Authed {
auth_with_token(url, std::env::var("GITEA_TOKEN").ok().as_deref())
} }
url.to_string()
/// The testable half of [`with_ambient_auth`]. The token is a parameter because
/// a test cannot set process environment variables here — the workspace denies
/// `unsafe`, and `set_var` is racy across test threads regardless.
fn auth_with_token(url: &str, token: Option<&str>) -> Authed {
let host = host_of(url);
let forge = host.is_some_and(|h| h.eq_ignore_ascii_case(FORGE_HOST));
let unauth = |why: String| Authed {
url: url.to_string(),
unauthenticated: Some(why),
forge,
};
let host = match host {
Some(h) => h,
None => return unauth(format!("no host could be read from {url:?}")),
};
if !forge {
return unauth(format!(
"{host} is not {FORGE_HOST}, so GITEA_TOKEN does not apply — git will \
use whatever ambient credentials exist (ssh agent, .netrc, helper)"
));
}
let token = match token {
Some(t) if !t.trim().is_empty() => t,
_ => return unauth("GITEA_TOKEN is unset or empty".to_string()),
};
// Only the scheme forms can carry basic-auth. An ssh remote authenticates
// with a key, and pretending otherwise would produce a URL git rejects.
let Some((scheme, rest)) = url.split_once("://") else {
return unauth(format!(
"{url} is an ssh-style remote; a token cannot be embedded in it"
));
};
if !matches!(scheme, "http" | "https") {
return unauth(format!("scheme {scheme} cannot carry a token"));
}
// Drop any userinfo already present rather than producing `a@b@host`.
let rest = rest.split_once('@').map(|(_, r)| r).unwrap_or(rest);
Authed {
url: format!("{scheme}://oauth2:{token}@{rest}"),
unauthenticated: None,
forge,
}
}
/// Git must never wait for a human.
///
/// Without this, a URL that ended up without credentials does not fail — git
/// opens `/dev/tty` to ask for a username, and in a server container that
/// surfaces as `No such device or address`, several layers away from the
/// missing token that caused it. With it, the failure names itself:
/// `terminal prompts disabled`.
pub(crate) fn no_terminal_prompt(cmd: &mut Command) -> &mut Command {
cmd.env("GIT_TERMINAL_PROMPT", "0")
} }
async fn clone(path: &std::path::Path, url: &str) -> Result<(), String> { async fn clone(path: &std::path::Path, url: &str) -> Result<(), String> {
@@ -116,14 +227,15 @@ async fn clone(path: &std::path::Path, url: &str) -> Result<(), String> {
// — so the base commit stays meaningful and a diff has something to be // — so the base commit stays meaningful and a diff has something to be
// relative to — while fetching file contents only on demand, which is // relative to — while fetching file contents only on demand, which is
// nearly as cheap as a shallow clone for a repo that gets read once. // nearly as cheap as a shallow clone for a repo that gets read once.
let out = Command::new("git") let mut cmd = Command::new("git");
.args([ cmd.args([
"clone", "clone",
"--filter=blob:none", "--filter=blob:none",
"--single-branch", "--single-branch",
url, url,
&path.display().to_string(), &path.display().to_string(),
]) ]);
let out = no_terminal_prompt(&mut cmd)
.output() .output()
.await .await
.map_err(|e| format!("spawn git clone: {e}"))?; .map_err(|e| format!("spawn git clone: {e}"))?;
@@ -131,10 +243,11 @@ async fn clone(path: &std::path::Path, url: &str) -> Result<(), String> {
return Err(format!( return Err(format!(
"git clone → exit {}: {}", "git clone → exit {}: {}",
out.status, out.status,
redact_token(&String::from_utf8_lossy(&out.stderr)) // Both ends: git prints its reason LAST, and a head-only clamp keeps
.chars() // the progress noise while dropping the answer.
.take(400) crate::evaluator_tools::clamp_output(&redact_token(&String::from_utf8_lossy(
.collect::<String>() &out.stderr
)))
)); ));
} }
share_repository_across_uids(path); share_repository_across_uids(path);
@@ -523,16 +636,15 @@ async fn fetch_and_reset(
// errors on a repo that is already complete, so it is only attempted when // errors on a repo that is already complete, so it is only attempted when
// the marker file is present. // the marker file is present.
if path.join(".git/shallow").exists() { if path.join(".git/shallow").exists() {
let deepen = Command::new("git") let mut cmd = Command::new("git");
.args([ cmd.args([
"-C", "-C",
&path.display().to_string(), &path.display().to_string(),
"fetch", "fetch",
"--unshallow", "--unshallow",
auth_url, auth_url,
]) ]);
.output() let deepen = no_terminal_prompt(&mut cmd).output().await;
.await;
match deepen { match deepen {
Ok(o) if o.status.success() => {} Ok(o) if o.status.success() => {}
Ok(o) => eprintln!( Ok(o) => eprintln!(
@@ -556,8 +668,9 @@ async fn fetch_and_reset(
// so `git fetch origin` has no credentials and fails with // so `git fetch origin` has no credentials and fails with
// "could not read Username". Building the URL here also means a rotated // "could not read Username". Building the URL here also means a rotated
// token takes effect immediately instead of at the next clone. // token takes effect immediately instead of at the next clone.
let fetch = Command::new("git") let mut cmd = Command::new("git");
.args(["-C", &path.display().to_string(), "fetch", auth_url, branch]) cmd.args(["-C", &path.display().to_string(), "fetch", auth_url, branch]);
let fetch = no_terminal_prompt(&mut cmd)
.output() .output()
.await .await
.map_err(|e| format!("spawn git fetch: {e}"))?; .map_err(|e| format!("spawn git fetch: {e}"))?;
@@ -565,10 +678,9 @@ async fn fetch_and_reset(
return Err(format!( return Err(format!(
"git fetch origin {branch} → exit {}: {}", "git fetch origin {branch} → exit {}: {}",
fetch.status, fetch.status,
redact_token(&String::from_utf8_lossy(&fetch.stderr)) crate::evaluator_tools::clamp_output(&redact_token(&String::from_utf8_lossy(
.chars() &fetch.stderr
.take(400) )))
.collect::<String>()
)); ));
} }
let reset = Command::new("git") let reset = Command::new("git")
@@ -602,6 +714,78 @@ async fn fetch_and_reset(
mod tests { mod tests {
use super::*; use super::*;
const TOK: Option<&str> = Some("secret123");
/// The forge in every shape a remote can be written. Each of these used to
/// fall out of the `strip_prefix("https://git.redclaw.dev/")` match and come
/// back unauthenticated with no log line — the fail-open found while tracing
/// #55.
#[test]
fn the_forge_is_recognised_however_the_url_is_written() {
for url in [
"https://git.redclaw.dev/o/r.git",
"http://git.redclaw.dev/o/r.git",
"https://GIT.RedClaw.dev/o/r.git",
"https://git.redclaw.dev:3000/o/r.git",
"https://oauth2:[email protected]/o/r.git",
] {
let a = auth_with_token(url, TOK);
assert!(a.is_forge(), "{url} was not recognised as the forge");
assert!(
a.unauthenticated.is_none(),
"{url} → {:?}",
a.unauthenticated
);
assert!(a.url.contains("oauth2:secret123@"), "{}", a.url);
// And exactly one set of credentials, not `old@` left behind.
assert_eq!(a.url.matches('@').count(), 1, "{}", a.url);
}
// The port and the scheme survive the rewrite — changing either would
// point the push somewhere the operator did not configure.
assert!(auth_with_token("https://git.redclaw.dev:3000/o/r.git", TOK)
.url
.contains("@git.redclaw.dev:3000/o/r.git"));
assert!(auth_with_token("http://git.redclaw.dev/o/r.git", TOK)
.url
.starts_with("http://oauth2:"));
}
/// Every path that cannot authenticate must SAY so. "Unauthenticated and
/// silent" is the shape that cost hours: the first symptom was git opening
/// /dev/tty, several layers from the cause.
#[test]
fn an_unauthenticated_url_carries_its_reason() {
let cases = [
(auth_with_token("https://git.redclaw.dev/o/r.git", None), true),
(auth_with_token("https://git.redclaw.dev/o/r.git", Some(" ")), true),
(auth_with_token("[email protected]:o/r.git", TOK), true),
(auth_with_token("ssh://[email protected]/o/r.git", TOK), true),
(auth_with_token("https://github.com/o/r.git", TOK), false),
];
for (a, is_forge) in cases {
let why = a.unauthenticated.as_deref().unwrap_or("");
assert!(!why.is_empty(), "{} came back with no reason", a.url);
assert_eq!(a.is_forge(), is_forge, "{}", a.url);
// And the URL is handed back untouched, so a caller that proceeds
// anyway (ssh keys, .netrc) still works.
assert!(!a.url.contains("secret123"), "{}", a.url);
}
}
/// A token must never be embedded in a URL for someone else's host.
#[test]
fn the_token_never_leaves_the_forge() {
for url in [
"https://github.com/o/r.git",
"https://git.redclaw.dev.evil.example/o/r.git",
"https://evil.example/git.redclaw.dev/r.git",
] {
let a = auth_with_token(url, TOK);
assert!(!a.url.contains("secret123"), "{url} → {}", a.url);
assert!(!a.is_forge(), "{url}");
}
}
/// The exclude must be idempotent — `ensure_checkout` re-runs on every /// The exclude must be idempotent — `ensure_checkout` re-runs on every
/// phase, and appending the same block each time would grow the file /// phase, and appending the same block each time would grow the file
/// without bound. /// without bound.
+103
View File
@@ -564,6 +564,109 @@ async fn a_failed_gate_publishes_to_a_wip_branch() {
); );
} }
/// #55: a mission whose checkout was re-cloned builds divergent history against
/// its OWN deterministic branch, and git rejects every push it will ever make.
/// That was terminal — the work stayed on a local branch in a directory that
/// gets reaped — and it is reachable from a retry, a container teardown, or disk
/// loss, not just from someone deleting a checkout by hand.
#[tokio::test]
async fn diverged_history_lands_on_a_new_branch_instead_of_being_lost() {
let tmp = tempfile::tempdir().unwrap();
let remote = tmp.path().join("remote.git");
std::fs::create_dir_all(&remote).unwrap();
Command::new("git")
.args(["init", "--bare", "--quiet"])
.arg(&remote)
.output()
.unwrap();
let branch = "clawmates/mission-test-dddddddd";
let url = remote.to_str().unwrap();
// The first attempt: a checkout that pushed its work and then vanished.
let first = seed_repo(tmp.path(), Uuid::now_v7());
std::fs::write(first.join("first.rs"), "fn first() {}\n").unwrap();
git(&first, &["add", "."]);
git(&first, &["commit", "--quiet", "-m", "first pass"]);
git(&first, &["checkout", "-B", branch]);
let one = mission_delivery::publish_phase_branch(
&first,
url,
branch,
mission_delivery::Gate::Always,
None,
)
.await
.unwrap();
assert!(one.pushed, "setup push failed: {:?}", one.error);
let claimed = Command::new("git")
.arg("-C")
.arg(&remote)
.args(["rev-parse", branch])
.output()
.unwrap();
let claimed = String::from_utf8_lossy(&claimed.stdout).trim().to_string();
// The retry: a fresh clone of the same mission, so unrelated history under
// the same deterministic branch name.
let second = seed_repo(tmp.path(), Uuid::now_v7());
std::fs::write(second.join("second.rs"), "fn second() {}\n").unwrap();
git(&second, &["add", "."]);
git(&second, &["commit", "--quiet", "-m", "retry"]);
git(&second, &["checkout", "-B", branch]);
let out = mission_delivery::publish_phase_branch(
&second,
url,
branch,
mission_delivery::Gate::Always,
None,
)
.await
.unwrap();
assert!(
out.pushed,
"the retry's work never reached the forge: {:?}",
out.error
);
assert!(
out.branch.starts_with(branch) && out.branch != branch,
"it must land on a NEW ref, not the contested one: {}",
out.branch
);
let refs = Command::new("git")
.arg("-C")
.arg(&remote)
.args(["for-each-ref", "--format=%(refname:short)"])
.output()
.unwrap();
let refs = String::from_utf8_lossy(&refs.stdout);
assert!(refs.contains(&out.branch), "refs: {refs}");
// NEVER force: the first attempt's ref still points where it did. Losing it
// to make this push look tidy would trade one lost copy of the work for
// another.
let still = Command::new("git")
.arg("-C")
.arg(&remote)
.args(["rev-parse", branch])
.output()
.unwrap();
assert_eq!(
String::from_utf8_lossy(&still.stdout).trim(),
claimed,
"the earlier attempt's branch was overwritten"
);
let show = Command::new("git")
.arg("-C")
.arg(&remote)
.args(["show", &format!("{}:second.rs", out.branch)])
.output()
.unwrap();
assert!(String::from_utf8_lossy(&show.stdout).contains("fn second()"));
}
/// An unreachable remote is a degraded success, not a failure: the patch and /// An unreachable remote is a degraded success, not a failure: the patch and
/// the local branch both still exist. /// the local branch both still exist.
#[tokio::test] #[tokio::test]