diff --git a/.forgejo/workflows/mirror-sync.yaml b/.forgejo/workflows/mirror-sync.yaml new file mode 100644 index 0000000..5089005 --- /dev/null +++ b/.forgejo/workflows/mirror-sync.yaml @@ -0,0 +1,93 @@ +# Mirror Sync — Spork Strategy +# +# Keeps the 'main' branch tracking upstream (via mirror) and +# rebases the 'blumeops' branch on top. See docs/explanation/spork-strategy.md +# in the blumeops repo for the full strategy. +# +# On conflict: the workflow fails. Manual rebase resolution required. + +name: Mirror Sync + +on: + schedule: + - cron: '0 5 * * *' # Daily at 05:00 UTC + workflow_dispatch: + +jobs: + sync: + runs-on: k8s + steps: + - name: Checkout blumeops branch + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: blumeops + fetch-depth: 0 + + - name: Configure git + run: | + git config user.name "Forgejo Actions" + git config user.email "actions@forge.eblu.me" + + - name: Add mirror remote + run: | + git remote add mirror "${{ env.MIRROR_URL }}" || true + git fetch mirror + env: + MIRROR_URL: https://forge.eblu.me/mirrors/kingfisher.git + + - name: Fast-forward main from mirror + run: | + git checkout -B main origin/main + git merge --ff-only mirror/main + git push origin main + + - name: Rebase blumeops onto main + run: | + git checkout blumeops + git rebase main + git push --force-with-lease origin blumeops + + - name: Rebase feature branches + run: | + # Rebase feature/local/* onto blumeops + for branch in $(git branch -r --list 'origin/feature/local/*'); do + local_name="${branch#origin/}" + echo "Rebasing $local_name onto blumeops..." + git checkout -B "$local_name" "$branch" + git rebase blumeops || { + echo "::error::Rebase conflict on $local_name" + git rebase --abort + continue + } + git push --force-with-lease origin "$local_name" + done + + # Rebase feature/upstream/* onto main + for branch in $(git branch -r --list 'origin/feature/upstream/*'); do + local_name="${branch#origin/}" + echo "Rebasing $local_name onto main..." + git checkout -B "$local_name" "$branch" + git rebase main || { + echo "::error::Rebase conflict on $local_name" + git rebase --abort + continue + } + git push --force-with-lease origin "$local_name" + done + + - name: Build deploy branch + run: | + git checkout -B deploy blumeops + + # Merge all feature branches into deploy + for branch in $(git branch -r --list 'origin/feature/local/*' 'origin/feature/upstream/*'); do + local_name="${branch#origin/}" + echo "Merging $local_name into deploy..." + git merge --no-ff "$local_name" -m "deploy: merge $local_name" || { + echo "::error::Merge conflict on $local_name into deploy" + git merge --abort + continue + } + done + + git push --force-with-lease origin deploy diff --git a/src/cli/commands/inputs.rs b/src/cli/commands/inputs.rs index b539173..574d329 100644 --- a/src/cli/commands/inputs.rs +++ b/src/cli/commands/inputs.rs @@ -183,10 +183,6 @@ pub struct InputSpecifierArgs { )] pub gitea_api_url: Url, - /// Override base URL for cloning Gitea repositories - #[arg(long, value_hint = ValueHint::Url, hide = true)] - pub gitea_clone_url_base: Option, - #[arg(long, default_value_t = GiteaRepoType::Source, hide = true)] pub gitea_repo_type: GiteaRepoType, diff --git a/src/cli/commands/scan.rs b/src/cli/commands/scan.rs index 6f2e646..d1d4f1d 100644 --- a/src/cli/commands/scan.rs +++ b/src/cli/commands/scan.rs @@ -355,7 +355,7 @@ pub enum ScanOperation { pub enum ListRepositoriesCommand { Github { api_url: Url, specifiers: GitHubRepoSpecifiers }, Gitlab { api_url: Url, specifiers: GitLabRepoSpecifiers }, - Gitea { api_url: Url, clone_url_base: Option, specifiers: GiteaRepoSpecifiers }, + Gitea { api_url: Url, specifiers: GiteaRepoSpecifiers }, Bitbucket { api_url: Url, specifiers: BitbucketRepoSpecifiers }, Azure { base_url: Url, specifiers: AzureRepoSpecifiers }, Huggingface { specifiers: HuggingFaceRepoSpecifiers }, @@ -467,7 +467,6 @@ impl ScanCommandArgs { if args.list_only { Some(ListRepositoriesCommand::Gitea { api_url: args.api_url, - clone_url_base: args.clone_url_base, specifiers: args.specifiers, }) } else { @@ -480,8 +479,6 @@ impl ScanCommandArgs { args.specifiers.all_organizations; scan_args.input_specifier_args.gitea_repo_type = args.specifiers.repo_type; scan_args.input_specifier_args.gitea_api_url = args.api_url; - scan_args.input_specifier_args.gitea_clone_url_base = - args.clone_url_base; None } } @@ -841,15 +838,6 @@ pub struct GiteaScanArgs { value_hint = ValueHint::Url )] pub api_url: Url, - - /// Override the base URL used for cloning repositories. - /// - /// By default, clone URLs returned by the Gitea/Forgejo API are used as-is. - /// When the API is reachable at a different hostname than the git clone - /// endpoint (e.g., internal API vs. public clone URL), use this flag to - /// rewrite the scheme, host, and port of clone URLs. - #[arg(long = "clone-url-base", value_hint = ValueHint::Url)] - pub clone_url_base: Option, } #[derive(Args, Debug, Clone)] diff --git a/src/direct_validate.rs b/src/direct_validate.rs index 89d1a11..512bcee 100644 --- a/src/direct_validate.rs +++ b/src/direct_validate.rs @@ -999,7 +999,6 @@ pub(crate) fn create_minimal_scan_args() -> crate::cli::commands::scan::ScanArgs gitea_exclude: Vec::new(), all_gitea_organizations: false, gitea_api_url: Url::parse("https://gitea.com/api/v1/").unwrap(), - gitea_clone_url_base: None, gitea_repo_type: GiteaRepoType::Source, bitbucket_user: Vec::new(), bitbucket_workspace: Vec::new(), diff --git a/src/gitea.rs b/src/gitea.rs index 30b1be9..95c214a 100644 --- a/src/gitea.rs +++ b/src/gitea.rs @@ -212,7 +212,6 @@ async fn fetch_authenticated_orgs( pub async fn enumerate_repo_urls( specifiers: &RepoSpecifiers, api_url: Url, - clone_url_base: Option<&Url>, ignore_certs: bool, mut progress: Option<&mut ProgressBar>, ) -> Result> { @@ -292,14 +291,6 @@ pub async fn enumerate_repo_urls( } } - // Rewrite clone URLs if a custom base was provided. - if let Some(base) = clone_url_base { - repos = repos - .into_iter() - .map(|raw| rewrite_clone_url(&raw, base).unwrap_or(raw)) - .collect(); - } - repos.sort(); repos.dedup(); Ok(repos) @@ -307,7 +298,6 @@ pub async fn enumerate_repo_urls( pub async fn list_repositories( api_url: Url, - clone_url_base: Option<&Url>, ignore_certs: bool, progress_enabled: bool, users: &[String], @@ -334,7 +324,7 @@ pub async fn list_repositories( exclude_repos: exclude_repos.to_vec(), }; - let urls = enumerate_repo_urls(&specifiers, api_url, clone_url_base, ignore_certs, Some(&mut progress)).await?; + let urls = enumerate_repo_urls(&specifiers, api_url, ignore_certs, Some(&mut progress)).await?; for url in urls { println!("{}", url); } @@ -342,15 +332,6 @@ pub async fn list_repositories( Ok(()) } -/// Rewrite a clone URL to use a different base (scheme, host, port), preserving the path. -fn rewrite_clone_url(raw: &str, base: &Url) -> Option { - let mut parsed = Url::parse(raw).ok()?; - parsed.set_scheme(base.scheme()).ok()?; - parsed.set_host(base.host_str()).ok()?; - parsed.set_port(base.port()).ok()?; - Some(parsed.to_string()) -} - fn parse_repo(repo_url: &GitUrl) -> Option<(String, String, String)> { let url = Url::parse(repo_url.as_str()).ok()?; let host = url.host_str()?.to_string(); @@ -390,28 +371,4 @@ mod tests { fn normalize_repo_identifier_handles_git_suffix() { assert_eq!(normalize_repo_identifier("owner/repo.git"), Some("owner/repo".into())); } - - #[test] - fn rewrite_clone_url_changes_host() { - let base = Url::parse("https://forge.internal.example.com/").unwrap(); - assert_eq!( - rewrite_clone_url("https://forge.public.example.com/owner/repo.git", &base), - Some("https://forge.internal.example.com/owner/repo.git".to_string()) - ); - } - - #[test] - fn rewrite_clone_url_changes_port() { - let base = Url::parse("https://forge.example.com:3000/").unwrap(); - assert_eq!( - rewrite_clone_url("https://forge.example.com/owner/repo.git", &base), - Some("https://forge.example.com:3000/owner/repo.git".to_string()) - ); - } - - #[test] - fn rewrite_clone_url_returns_none_for_invalid_url() { - let base = Url::parse("https://forge.example.com/").unwrap(); - assert_eq!(rewrite_clone_url("not-a-url", &base), None); - } } diff --git a/src/main.rs b/src/main.rs index 65a437c..313834c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1495,10 +1495,9 @@ async fn async_main(args: CommandLineArgs, matches: clap::ArgMatches) -> Result< ) .await?; } - ListRepositoriesCommand::Gitea { api_url, clone_url_base, specifiers } => { + ListRepositoriesCommand::Gitea { api_url, specifiers } => { gitea::list_repositories( api_url, - clone_url_base.as_ref(), global_args.ignore_certs, global_args.use_progress(), &specifiers.user, @@ -1637,7 +1636,6 @@ fn create_default_scan_args() -> cli::commands::scan::ScanArgs { gitea_exclude: Vec::new(), all_gitea_organizations: false, gitea_api_url: Url::parse("https://gitea.com/api/v1/").unwrap(), - gitea_clone_url_base: None, gitea_repo_type: GiteaRepoType::Source, bitbucket_user: Vec::new(), diff --git a/src/reporter.rs b/src/reporter.rs index 6b5f2b0..5c3536d 100644 --- a/src/reporter.rs +++ b/src/reporter.rs @@ -1781,7 +1781,6 @@ mod tests { gitea_exclude: Vec::new(), all_gitea_organizations: false, gitea_api_url: Url::parse("https://gitea.com/api/v1/").unwrap(), - gitea_clone_url_base: None, gitea_repo_type: GiteaRepoType::Source, bitbucket_user: Vec::new(), bitbucket_workspace: Vec::new(), diff --git a/src/reporter/json_format.rs b/src/reporter/json_format.rs index fbbb080..323c82f 100644 --- a/src/reporter/json_format.rs +++ b/src/reporter/json_format.rs @@ -137,7 +137,6 @@ mod tests { gitea_exclude: Vec::new(), all_gitea_organizations: false, gitea_api_url: Url::parse("https://gitea.com/api/v1/").unwrap(), - gitea_clone_url_base: None, gitea_repo_type: GiteaRepoType::Source, // Bitbucket diff --git a/src/scanner/repos.rs b/src/scanner/repos.rs index 8031ab1..4e4ca8c 100644 --- a/src/scanner/repos.rs +++ b/src/scanner/repos.rs @@ -496,11 +496,9 @@ pub async fn enumerate_gitea_repos( let mut num_found: u64 = 0; let api_url = args.input_specifier_args.gitea_api_url.clone(); - let clone_url_base = args.input_specifier_args.gitea_clone_url_base.as_ref(); let repo_strings = gitea::enumerate_repo_urls( &repo_specifiers, api_url, - clone_url_base, global_args.ignore_certs, Some(&mut progress), ) diff --git a/tests/int_gitea_clone_url_base.rs b/tests/int_gitea_clone_url_base.rs deleted file mode 100644 index 6ae968e..0000000 --- a/tests/int_gitea_clone_url_base.rs +++ /dev/null @@ -1,84 +0,0 @@ -// tests/int_gitea_clone_url_base.rs -// -// Integration test: verify that --clone-url-base rewrites clone URLs -// returned by the Gitea API during repository enumeration. -// -// Uses wiremock to mock the Gitea API and assert_cmd to exercise the full -// CLI path: argument parsing → API enumeration → URL rewriting → output. - -use assert_cmd::Command; -use predicates::str::contains; -use wiremock::{ - matchers::{method, path, query_param}, - Mock, MockServer, ResponseTemplate, -}; - -/// Run `kingfisher scan gitea --list-only` against a mock Gitea API with and -/// without --clone-url-base, verifying that clone URLs are rewritten. -#[tokio::test] -async fn clone_url_base_rewrites_listed_urls() { - let mock_server = MockServer::start().await; - - let public_host = "https://forge.public.example.com"; - let repo_json = serde_json::json!([{ - "full_name": "eblume/kingfisher", - "clone_url": format!("{public_host}/eblume/kingfisher.git"), - "fork": false - }]); - - // Page 1: return one repo. - Mock::given(method("GET")) - .and(path("/api/v1/users/eblume/repos")) - .and(query_param("page", "1")) - .respond_with(ResponseTemplate::new(200).set_body_json(&repo_json)) - .mount(&mock_server) - .await; - - // Page 2: return empty array to terminate pagination. - Mock::given(method("GET")) - .and(path("/api/v1/users/eblume/repos")) - .and(query_param("page", "2")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([]))) - .mount(&mock_server) - .await; - - let api_url = format!("{}/api/v1/", mock_server.uri()); - - // WITH --clone-url-base: URLs should be rewritten. - Command::new(assert_cmd::cargo::cargo_bin!("kingfisher")) - .args([ - "scan", - "gitea", - "--api-url", - &api_url, - "--clone-url-base", - "https://forge.internal.example.com/", - "--user", - "eblume", - "--list-only", - "--no-update-check", - "--quiet", - ]) - .assert() - .success() - .stdout(contains("https://forge.internal.example.com/eblume/kingfisher.git")); - - // WITHOUT --clone-url-base: URLs should be unchanged. - Command::new(assert_cmd::cargo::cargo_bin!("kingfisher")) - .args([ - "scan", - "gitea", - "--api-url", - &api_url, - "--user", - "eblume", - "--list-only", - "--no-update-check", - "--quiet", - ]) - .assert() - .success() - .stdout(contains(&format!( - "{public_host}/eblume/kingfisher.git" - ))); -}