mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-06 02:48:25 +00:00
refactor(server): extract reusable git-checkout/materialization core (#523)
## What Foundational refactor toward running a workflow that lives in one repo against a *different* workspace repo (shared / external workflows). No public API surface and no behavior change for automations — it only reshapes internals behind a reusable seam. - **New `git_checkout` module.** Lifts the git-clone + manifest-from-checkout machinery out of `automation_materializer`: `GitRepoCache` (cached bare clone + per-call worktree), the git command plans, credential resolution/redaction, and GitHub owner/repo slug parsing/validation. All `pub(crate)`; no module is exported. - **Split the workflow source from the git context.** `build_manifest_from_checkout` now takes the *workflow-source checkout* (which workflow to bundle) and the *git context* (which repo the run clones and executes in) as separate inputs. Automations are the case where both coincide. This is the seam a future external-workflow resolver needs. - **Decoupled the builder input.** `ManifestFromCheckoutInput` no longer embeds `AutomationRunMaterializeInput`; it takes only the fields it needs plus a caller-supplied error context, so it's reusable without automation-specific types. ## Review fixes folded in - **Error type points the right way.** The shared materialize error moved into `git_checkout` as the provider-neutral `RunMaterializeError` (same variants, neutral messages). The foundation module no longer depends back on its consumer, and a bad workflow-source slug no longer reports "invalid automation target". - **Required git context, not `Option`.** No caller omits it today; widening to optional later is backwards-compatible if a real case appears. ## Testing - `cargo build -p fabro-server`, pinned-nightly `fmt --all` and `clippy -p fabro-server --all-targets -D warnings`: clean. - `cargo nextest run -p fabro-server`: 729/732 pass. The 3 failures are graphviz SVG-render-subprocess tests (`get_graph_returns_svg`, `render_graph_from_manifest_*`) that fail identically on the clean baseline in this environment — pre-existing and unrelated. - The rewritten unit test proves the split: a manifest built from a workflow-source checkout while `manifest.git` points at a *different* repo and ref. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b6ecbe20a9
commit
274203bfda
6 changed files with 890 additions and 838 deletions
|
|
@ -7,6 +7,7 @@ pub use error::{AutomationStoreError, AutomationValidationError};
|
|||
pub use id::{AutomationId, AutomationRevision, AutomationRevisionParseError, AutomationTriggerId};
|
||||
pub use model::{
|
||||
ApiTrigger, Automation, AutomationDraft, AutomationReplace, AutomationTarget,
|
||||
AutomationTrigger, ScheduleTrigger, parse_schedule_expression,
|
||||
AutomationTrigger, GitHubRepositorySlug, ScheduleTrigger, parse_github_repository_slug,
|
||||
parse_schedule_expression,
|
||||
};
|
||||
pub use store::AutomationStore;
|
||||
|
|
|
|||
|
|
@ -137,6 +137,24 @@ pub struct AutomationTarget {
|
|||
pub workflow: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GitHubRepositorySlug {
|
||||
owner: String,
|
||||
repo: String,
|
||||
}
|
||||
|
||||
impl GitHubRepositorySlug {
|
||||
#[must_use]
|
||||
pub fn owner(&self) -> &str {
|
||||
&self.owner
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn repo(&self) -> &str {
|
||||
&self.repo
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum AutomationTrigger {
|
||||
|
|
@ -273,7 +291,9 @@ fn validate_fields(value: &AutomationReplace) -> Result<(), AutomationValidation
|
|||
validate_triggers(&value.triggers)
|
||||
}
|
||||
|
||||
fn validate_repository_slug(value: &str) -> Result<(), AutomationValidationError> {
|
||||
pub fn parse_github_repository_slug(
|
||||
value: &str,
|
||||
) -> Result<GitHubRepositorySlug, AutomationValidationError> {
|
||||
let Some((owner, repo)) = value.split_once('/') else {
|
||||
return Err(AutomationValidationError::InvalidRepositorySlug {
|
||||
value: value.to_string(),
|
||||
|
|
@ -284,7 +304,14 @@ fn validate_repository_slug(value: &str) -> Result<(), AutomationValidationError
|
|||
value: value.to_string(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
Ok(GitHubRepositorySlug {
|
||||
owner: owner.to_string(),
|
||||
repo: repo.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_repository_slug(value: &str) -> Result<(), AutomationValidationError> {
|
||||
parse_github_repository_slug(value).map(|_| ())
|
||||
}
|
||||
|
||||
fn valid_github_owner(value: &str) -> bool {
|
||||
|
|
@ -515,6 +542,15 @@ enabled = true
|
|||
assert_eq!(trigger_ids, vec!["nightly"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repository_slug_parser_returns_validated_parts() {
|
||||
let slug = crate::parse_github_repository_slug("owner/.github").unwrap();
|
||||
|
||||
assert_eq!(slug.owner(), "owner");
|
||||
assert_eq!(slug.repo(), ".github");
|
||||
assert!(crate::parse_github_repository_slug("not/github/slug").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validation_rejects_invalid_inputs() {
|
||||
let cases = [
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
768
lib/crates/fabro-server/src/git_checkout.rs
Normal file
768
lib/crates/fabro-server/src/git_checkout.rs
Normal file
|
|
@ -0,0 +1,768 @@
|
|||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||
use fabro_automation::GitHubRepositorySlug;
|
||||
use fabro_store::KeyedMutex;
|
||||
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("invalid repository target: {0}")]
|
||||
InvalidTarget(String),
|
||||
#[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`). Stale worktree admin entries from
|
||||
/// crashed prior calls are pruned at the start of each call so they do
|
||||
/// not accumulate.
|
||||
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.
|
||||
let _ = fs::remove_dir_all(&bare_dir).await;
|
||||
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 {
|
||||
// Drop any worktree admin entries whose working trees were
|
||||
// deleted by previous `TempDir` cleanup. Cheap and idempotent.
|
||||
let _ = run_git_plan(build_worktree_prune_plan(bare_dir)).await;
|
||||
} else {
|
||||
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())?;
|
||||
|
||||
run_git_plan(build_worktree_add_plan(bare_dir, args.worktree_dir)).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(_) => bare_dir.exists(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn parse_github_repository_slug(
|
||||
value: &str,
|
||||
) -> Result<GitHubRepositorySlug, GitCheckoutError> {
|
||||
fabro_automation::parse_github_repository_slug(value)
|
||||
.map_err(|err| GitCheckoutError::InvalidTarget(err.to_string()))
|
||||
}
|
||||
|
||||
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) -> GitCommandPlan {
|
||||
GitCommandPlan::new(
|
||||
[
|
||||
"worktree".to_string(),
|
||||
"add".to_string(),
|
||||
"--detach".to_string(),
|
||||
"--force".to_string(),
|
||||
worktree_dir.display().to_string(),
|
||||
"FETCH_HEAD".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 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::*;
|
||||
|
||||
#[test]
|
||||
fn target_repository_urls_are_github_metadata_urls_without_credentials() {
|
||||
let repo = parse_github_repository_slug("fabro-sh/fabro").expect("slug should parse");
|
||||
|
||||
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_rejects_non_github_owner_repo_shapes() {
|
||||
for value in [
|
||||
"fabro-sh",
|
||||
"https://github.com/fabro-sh/fabro",
|
||||
"fabro-sh/fabro/extra",
|
||||
"-owner/repo",
|
||||
] {
|
||||
let error = parse_github_repository_slug(value).expect_err("invalid slug should fail");
|
||||
assert!(
|
||||
error.to_string().contains("invalid repository target"),
|
||||
"unexpected error for {value}: {error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_repository_validation_matches_automation_validation() {
|
||||
let repo = parse_github_repository_slug("owner/.github").expect("slug should parse");
|
||||
|
||||
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 = parse_github_repository_slug("fabro-sh/fabro").unwrap();
|
||||
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);
|
||||
assert_eq!(worktree.args, vec![
|
||||
"worktree",
|
||||
"add",
|
||||
"--detach",
|
||||
"--force",
|
||||
worktree_dir.to_str().unwrap(),
|
||||
"FETCH_HEAD",
|
||||
]);
|
||||
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 = parse_github_repository_slug("fabro-sh/fabro").unwrap();
|
||||
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 = parse_github_repository_slug("fabro-sh/fabro").unwrap();
|
||||
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 = parse_github_repository_slug("fabro-sh/fabro").unwrap();
|
||||
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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -25,6 +25,7 @@ pub mod csp;
|
|||
mod demo;
|
||||
pub mod diagnostics;
|
||||
pub mod error;
|
||||
mod git_checkout;
|
||||
pub mod github_webhooks;
|
||||
pub mod install;
|
||||
mod interp;
|
||||
|
|
|
|||
|
|
@ -138,11 +138,12 @@ use ulid::Ulid;
|
|||
|
||||
use crate::auth::{self, GithubEndpoints, auth_translation_middleware, demo_routing_middleware};
|
||||
use crate::automation_materializer::{
|
||||
AutomationRunMaterializeError, AutomationRunMaterializeInput, AutomationRunMaterialized,
|
||||
AutomationRunMaterializer, GitRepoCache, ProductionAutomationRunMaterializer,
|
||||
AutomationRunMaterializeInput, AutomationRunMaterialized, AutomationRunMaterializer,
|
||||
ProductionAutomationRunMaterializer, RunMaterializeError,
|
||||
};
|
||||
use crate::canonical_origin::{canonical_origin_from_effective_web_url, effective_web_url};
|
||||
use crate::error::ApiError;
|
||||
use crate::git_checkout::GitRepoCache;
|
||||
use crate::github_webhooks::{
|
||||
WEBHOOK_ROUTE, WEBHOOK_SECRET_ENV, parse_event_metadata, verify_signature,
|
||||
};
|
||||
|
|
@ -1118,7 +1119,7 @@ impl AppState {
|
|||
pub(crate) async fn materialize_automation_run(
|
||||
&self,
|
||||
input: AutomationRunMaterializeInput,
|
||||
) -> Result<AutomationRunMaterialized, AutomationRunMaterializeError> {
|
||||
) -> Result<AutomationRunMaterialized, RunMaterializeError> {
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
if let Some(materializer) = self.automation_materializer_override.as_ref() {
|
||||
return materializer.materialize(input).await;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue