From bd7a255cb4f8cb48fe0160980dd33f6dc2601b77 Mon Sep 17 00:00:00 2001 From: denkhaus <489608+denkhaus@users.noreply.github.com> Date: Fri, 21 Aug 2026 18:00:40 +0200 Subject: [PATCH] Resolve insteadOf rewrites in the rewind/fork origin guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rewind/fork guard compared the normalized raw origin URL with the run spec's repository URL. Checkouts that store their origin in rewritten form (SSH host aliases for account separation via url..insteadOf) never match the canonical URL the run spec stores, so rewind and fork were refused from those checkouts. The guard now builds every URL the raw origin can denote — itself, git's forward insteadOf rewrite, and the inverse rewrite that recovers the pre-rewrite form — and accepts the operation when any candidate matches after normalization. Reads all insteadOf values per replacement key from the full config cascade, like git does. Verified against a live checkout with a denkhaus.github.com alias origin and multi-value insteadOf rewrites: rewind passes the guard without the empty-HOME workaround. --- lib/apps/fabro-cli/src/shared/repo.rs | 151 +++++++++++++++++++++++++- 1 file changed, 148 insertions(+), 3 deletions(-) diff --git a/lib/apps/fabro-cli/src/shared/repo.rs b/lib/apps/fabro-cli/src/shared/repo.rs index 44dd0ad99..28f185e74 100644 --- a/lib/apps/fabro-cli/src/shared/repo.rs +++ b/lib/apps/fabro-cli/src/shared/repo.rs @@ -1,5 +1,8 @@ +use std::path::Path; + use anyhow::{Result, bail}; use fabro_sandbox::daytona::detect_repo_info; +use git2::Repository; pub(crate) fn ensure_matching_repo_origin( expected_origin_url: Option<&str>, @@ -15,9 +18,9 @@ pub(crate) fn ensure_matching_repo_origin( "Current directory is not a git repository with an origin remote; refusing to {action} run from repository '{expected_origin_url}'" ) })?; - let current_origin_url = fabro_github::normalize_repo_origin_url(&origin_url); - if current_origin_url != expected_origin_url { + if !origin_matches_expected(&origin_url, expected_origin_url, &cwd) { + let current_origin_url = fabro_github::normalize_repo_origin_url(&origin_url); bail!( "Current repository origin '{current_origin_url}' does not match run repository '{expected_origin_url}'; refusing to {action} this run from the wrong checkout" ); @@ -26,12 +29,154 @@ pub(crate) fn ensure_matching_repo_origin( Ok(()) } +/// Whether the raw local `origin` URL denotes the same repository as +/// `expected`, honoring `url..insteadOf` config rewrites. +/// +/// A checkout can store its origin in rewritten form (for example an SSH host +/// alias used for account separation) while the run spec stores the canonical +/// URL, or the other way around. Both rewrite directions are therefore +/// compared, after normalization, before the guard rejects the operation. +fn origin_matches_expected(raw_origin: &str, expected: &str, repo_path: &Path) -> bool { + let expected_normalized = fabro_github::normalize_repo_origin_url(expected); + rewrite_candidates( + raw_origin, + &insteadof_rewrites(repo_path).unwrap_or_default(), + ) + .iter() + .any(|candidate| fabro_github::normalize_repo_origin_url(candidate) == expected_normalized) +} + +/// Every URL the raw origin can denote: itself, git's forward `insteadOf` +/// rewrite, and the inverse rewrite that recovers the pre-rewrite form for +/// origins stored in rewritten form. +fn rewrite_candidates(raw_origin: &str, rewrites: &[(String, String)]) -> Vec { + let mut candidates = vec![raw_origin.to_string()]; + for (replacement, matcher) in rewrites { + if let Some(rest) = raw_origin.strip_prefix(matcher.as_str()) { + candidates.push(format!("{replacement}{rest}")); + } + if let Some(rest) = raw_origin.strip_prefix(replacement.as_str()) { + candidates.push(format!("{matcher}{rest}")); + } + } + candidates +} + +/// `url..insteadOf` pairs visible from `repo_path`, following +/// git's config cascade (repo-local, global, system). Git allows multiple +/// `insteadOf` values per replacement key; each becomes its own pair. +fn insteadof_rewrites(repo_path: &Path) -> Option> { + let repo = Repository::discover(repo_path).ok()?; + let config = repo.config().ok()?; + let mut entries = config.entries(Some("url.*.insteadof")).ok()?; + let mut pairs = Vec::new(); + while let Some(entry) = entries.next() { + let entry = entry.ok()?; + let Some(name) = entry.name() else { + continue; + }; + let name = name.to_ascii_lowercase(); + let Some(replacement) = name + .strip_prefix("url.") + .and_then(|rest| rest.strip_suffix(".insteadof")) + else { + continue; + }; + if let Some(value) = entry.value() { + pairs.push((replacement.to_string(), value.to_string())); + } + } + Some(pairs) +} + #[cfg(test)] mod tests { - use super::ensure_matching_repo_origin; + use super::{ensure_matching_repo_origin, rewrite_candidates}; #[test] fn missing_expected_origin_skips_guard() { ensure_matching_repo_origin(None, "fork").unwrap(); } + + #[test] + fn candidates_keep_the_raw_url() { + let candidates = rewrite_candidates("https://example.com/owner/repo.git", &[]); + assert_eq!(candidates, vec!["https://example.com/owner/repo.git"]); + } + + #[test] + fn candidates_recover_canonical_form_from_alias_origin() { + // Origin stored in rewritten form: the inverse rewrite recovers the + // canonical URL the run spec stores. + let rewrites = vec![( + "git@denkhaus.github.com:denkhaus/".to_string(), + "https://github.com/denkhaus/".to_string(), + )]; + let candidates = + rewrite_candidates("git@denkhaus.github.com:denkhaus/fabro.git", &rewrites); + assert!(candidates.contains(&"https://github.com/denkhaus/fabro.git".to_string())); + } + + #[test] + fn candidates_apply_forward_rewrite_to_canonical_origin() { + // Origin stored canonically: git's forward rewrite yields the alias + // form actually used for fetches and pushes. + let rewrites = vec![( + "git@denkhaus.github.com:denkhaus/".to_string(), + "git@github.com:denkhaus/".to_string(), + )]; + let candidates = rewrite_candidates("git@github.com:denkhaus/fabro.git", &rewrites); + assert!(candidates.contains(&"git@denkhaus.github.com:denkhaus/fabro.git".to_string())); + } + + #[test] + fn insteadof_rewrites_reads_git_config_cascade() { + let dir = tempfile::tempdir().expect("tempdir"); + let repo = git2::Repository::init(dir.path()).expect("init repo"); + let mut config = repo.config().expect("repo config"); + config + .set_str( + "url.git@denkhaus.github.com:denkhaus/.insteadOf", + "https://github.com/denkhaus/", + ) + .expect("set insteadOf"); + config + .set_multivar( + "url.git@denkhaus.github.com:denkhaus/.insteadOf", + "^$", + "git@github.com:denkhaus/", + ) + .expect("set second insteadOf value"); + + let pairs = super::insteadof_rewrites(dir.path()) + .expect("rewrites should be readable from the repo config"); + assert!(pairs.contains(&( + "git@denkhaus.github.com:denkhaus/".to_string(), + "https://github.com/denkhaus/".to_string(), + ))); + assert!(pairs.contains(&( + "git@denkhaus.github.com:denkhaus/".to_string(), + "git@github.com:denkhaus/".to_string(), + ))); + } + + #[test] + fn candidates_use_every_insteadof_value_of_one_replacement() { + // Git allows several insteadOf values per replacement; each match + // pattern must produce its own inverse candidate. + let rewrites = vec![ + ( + "git@denkhaus.github.com:denkhaus/".to_string(), + "https://github.com/denkhaus/".to_string(), + ), + ( + "git@denkhaus.github.com:denkhaus/".to_string(), + "git@github.com:denkhaus/".to_string(), + ), + ]; + let candidates = + rewrite_candidates("git@denkhaus.github.com:denkhaus/fabro.git", &rewrites); + assert!(candidates.contains(&"https://github.com/denkhaus/fabro.git".to_string())); + assert!(candidates.contains(&"git@github.com:denkhaus/fabro.git".to_string())); + } }