fabro/lib/apps/fabro-server/src/git_checkout.rs
Scott Werner 0250b40586 Publish a successful run from its run_finished hook
Pushing the run branch and opening the pull request now happen in the
run's worker, in Fabro's run_finished hook, after the last stage and
before the run's terminal record, as the legacy publish step did. A
failed push or pull request fails the run with publish_failed instead of
leaving a warning on a run that already succeeded.

fabro-petri gains a RunPublisher the hooks call for a successful run with
its run branch, final commit, snapshot repository and patch; the worker's
GitHub publisher pushes from the snapshot repository with a push token it
mints at that moment, opens the pull request its settings ask for, and
records it. The worker resolves the server's GitHub credentials itself
for both the read-only checkout token and the push token, so the server
no longer hands it a clone credential or publishes after the run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 13:00:26 -04:00

1137 lines
39 KiB
Rust

use std::borrow::Cow;
use std::path::{Path, PathBuf};
use std::time::Duration;
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use fabro_proc::ProcessError;
use fabro_store::KeyedMutex;
use fabro_types::{GitHubRepositorySlug, GitRunTarget};
use tokio::fs;
use tokio::process::Command;
use tokio_util::sync::CancellationToken;
const GIT_CLONE_TIMEOUT: Duration = Duration::from_mins(2);
const GIT_FETCH_TIMEOUT: Duration = Duration::from_mins(1);
const GIT_WORKTREE_ADD_TIMEOUT: Duration = Duration::from_secs(30);
const GIT_WORKTREE_PRUNE_TIMEOUT: Duration = Duration::from_secs(10);
const GIT_REV_PARSE_TIMEOUT: Duration = Duration::from_secs(10);
/// Error returned while preparing a checkout from a git source.
#[derive(thiserror::Error, Debug)]
pub(crate) enum GitCheckoutError {
#[error("failed to create Git cache directory {path}")]
CacheDirectory {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to clone repository")]
Clone {
#[source]
source: GitCommandError,
},
#[error("failed to fetch branch {branch:?}")]
FetchBranch {
branch: String,
#[source]
source: GitCommandError,
},
#[error("failed to fetch tag {tag:?}")]
FetchTag {
tag: String,
#[source]
source: GitCommandError,
},
#[error("failed to fetch exact commit {sha}")]
FetchCommit {
sha: String,
#[source]
source: GitCommandError,
},
#[error("failed to resolve fetched Git target to a commit")]
ResolveCommit {
#[source]
source: GitCommandError,
},
#[error("failed to add Git worktree")]
AddWorktree {
#[source]
source: GitCommandError,
},
}
#[derive(thiserror::Error, Debug)]
pub(crate) enum GitCommandError {
#[error("{command} timed out after {timeout_secs}s")]
Timeout {
command: String,
timeout_secs: u64,
},
#[error("failed to run {command}")]
Spawn {
command: String,
#[source]
source: std::io::Error,
},
#[error("{message}")]
Exit { message: String },
}
/// Persistent on-disk cache of bare GitHub clones, one per `(owner, repo)`.
///
/// Materializing an automation run only needs to read the workflow + its
/// supporting files out of the repo at a given ref. Cloning fresh on every
/// click costs 5-15s on the request thread. With this cache, the first
/// materialize for a repo pays the clone, and every subsequent one pays only
/// the delta `git fetch` plus a cheap `git worktree add` into the per-call
/// scratch dir.
#[derive(Debug)]
pub(crate) struct GitRepoCache {
cache_root: PathBuf,
locks: KeyedMutex<(String, String)>,
}
impl GitRepoCache {
pub(crate) fn new(cache_root: PathBuf) -> Self {
Self {
cache_root,
locks: KeyedMutex::new(),
}
}
fn bare_dir(&self, repo: &GitHubRepositorySlug) -> PathBuf {
self.cache_root
.join(repo.owner())
.join(format!("{}.git", repo.repo()))
}
/// Prepare a worktree containing the requested ref of `repo`, fetched from
/// `clone_url`, at
/// `worktree_dir`. Returns the resolved commit SHA.
///
/// First call for a repo: a `--bare --depth 1` clone is created at
/// `<cache>/<owner>/<repo>.git`. Subsequent calls reuse the bare clone
/// and only `git fetch --depth 1` the requested ref. In both cases a
/// short-lived worktree is added at `worktree_dir`; the caller owns its
/// lifetime (typically a `TempDir`). If stale worktree admin entries from
/// crashed prior calls block the add, the cache prunes those entries and
/// retries once.
pub(crate) async fn prepare_worktree(
&self,
args: WorktreePrepareInput<'_>,
clone_url: &str,
) -> Result<String, GitCheckoutError> {
let _guard = self
.locks
.lock((args.repo.owner().to_string(), args.repo.repo().to_string()))
.await;
let bare_dir = self.bare_dir(args.repo);
match self.try_prepare_worktree(&bare_dir, &args, clone_url).await {
Ok(sha) => Ok(sha),
Err(first_err) if bare_clone_may_be_corrupt(&bare_dir).await => {
// Best-effort wipe and one retry. Corruption is rare; auth
// and network failures don't trip this branch because
// `bare_clone_may_be_corrupt` only returns true when the
// bare repo's `HEAD` file is missing or empty.
if let Err(remove_err) = fs::remove_dir_all(&bare_dir).await {
tracing::warn!(
path = %bare_dir.display(),
error = %remove_err,
"failed to remove corrupt cached git repository"
);
return Err(first_err);
}
self.try_prepare_worktree(&bare_dir, &args, clone_url)
.await
.map_err(|_| first_err)
}
Err(err) => Err(err),
}
}
async fn try_prepare_worktree(
&self,
bare_dir: &Path,
args: &WorktreePrepareInput<'_>,
clone_url: &str,
) -> Result<String, GitCheckoutError> {
let bare_exists = fs::try_exists(&bare_dir.join("HEAD"))
.await
.unwrap_or(false);
if !bare_exists {
if let Some(parent) = bare_dir.parent() {
fs::create_dir_all(parent).await.map_err(|err| {
GitCheckoutError::CacheDirectory {
path: parent.to_path_buf(),
source: err,
}
})?;
}
run_git_plan(build_bare_clone_plan(clone_url, bare_dir, args.auth))
.await
.map_err(|source| GitCheckoutError::Clone { source })?;
}
let fetch_target = args.selector;
run_git_plan(build_bare_fetch_plan(
bare_dir,
clone_url,
&fetch_target.selector(),
args.auth,
))
.await
.map_err(|source| fetch_target.checkout_error(source))?;
let checked_out_sha = run_git_plan(build_rev_parse_fetch_head_plan(bare_dir))
.await
.map_err(|source| GitCheckoutError::ResolveCommit { source })
.map(|stdout| String::from_utf8_lossy(&stdout).trim().to_string())?;
add_worktree_with_stale_retry(bare_dir, args.worktree_dir, &checked_out_sha).await?;
Ok(checked_out_sha)
}
}
pub(crate) struct WorktreePrepareInput<'a> {
pub repo: &'a GitHubRepositorySlug,
pub selector: GitCheckoutSelector<'a>,
pub auth: Option<&'a GitAuthConfig>,
pub worktree_dir: &'a Path,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum GitCheckoutSelector<'a> {
Branch(&'a str),
Tag(&'a str),
Commit(&'a str),
}
impl<'a> From<&'a GitRunTarget> for GitCheckoutSelector<'a> {
fn from(target: &'a GitRunTarget) -> Self {
if let Some(sha) = target.sha.as_deref() {
Self::Commit(sha)
} else if let Some(tag) = target.tag.as_deref() {
Self::Tag(tag)
} else {
Self::Branch(&target.branch)
}
}
}
impl GitCheckoutSelector<'_> {
fn selector(&self) -> Cow<'_, str> {
match self {
Self::Branch(selector) | Self::Commit(selector) => Cow::Borrowed(selector),
Self::Tag(tag) => Cow::Owned(format!("refs/tags/{tag}")),
}
}
fn checkout_error(&self, source: GitCommandError) -> GitCheckoutError {
match self {
Self::Branch(branch) => GitCheckoutError::FetchBranch {
branch: (*branch).to_string(),
source,
},
Self::Tag(tag) => GitCheckoutError::FetchTag {
tag: (*tag).to_string(),
source,
},
Self::Commit(sha) => GitCheckoutError::FetchCommit {
sha: (*sha).to_string(),
source,
},
}
}
}
async fn bare_clone_may_be_corrupt(bare_dir: &Path) -> bool {
match fs::metadata(&bare_dir.join("HEAD")).await {
Ok(meta) => meta.len() == 0,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
fs::try_exists(bare_dir).await.unwrap_or(false)
}
Err(err) => {
tracing::warn!(
path = %bare_dir.join("HEAD").display(),
%err,
"Failed to inspect cached git repository HEAD"
);
false
}
}
}
pub(crate) fn github_clone_url(repo: &GitHubRepositorySlug) -> String {
let mut url = repo.https_url();
url.push_str(".git");
url
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct GitAuthConfig {
extraheader: String,
sensitive_values: Vec<String>,
}
impl GitAuthConfig {
pub(crate) fn new(credentials: &fabro_github::GitCloneCredentials) -> Self {
Self::from_parts(credentials.username(), credentials.password())
}
pub(crate) fn from_parts(username: &str, password: &str) -> Self {
let encoded_credentials = BASE64_STANDARD.encode(format!("{username}:{password}"));
let extraheader = basic_auth_header_from_encoded(&encoded_credentials);
Self {
sensitive_values: vec![
password.to_string(),
encoded_credentials,
extraheader.clone(),
],
extraheader,
}
}
fn git_env(&self, clone_url: &str) -> Vec<(String, String)> {
vec![
("GIT_CONFIG_COUNT".to_string(), "1".to_string()),
(
"GIT_CONFIG_KEY_0".to_string(),
format!("http.{clone_url}.extraheader"),
),
("GIT_CONFIG_VALUE_0".to_string(), self.extraheader.clone()),
]
}
fn sensitive_values(&self) -> &[String] {
&self.sensitive_values
}
}
pub(crate) async fn resolve_git_read_auth_config(
credentials: Option<&fabro_github::GitHubCredentials>,
repo: &GitHubRepositorySlug,
github_api_base_url: &str,
http_client: Option<fabro_http::HttpClient>,
) -> anyhow::Result<Option<GitAuthConfig>> {
let Some(credentials) = credentials else {
return Ok(None);
};
let context = match http_client {
Some(client) => {
fabro_github::GitHubContext::with_http_client(credentials, github_api_base_url, client)
}
None => fabro_github::GitHubContext::new(credentials, github_api_base_url),
};
let credentials =
fabro_github::resolve_read_only_clone_credentials(&context, repo.owner(), repo.repo())
.await?;
Ok(Some(GitAuthConfig::new(&credentials)))
}
#[cfg(test)]
fn basic_auth_header(username: &str, password: &str) -> String {
basic_auth_header_from_encoded(&BASE64_STANDARD.encode(format!("{username}:{password}")))
}
fn basic_auth_header_from_encoded(encoded_credentials: &str) -> String {
format!("AUTHORIZATION: basic {encoded_credentials}")
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct GitCommandPlan {
program: String,
args: Vec<String>,
env: Vec<(String, String)>,
current_dir: Option<PathBuf>,
timeout: Duration,
sensitive_values: Vec<String>,
}
impl GitCommandPlan {
fn new(args: impl IntoIterator<Item = impl Into<String>>, timeout: Duration) -> Self {
Self {
program: "git".to_string(),
args: args.into_iter().map(Into::into).collect(),
env: vec![("GIT_TERMINAL_PROMPT".to_string(), "0".to_string())],
current_dir: None,
timeout,
sensitive_values: Vec::new(),
}
}
fn current_dir(mut self, current_dir: impl Into<PathBuf>) -> Self {
self.current_dir = Some(current_dir.into());
self
}
fn with_auth(mut self, clone_url: &str, auth: Option<&GitAuthConfig>) -> Self {
if let Some(auth) = auth {
self.env.extend(auth.git_env(clone_url));
self.sensitive_values
.extend(auth.sensitive_values().iter().cloned());
}
self
}
#[cfg(test)]
fn env_value(&self, name: &str) -> Option<&str> {
self.env
.iter()
.find(|(key, _)| key == name)
.map(|(_, value)| value.as_str())
}
}
fn build_bare_clone_plan(
clone_url: &str,
bare_dir: &Path,
auth: Option<&GitAuthConfig>,
) -> GitCommandPlan {
GitCommandPlan::new(
[
"clone".to_string(),
"--bare".to_string(),
"--depth".to_string(),
"1".to_string(),
clone_url.to_string(),
bare_dir.display().to_string(),
],
GIT_CLONE_TIMEOUT,
)
.with_auth(clone_url, auth)
}
fn build_bare_fetch_plan(
bare_dir: &Path,
clone_url: &str,
ref_selector: &str,
auth: Option<&GitAuthConfig>,
) -> GitCommandPlan {
GitCommandPlan::new(
[
"fetch".to_string(),
"--depth".to_string(),
"1".to_string(),
"origin".to_string(),
"--".to_string(),
ref_selector.to_string(),
],
GIT_FETCH_TIMEOUT,
)
.current_dir(bare_dir)
.with_auth(clone_url, auth)
}
fn build_worktree_add_plan(bare_dir: &Path, worktree_dir: &Path, target: &str) -> GitCommandPlan {
GitCommandPlan::new(
[
"worktree".to_string(),
"add".to_string(),
"--detach".to_string(),
"--force".to_string(),
worktree_dir.display().to_string(),
target.to_string(),
],
GIT_WORKTREE_ADD_TIMEOUT,
)
.current_dir(bare_dir)
}
fn build_worktree_prune_plan(bare_dir: &Path) -> GitCommandPlan {
GitCommandPlan::new(["worktree", "prune"], GIT_WORKTREE_PRUNE_TIMEOUT).current_dir(bare_dir)
}
fn build_rev_parse_fetch_head_plan(bare_dir: &Path) -> GitCommandPlan {
GitCommandPlan::new(["rev-parse", "FETCH_HEAD^{commit}"], GIT_REV_PARSE_TIMEOUT)
.current_dir(bare_dir)
}
async fn add_worktree_with_stale_retry(
bare_dir: &Path,
worktree_dir: &Path,
target: &str,
) -> Result<(), GitCheckoutError> {
match run_git_plan(build_worktree_add_plan(bare_dir, worktree_dir, target)).await {
Ok(_) => Ok(()),
Err(first_err) => {
tracing::warn!(
error = ?first_err,
bare_dir = %bare_dir.display(),
worktree_dir = %worktree_dir.display(),
"git worktree add failed; pruning stale worktree entries and retrying"
);
if let Err(prune_err) = run_git_plan(build_worktree_prune_plan(bare_dir)).await {
tracing::warn!(
error = ?prune_err,
bare_dir = %bare_dir.display(),
"failed to prune stale git worktree entries"
);
}
run_git_plan(build_worktree_add_plan(bare_dir, worktree_dir, target))
.await
.map(|_| ())
.map_err(|source| GitCheckoutError::AddWorktree { source })
}
}
}
async fn run_git_plan(plan: GitCommandPlan) -> Result<Vec<u8>, GitCommandError> {
let mut command = Command::new(&plan.program);
command.args(&plan.args);
command.envs(plan.env.iter().map(|(key, value)| (key, value)));
if let Some(current_dir) = plan.current_dir.as_ref() {
command.current_dir(current_dir);
}
let output = fabro_proc::capture(
&mut command,
Some(plan.timeout),
&CancellationToken::new(),
None,
)
.await
.map_err(|err| match err {
ProcessError::TimedOut => GitCommandError::Timeout {
command: safe_command_label(&plan),
timeout_secs: plan.timeout.as_secs(),
},
ProcessError::Io(source) => GitCommandError::Spawn {
command: safe_command_label(&plan),
source,
},
ProcessError::Cancelled => GitCommandError::Spawn {
command: safe_command_label(&plan),
source: std::io::Error::new(std::io::ErrorKind::Interrupted, err),
},
})?
.output;
if output.status.success() {
return Ok(output.stdout);
}
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let mut message = format!(
"{} exited with status {}",
safe_command_label(&plan),
output.status
);
if !stderr.trim().is_empty() {
message.push_str(": ");
message.push_str(stderr.trim());
} else if !stdout.trim().is_empty() {
message.push_str(": ");
message.push_str(stdout.trim());
}
Err(GitCommandError::Exit {
message: redact_git_output(&message, &plan.sensitive_values),
})
}
fn safe_command_label(plan: &GitCommandPlan) -> String {
if plan.args.is_empty() {
plan.program.clone()
} else {
format!("{} {}", plan.program, plan.args.join(" "))
}
}
fn redact_git_output(text: &str, sensitive_values: &[String]) -> String {
let mut redacted = fabro_redact::redact_string(text);
for value in sensitive_values
.iter()
.map(String::as_str)
.filter(|value| !value.is_empty())
{
redacted = redacted.replace(value, "REDACTED");
}
redacted
}
#[cfg(test)]
mod tests {
#![expect(
clippy::disallowed_methods,
reason = "Git checkout unit tests build local git repositories and inspect temp files synchronously."
)]
use std::error::Error;
use std::fs;
use std::path::Path;
use tempfile::TempDir;
use tokio::{task, time};
use super::*;
fn shell_plan(script: &str, directory: &Path) -> GitCommandPlan {
GitCommandPlan {
program: "sh".to_string(),
args: vec!["-c".to_string(), script.to_string()],
env: vec![
("HOME".to_string(), directory.display().to_string()),
("GIT_CONFIG_NOSYSTEM".to_string(), "1".to_string()),
("GIT_CONFIG_GLOBAL".to_string(), "/dev/null".to_string()),
],
current_dir: Some(directory.to_path_buf()),
timeout: Duration::from_secs(5),
sensitive_values: vec![],
}
}
#[tokio::test]
async fn git_plan_capture_preserves_bytes_environment_and_diagnostics() {
let temp = TempDir::new().unwrap();
let mut plan = shell_plan(
"printf '%s' \"$TEST_CAPTURE_VALUE\"; head -c 200000 /dev/zero; printf '\\377'",
temp.path(),
);
plan.env.push((
"TEST_CAPTURE_VALUE".to_string(),
"literal $(command)".to_string(),
));
let output = run_git_plan(plan).await.unwrap();
assert!(output.starts_with(b"literal $(command)"));
assert_eq!(output.len(), 200_019);
assert_eq!(output.last(), Some(&255));
let mut plan = shell_plan(
"printf stdout; printf '%s' \"$TEST_CAPTURE_VALUE\" >&2; exit 7",
temp.path(),
);
let secret = "fixture-secret-value";
plan.env
.push(("TEST_CAPTURE_VALUE".to_string(), secret.to_string()));
plan.sensitive_values.push(secret.to_string());
let error = run_git_plan(plan).await.unwrap_err();
let message = error.to_string();
assert!(!message.contains(secret));
assert!(message.ends_with("REDACTED"));
let mut plan = shell_plan("exit 0", temp.path());
plan.program = "/nonexistent/fabro-test-git".to_string();
let error = run_git_plan(plan).await.unwrap_err();
assert!(Error::source(&error).unwrap().is::<std::io::Error>());
}
#[cfg(unix)]
#[tokio::test]
async fn git_plan_deadline_includes_held_pipe() {
struct Cleanup(TempDir);
impl Drop for Cleanup {
fn drop(&mut self) {
if let Some(pid) = fs::read_to_string(self.0.path().join("leader.pid"))
.ok()
.and_then(|value| value.trim().parse().ok())
{
fabro_proc::sigkill_process_group(pid);
}
}
}
let fixture = Cleanup(TempDir::new().unwrap());
let mut plan = shell_plan(
"echo $$ > leader.pid; sleep 60 & echo $! > helper.pid; exit 0",
fixture.0.path(),
);
plan.timeout = Duration::from_millis(200);
let error = time::timeout(Duration::from_secs(8), run_git_plan(plan))
.await
.unwrap()
.unwrap_err();
assert!(matches!(error, GitCommandError::Timeout { .. }));
let helper: u32 = fs::read_to_string(fixture.0.path().join("helper.pid"))
.unwrap()
.trim()
.parse()
.unwrap();
time::timeout(Duration::from_secs(5), async {
while task::spawn_blocking(move || fabro_proc::process_running_strict(helper))
.await
.unwrap()
{
time::sleep(Duration::from_millis(20)).await;
}
})
.await
.unwrap();
}
fn repository_slug(value: &str) -> GitHubRepositorySlug {
GitHubRepositorySlug::try_new(value).expect("slug should parse")
}
fn git_target(branch: &str, tag: Option<&str>, sha: Option<&str>) -> GitRunTarget {
GitRunTarget {
repo: "fabro-sh/fabro".to_string(),
branch: branch.to_string(),
tag: tag.map(str::to_string),
sha: sha.map(str::to_string),
}
}
fn workflow_source(branch: &str, tag: Option<&str>, sha: Option<&str>) -> GitRunTarget {
GitRunTarget {
repo: "fabro-sh/workflows".to_string(),
branch: branch.to_string(),
tag: tag.map(str::to_string),
sha: sha.map(str::to_string),
}
}
#[test]
fn checkout_selectors_use_sha_then_tag_then_branch_precedence() {
let target = git_target(
"main",
Some("v1"),
Some("abcdef0123456789abcdef0123456789abcdef01"),
);
assert_eq!(
GitCheckoutSelector::from(&target),
GitCheckoutSelector::Commit("abcdef0123456789abcdef0123456789abcdef01")
);
for (source, expected) in [
(
workflow_source("main", None, None),
GitCheckoutSelector::Branch("main"),
),
(
workflow_source("main", Some("v1"), None),
GitCheckoutSelector::Tag("v1"),
),
(
workflow_source(
"unrelated-context",
Some("v1"),
Some("abcdef0123456789abcdef0123456789abcdef01"),
),
GitCheckoutSelector::Commit("abcdef0123456789abcdef0123456789abcdef01"),
),
] {
assert_eq!(GitCheckoutSelector::from(&source), expected);
}
}
#[test]
fn checkout_reuse_identity_folds_only_repository_case() {
assert_eq!(
repository_slug("Fabro-Sh/Workflows"),
repository_slug("fabro-sh/workflows")
);
assert_ne!(
GitCheckoutSelector::Branch("Main"),
GitCheckoutSelector::Branch("main")
);
assert_ne!(
GitCheckoutSelector::Branch("v1"),
GitCheckoutSelector::Tag("v1")
);
assert_eq!(
GitCheckoutSelector::Commit("abcdef0123456789abcdef0123456789abcdef01"),
GitCheckoutSelector::Commit("abcdef0123456789abcdef0123456789abcdef01")
);
}
#[test]
fn target_repository_urls_are_github_metadata_urls_without_credentials() {
let repo = repository_slug("fabro-sh/fabro");
assert_eq!(repo.owner(), "fabro-sh");
assert_eq!(repo.repo(), "fabro");
assert_eq!(
github_clone_url(&repo),
"https://github.com/fabro-sh/fabro.git"
);
assert_eq!(repo.https_url(), "https://github.com/fabro-sh/fabro");
assert!(!github_clone_url(&repo).contains('@'));
}
#[test]
fn target_repository_validation_matches_automation_validation() {
let repo = repository_slug("owner/.github");
assert_eq!(repo.owner(), "owner");
assert_eq!(repo.repo(), ".github");
assert_eq!(repo.https_url(), "https://github.com/owner/.github");
}
#[test]
fn bare_cache_command_plans_use_argv_prompt_disable_and_timeouts() {
let repo = repository_slug("fabro-sh/fabro");
let clone_url = github_clone_url(&repo);
let temp = TempDir::new().unwrap();
let bare_dir = temp.path().join("fabro-sh/fabro.git");
let worktree_dir = temp.path().join("worktree/repo");
let clone = build_bare_clone_plan(&clone_url, &bare_dir, None);
assert_eq!(clone.program, "git");
assert_eq!(clone.args, vec![
"clone",
"--bare",
"--depth",
"1",
"https://github.com/fabro-sh/fabro.git",
bare_dir.to_str().unwrap(),
]);
assert_eq!(clone.timeout, Duration::from_mins(2));
assert_eq!(clone.env_value("GIT_TERMINAL_PROMPT"), Some("0"));
let fetch = build_bare_fetch_plan(&bare_dir, &clone_url, "feature/materialize", None);
assert_eq!(fetch.args, vec![
"fetch",
"--depth",
"1",
"origin",
"--",
"feature/materialize",
]);
assert_eq!(fetch.current_dir.as_deref(), Some(bare_dir.as_path()));
assert_eq!(fetch.timeout, Duration::from_mins(1));
assert_eq!(fetch.env_value("GIT_TERMINAL_PROMPT"), Some("0"));
let worktree = build_worktree_add_plan(
&bare_dir,
&worktree_dir,
"0123456789abcdef0123456789abcdef01234567",
);
assert_eq!(worktree.args, vec![
"worktree",
"add",
"--detach",
"--force",
worktree_dir.to_str().unwrap(),
"0123456789abcdef0123456789abcdef01234567",
]);
assert_eq!(worktree.current_dir.as_deref(), Some(bare_dir.as_path()));
assert_eq!(worktree.timeout, Duration::from_secs(30));
assert_eq!(worktree.env_value("GIT_TERMINAL_PROMPT"), Some("0"));
let prune = build_worktree_prune_plan(&bare_dir);
assert_eq!(prune.args, vec!["worktree", "prune"]);
assert_eq!(prune.current_dir.as_deref(), Some(bare_dir.as_path()));
assert_eq!(prune.timeout, Duration::from_secs(10));
let rev_parse = build_rev_parse_fetch_head_plan(&bare_dir);
assert_eq!(rev_parse.args, vec!["rev-parse", "FETCH_HEAD^{commit}"]);
assert_eq!(rev_parse.current_dir.as_deref(), Some(bare_dir.as_path()));
assert_eq!(rev_parse.timeout, Duration::from_secs(10));
}
#[test]
fn credential_redaction_removes_tokens_and_basic_auth_headers() {
let secret = "ghu_materializer_secret";
let basic = basic_auth_header("x-access-token", secret);
let message = format!(
"fatal: could not read Username for https://github.com/fabro-sh/fabro.git; token={secret}; header={basic}"
);
let redacted = redact_git_output(&message, &[secret.to_string(), basic.clone()]);
assert!(!redacted.contains(secret), "token leaked: {redacted}");
assert!(
!redacted.contains(&basic),
"basic header leaked: {redacted}"
);
let encoded_secret = BASE64_STANDARD.encode(format!("x-access-token:{secret}"));
assert!(
!redacted.contains(&encoded_secret),
"encoded credential leaked: {redacted}"
);
assert!(
redacted.contains("REDACTED"),
"expected redaction marker: {redacted}"
);
}
#[test]
fn credential_config_env_keeps_clone_url_uncredentialed() {
let repo = repository_slug("fabro-sh/fabro");
let clone_url = github_clone_url(&repo);
let auth = GitAuthConfig::from_parts("x-access-token", "ghu_secret");
let plan = build_bare_clone_plan(&clone_url, Path::new("/tmp/fabro-checkout"), Some(&auth));
assert!(
plan.args
.iter()
.any(|arg| arg == "https://github.com/fabro-sh/fabro.git")
);
assert!(plan.args.iter().all(|arg| !arg.contains("ghu_secret")));
assert_eq!(plan.env_value("GIT_CONFIG_COUNT"), Some("1"));
assert_eq!(
plan.env_value("GIT_CONFIG_KEY_0"),
Some("http.https://github.com/fabro-sh/fabro.git.extraheader")
);
assert!(
plan.env_value("GIT_CONFIG_VALUE_0")
.is_some_and(|value| value.starts_with("AUTHORIZATION: basic "))
);
}
fn seed_upstream(upstream: &Path) -> String {
std::process::Command::new("git")
.args([
"init",
"--bare",
"--initial-branch=main",
upstream.to_str().unwrap(),
])
.status()
.expect("git init --bare");
let work = upstream.parent().unwrap().join("seed");
fs::create_dir_all(&work).unwrap();
std::process::Command::new("git")
.args(["init", "--initial-branch=main", work.to_str().unwrap()])
.status()
.expect("git init seed");
let configure = |key: &str, value: &str| {
std::process::Command::new("git")
.args(["-C", work.to_str().unwrap(), "config", key, value])
.status()
.expect("git config seed");
};
configure("user.email", "test@fabro.sh");
configure("user.name", "Fabro Test");
configure("commit.gpgsign", "false");
fs::write(work.join("README.md"), "seed\n").unwrap();
std::process::Command::new("git")
.args(["-C", work.to_str().unwrap(), "add", "."])
.status()
.expect("git add seed");
std::process::Command::new("git")
.args(["-C", work.to_str().unwrap(), "commit", "-m", "seed"])
.status()
.expect("git commit seed");
std::process::Command::new("git")
.args(["-C", work.to_str().unwrap(), "tag", "-a", "v1", "-m", "v1"])
.status()
.expect("git tag seed");
std::process::Command::new("git")
.args([
"-C",
work.to_str().unwrap(),
"push",
"--follow-tags",
upstream.to_str().unwrap(),
"main",
])
.status()
.expect("git push seed");
let output = std::process::Command::new("git")
.args(["-C", work.to_str().unwrap(), "rev-parse", "HEAD"])
.output()
.expect("git rev-parse seed");
String::from_utf8(output.stdout).unwrap().trim().to_string()
}
fn objects_signature(dir: &Path) -> Vec<(String, u64)> {
let mut files = Vec::new();
let mut stack = vec![dir.join("objects")];
while let Some(path) = stack.pop() {
let Ok(iter) = fs::read_dir(&path) else {
continue;
};
for entry in iter.flatten() {
let entry_path = entry.path();
let Ok(meta) = entry.metadata() else { continue };
if meta.is_dir() {
stack.push(entry_path);
} else if meta.is_file() {
let rel = entry_path
.strip_prefix(dir.join("objects"))
.unwrap()
.to_string_lossy()
.into_owned();
files.push((rel, meta.len()));
}
}
}
files.sort();
files
}
#[tokio::test]
async fn bare_clone_reused_across_calls() {
let temp = TempDir::new().unwrap();
let upstream = temp.path().join("upstream.git");
let expected_sha = seed_upstream(&upstream);
let cache = GitRepoCache::new(temp.path().join("cache"));
let repo = repository_slug("fabro-sh/fabro");
let upstream_url = upstream.to_str().unwrap().to_string();
let target = git_target("main", None, None);
let worktree_a = temp.path().join("wt-a");
let sha_a = cache
.prepare_worktree(
WorktreePrepareInput {
repo: &repo,
selector: GitCheckoutSelector::from(&target),
auth: None,
worktree_dir: &worktree_a,
},
&upstream_url,
)
.await
.expect("first prepare_worktree");
assert_eq!(sha_a, expected_sha);
let bare_dir = cache.bare_dir(&repo);
assert!(bare_dir.join("HEAD").exists(), "bare clone should exist");
let signature_before = objects_signature(&bare_dir);
assert!(
!signature_before.is_empty(),
"expected object files after clone"
);
let worktree_b = temp.path().join("wt-b");
let sha_b = cache
.prepare_worktree(
WorktreePrepareInput {
repo: &repo,
selector: GitCheckoutSelector::from(&target),
auth: None,
worktree_dir: &worktree_b,
},
&upstream_url,
)
.await
.expect("second prepare_worktree");
assert_eq!(sha_b, expected_sha);
let signature_after = objects_signature(&bare_dir);
assert_eq!(
signature_before, signature_after,
"second call should reuse the bare clone, not re-clone",
);
}
#[tokio::test]
async fn bare_clone_recovers_from_corruption() {
let temp = TempDir::new().unwrap();
let upstream = temp.path().join("upstream.git");
let expected_sha = seed_upstream(&upstream);
let cache = GitRepoCache::new(temp.path().join("cache"));
let repo = repository_slug("fabro-sh/fabro");
let upstream_url = upstream.to_str().unwrap().to_string();
let target = git_target("main", None, None);
let worktree_a = temp.path().join("wt-a");
cache
.prepare_worktree(
WorktreePrepareInput {
repo: &repo,
selector: GitCheckoutSelector::from(&target),
auth: None,
worktree_dir: &worktree_a,
},
&upstream_url,
)
.await
.expect("first prepare_worktree");
// Simulate corruption by truncating HEAD.
let bare_dir = cache.bare_dir(&repo);
fs::write(bare_dir.join("HEAD"), "").unwrap();
let worktree_b = temp.path().join("wt-b");
let sha = cache
.prepare_worktree(
WorktreePrepareInput {
repo: &repo,
selector: GitCheckoutSelector::from(&target),
auth: None,
worktree_dir: &worktree_b,
},
&upstream_url,
)
.await
.expect("prepare_worktree should recover from corruption");
assert_eq!(sha, expected_sha);
assert!(bare_dir.join("HEAD").exists());
assert!(
!fs::read_to_string(bare_dir.join("HEAD"))
.unwrap()
.is_empty()
);
}
#[tokio::test]
async fn tag_and_exact_commit_modes_return_the_peeled_sha() {
let temp = TempDir::new().unwrap();
let upstream = temp.path().join("upstream.git");
let expected_sha = seed_upstream(&upstream);
let cache = GitRepoCache::new(temp.path().join("cache"));
let repo = repository_slug("fabro-sh/fabro");
let upstream_url = upstream.to_str().unwrap().to_string();
for (name, target) in [
("tag", git_target("main", Some("v1"), None)),
(
"pinned-tag",
git_target("main", Some("v1"), Some(&expected_sha)),
),
("commit", git_target("main", None, Some(&expected_sha))),
] {
let sha = cache
.prepare_worktree(
WorktreePrepareInput {
repo: &repo,
selector: GitCheckoutSelector::from(&target),
auth: None,
worktree_dir: &temp.path().join(name),
},
&upstream_url,
)
.await
.expect("target should materialize");
assert_eq!(sha, expected_sha, "{name}");
}
}
#[tokio::test]
async fn missing_tag_and_unavailable_commit_are_distinct_errors() {
let temp = TempDir::new().unwrap();
let upstream = temp.path().join("upstream.git");
seed_upstream(&upstream);
let cache = GitRepoCache::new(temp.path().join("cache"));
let repo = repository_slug("fabro-sh/fabro");
let upstream_url = upstream.to_str().unwrap().to_string();
let missing_tag = git_target("main", Some("missing"), None);
let unavailable_sha = "ffffffffffffffffffffffffffffffffffffffff";
let unavailable_commit = git_target("main", None, Some(unavailable_sha));
let tag_error = cache
.prepare_worktree(
WorktreePrepareInput {
repo: &repo,
selector: GitCheckoutSelector::from(&missing_tag),
auth: None,
worktree_dir: &temp.path().join("missing-tag"),
},
&upstream_url,
)
.await
.expect_err("missing tag should fail");
assert!(matches!(
tag_error,
GitCheckoutError::FetchTag { tag, .. } if tag == "missing"
));
let commit_error = cache
.prepare_worktree(
WorktreePrepareInput {
repo: &repo,
selector: GitCheckoutSelector::from(&unavailable_commit),
auth: None,
worktree_dir: &temp.path().join("missing-commit"),
},
&upstream_url,
)
.await
.expect_err("unavailable commit should fail");
assert!(matches!(
commit_error,
GitCheckoutError::FetchCommit { sha, .. } if sha == unavailable_sha
));
}
}