fabro/lib/apps/fabro-server/src/git_checkout.rs
Scott Werner cdc88b3158 Move GitHub coordinate validation to shared types
GitHub repository slug and git ref selector syntax now has one owner:
fabro-types::repository defines GitHubRepositorySlug with a try_new
constructor and the is_valid_github_ref_selector predicate.
fabro-automation keeps its public type path as a re-export of the same
type and delegates its existing parser and ref validation to the shared
grammar, preserving its exact error variants and messages. Server
checkout and materialization code imports the type from its canonical
owner. No wire, API, or behavior change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 14:53:46 -04:00

792 lines
27 KiB
Rust

use std::path::{Path, PathBuf};
use std::time::Duration;
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use fabro_store::KeyedMutex;
use fabro_types::GitHubRepositorySlug;
use tokio::process::Command;
use tokio::{fs, time};
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, Clone, PartialEq, Eq)]
pub(crate) enum GitCheckoutError {
#[error("failed to clone repository: {0}")]
CloneFailed(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` 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<'_>,
) -> Result<String, GitCheckoutError> {
let clone_url = github_clone_url(args.repo);
self.prepare_worktree_with_clone_url(args, &clone_url).await
}
async fn prepare_worktree_with_clone_url(
&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::CloneFailed(format!(
"failed to create cache dir {}: {err}",
parent.display()
))
})?;
}
run_git_plan(build_bare_clone_plan(clone_url, bare_dir, args.auth)).await?;
}
run_git_plan(build_bare_fetch_plan(
bare_dir,
clone_url,
args.ref_selector,
args.auth,
))
.await?;
let checked_out_sha = run_git_plan(build_rev_parse_fetch_head_plan(bare_dir))
.await
.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 ref_selector: &'a str,
pub auth: Option<&'a GitAuthConfig>,
pub worktree_dir: &'a Path,
}
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
}
}
}
fn github_clone_url(repo: &GitHubRepositorySlug) -> String {
format!("https://github.com/{}/{}.git", repo.owner(), repo.repo())
}
pub(crate) fn github_metadata_url(repo: &GitHubRepositorySlug) -> String {
format!("https://github.com/{}/{}", repo.owner(), repo.repo())
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct GitAuthConfig {
extraheader: Option<String>,
sensitive_values: Vec<String>,
}
impl GitAuthConfig {
fn new(username: Option<String>, password: Option<String>) -> Self {
let Some(password) = password.filter(|value| !value.is_empty()) else {
return Self {
extraheader: None,
sensitive_values: Vec::new(),
};
};
let username = username
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "x-access-token".to_string());
let encoded_credentials = BASE64_STANDARD.encode(format!("{username}:{password}"));
let extraheader = basic_auth_header_from_encoded(&encoded_credentials);
Self {
sensitive_values: vec![password, encoded_credentials, extraheader.clone()],
extraheader: Some(extraheader),
}
}
fn git_env(&self, clone_url: &str) -> Vec<(String, String)> {
let Some(extraheader) = self.extraheader.as_ref() else {
return Vec::new();
};
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(), extraheader.clone()),
]
}
fn sensitive_values(&self) -> &[String] {
&self.sensitive_values
}
}
pub(crate) async fn resolve_git_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 (username, password) =
fabro_github::resolve_clone_credentials(&context, repo.owner(), repo.repo()).await?;
Ok(Some(GitAuthConfig::new(username, password)))
}
#[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"], 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!(
%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!(
%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(|_| ())
}
}
}
async fn run_git_plan(plan: GitCommandPlan) -> Result<Vec<u8>, GitCheckoutError> {
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);
}
command.kill_on_drop(true);
let output = time::timeout(plan.timeout, command.output())
.await
.map_err(|_| {
GitCheckoutError::CloneFailed(format!(
"{} timed out after {}s",
safe_command_label(&plan),
plan.timeout.as_secs()
))
})?
.map_err(|err| {
GitCheckoutError::CloneFailed(format!(
"failed to run {}: {err}",
safe_command_label(&plan)
))
})?;
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(GitCheckoutError::CloneFailed(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::fs;
use std::path::Path;
use tempfile::TempDir;
use super::*;
fn repository_slug(value: &str) -> GitHubRepositorySlug {
GitHubRepositorySlug::try_new(value).expect("slug should parse")
}
#[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!(
github_metadata_url(&repo),
"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!(
github_metadata_url(&repo),
"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"]);
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::new(
Some("x-access-token".to_string()),
Some("ghu_secret".to_string()),
);
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(),
"push",
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 worktree_a = temp.path().join("wt-a");
let sha_a = cache
.prepare_worktree_with_clone_url(
WorktreePrepareInput {
repo: &repo,
ref_selector: "main",
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_with_clone_url(
WorktreePrepareInput {
repo: &repo,
ref_selector: "main",
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 worktree_a = temp.path().join("wt-a");
cache
.prepare_worktree_with_clone_url(
WorktreePrepareInput {
repo: &repo,
ref_selector: "main",
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_with_clone_url(
WorktreePrepareInput {
repo: &repo,
ref_selector: "main",
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()
);
}
}