diff --git a/docs/site/docs-en/configuration/config-file.md b/docs/site/docs-en/configuration/config-file.md index 52a9c2b6..0ad7471d 100644 --- a/docs/site/docs-en/configuration/config-file.md +++ b/docs/site/docs-en/configuration/config-file.md @@ -189,7 +189,7 @@ Global settings that apply to all packages. | `releaseCommitMode` | string | `"commit"` | How to handle the release commit: `"commit"`, `"pr"`, or `"none"` | | `releaseCommitScope` | string | `"grouped"` | In a monorepo where several packages are bumped at once, whether to create a single `"grouped"` commit or one commit `"per-package"`. Only matters when multiple packages bump. | | `releaseCommitBody` | string | `"none"` | What goes in the body of the release commit, below the subject. `"none"` keeps the single-line subject. `"summary"` lists one line per released package with its commit count. `"full"` embeds the changelog section written for each package — under `"grouped"` scope each section is headed `## `. | -| `forge` | string | `"auto"` | Git forge override: `"auto"` detects from the remote URL, and for an unrecognised host it probes the API over HTTPS to auto-detect a self-hosted **GitLab**, **GitHub Enterprise**, or **Gitea / Forgejo** instance (cached, ~2s, best-effort). Set `"github"`, `"gitlab"`, `"gitea"` (Gitea / Forgejo / Codeberg), or `"bitbucket"` (Bitbucket Cloud) to force a forge — needed only when the host isn't reachable over HTTPS or you want to skip probing. Gitea auth uses `GITEA_TOKEN` / `FORGEJO_TOKEN`; Bitbucket uses `BITBUCKET_TOKEN`. All cover release creation — on Bitbucket, which has no release object, the release is the annotated tag FerrFlow pushes. PR mode is GitHub/GitLab only. | +| `forge` | string | `"auto"` | Git forge override: `"auto"` detects from the remote URL, and for an unrecognised host it probes the API over HTTPS to auto-detect a self-hosted **GitLab**, **GitHub Enterprise**, or **Gitea / Forgejo** instance (cached, ~2s, best-effort). The same answer picks the credential git pushes with, so a self-hosted GitLab pushes with `GITLAB_TOKEN` whatever its host name. Set `"github"`, `"gitlab"`, `"gitea"` (Gitea / Forgejo / Codeberg), or `"bitbucket"` (Bitbucket Cloud) to force a forge — needed only when the host isn't reachable over HTTPS or you want to skip probing. Gitea auth uses `GITEA_TOKEN` / `FORGEJO_TOKEN`; Bitbucket uses `BITBUCKET_TOKEN`. All cover release creation — on Bitbucket, which has no release object, the release is the annotated tag FerrFlow pushes. PR mode is GitHub/GitLab only. | | `skipCi` | boolean | depends on mode | Add `[skip ci]` to release commits. Defaults to `true` when mode is `"commit"`, `false` otherwise. | | `commitSkipMarkers` | array | `["[skip ci]", "[ci skip]", "[no ci]", "[skip actions]", "[actions skip]"]` | Markers that cause FerrFlow to skip a commit when computing the next version. Matched case-insensitively, subject line only. | | `commitFormats` | object | permissive conventional | Which commit subjects map to which bump level. Each of `major` / `minor` / `patch` takes a pattern string, a list of patterns, or `"all"` as a catch-all; `*` matches any run of characters (including `/`) and `?` exactly one. Resolution is major → minor → patch, first match wins. `caseSensitive` (default `true`) lowercases both sides when false. Defaults also accept capitalised and slash-separated variants (`Feat:`, `feat/`, `feature:`, `Fix/`, `Perf:`, `Refactor/`, and so on), listed in full under [permissive defaults](/docs/reference/conventional-commits). Breaking markers (`feat!:`, `fix(api)!:`, a `BREAKING CHANGE:` footer) are always detected regardless of what is configured. | diff --git a/docs/site/docs-fr/configuration/config-file.md b/docs/site/docs-fr/configuration/config-file.md index 33a8a4e9..1ecafbd1 100644 --- a/docs/site/docs-fr/configuration/config-file.md +++ b/docs/site/docs-fr/configuration/config-file.md @@ -189,7 +189,7 @@ Paramètres globaux qui s'appliquent à tous les packages. | `releaseCommitMode` | string | `"commit"` | Gestion du commit de release : `"commit"`, `"pr"` ou `"none"` | | `releaseCommitScope` | string | `"grouped"` | Dans un monorepo où plusieurs packages sont bumpés en même temps, créer un seul commit `"grouped"` ou un commit `"per-package"`. N'a d'effet que quand plusieurs packages bumpent. | | `releaseCommitBody` | string | `"none"` | Ce que contient le corps du commit de release, sous la ligne de sujet. `"none"` conserve le sujet sur une seule ligne. `"summary"` liste une ligne par package publié avec son nombre de commits. `"full"` intègre la section de changelog écrite pour chaque package — en scope `"grouped"`, chaque section est titrée `## `. | -| `forge` | string | `"auto"` | Forçage du forge git : `"auto"` détecte depuis l'URL du remote, et pour un hôte non reconnu il sonde l'API en HTTPS pour auto-détecter une instance auto-hébergée de **GitLab**, **GitHub Enterprise** ou **Gitea / Forgejo** (mis en cache, ~2s, best-effort). Renseignez `"github"`, `"gitlab"`, `"gitea"` (Gitea / Forgejo / Codeberg) ou `"bitbucket"` (Bitbucket Cloud) pour forcer un forge — nécessaire uniquement si l'hôte n'est pas joignable en HTTPS ou pour éviter le sondage. L'auth Gitea utilise `GITEA_TOKEN` / `FORGEJO_TOKEN` ; Bitbucket utilise `BITBUCKET_TOKEN`. Tous couvrent la création de release — sur Bitbucket, qui n'a pas d'objet release, la release est le tag annoté que FerrFlow pousse. Le mode PR reste GitHub/GitLab. | +| `forge` | string | `"auto"` | Forçage du forge git : `"auto"` détecte depuis l'URL du remote, et pour un hôte non reconnu il sonde l'API en HTTPS pour auto-détecter une instance auto-hébergée de **GitLab**, **GitHub Enterprise** ou **Gitea / Forgejo** (mis en cache, ~2s, best-effort). Ce même résultat choisit l'identifiant avec lequel git pousse : une instance GitLab auto-hébergée pousse avec `GITLAB_TOKEN`, quel que soit son nom d'hôte. Renseignez `"github"`, `"gitlab"`, `"gitea"` (Gitea / Forgejo / Codeberg) ou `"bitbucket"` (Bitbucket Cloud) pour forcer un forge — nécessaire uniquement si l'hôte n'est pas joignable en HTTPS ou pour éviter le sondage. L'auth Gitea utilise `GITEA_TOKEN` / `FORGEJO_TOKEN` ; Bitbucket utilise `BITBUCKET_TOKEN`. Tous couvrent la création de release — sur Bitbucket, qui n'a pas d'objet release, la release est le tag annoté que FerrFlow pousse. Le mode PR reste GitHub/GitLab. | | `skipCi` | boolean | dépend du mode | Ajouter `[skip ci]` aux commits de release. Par défaut `true` en mode `"commit"`, `false` sinon. | | `commitSkipMarkers` | array | `["[skip ci]", "[ci skip]", "[no ci]", "[skip actions]", "[actions skip]"]` | Marqueurs qui font ignorer un commit par FerrFlow lors du calcul de la prochaine version. Comparaison insensible à la casse, sur la ligne de sujet uniquement. | | `commitFormats` | object | conventionnel permissif | Quels sujets de commit correspondent à quel niveau de bump. Chacun de `major` / `minor` / `patch` accepte un motif, une liste de motifs, ou `"all"` comme fourre-tout ; `*` correspond à n'importe quelle suite de caractères (y compris `/`) et `?` à exactement un. Résolution : major → minor → patch, premier motif gagnant. `caseSensitive` (défaut `true`) met les deux côtés en minuscules quand il vaut `false`. Les défauts acceptent aussi les variantes capitalisées et séparées par une barre oblique (`Feat:`, `feat/`, `feature:`, `Fix/`, `Perf:`, `Refactor/`, etc.), listées en entier dans [défauts permissifs](/fr/docs/reference/conventional-commits). Les marqueurs de rupture (`feat!:`, `fix(api)!:`, un pied de page `BREAKING CHANGE:`) sont toujours détectés quelle que soit la configuration. | diff --git a/src/git/auth.rs b/src/git/auth.rs index 1f9b4ed5..9aba3be4 100644 --- a/src/git/auth.rs +++ b/src/git/auth.rs @@ -1,6 +1,7 @@ use std::process::Command; use super::repo::Repository; +use crate::config::ForgeKind; use crate::forge::gitlab::GitLabToken; #[cfg(test)] @@ -32,24 +33,36 @@ fn is_gitlab(url: &str) -> bool { host_of(url).is_some_and(|host| host.contains("gitlab")) } -pub(super) fn token_for_url(url: &str) -> Option<(String, String)> { - if let Ok(token) = std::env::var("FERRFLOW_TOKEN") { - let user = if is_gitlab(url) { +fn forge_of(url: &str, configured: ForgeKind) -> Option { + match configured { + ForgeKind::Auto if is_gitlab(url) => Some(ForgeKind::Gitlab), + ForgeKind::Auto => crate::forge::detect_forge_with_probe(url), + explicit => Some(explicit), + } +} + +pub(super) fn token_for_url(url: &str, forge: ForgeKind) -> Option<(String, String)> { + let ferrflow_token = std::env::var("FERRFLOW_TOKEN").ok(); + let gitlab_token = std::env::var("GITLAB_TOKEN").ok(); + let github_token = std::env::var("GITHUB_TOKEN").ok(); + if ferrflow_token.is_none() && gitlab_token.is_none() && github_token.is_none() { + return None; + } + let is_gitlab = forge_of(url, forge) == Some(ForgeKind::Gitlab); + if let Some(token) = ferrflow_token { + let user = if is_gitlab { GitLabToken::of(&token).git_username() } else { "x-access-token" }; return Some((user.to_string(), token)); } - if is_gitlab(url) { - if let Ok(token) = std::env::var("GITLAB_TOKEN") { - let user = GitLabToken::of(&token).git_username(); - return Some((user.to_string(), token)); - } - } else if let Ok(token) = std::env::var("GITHUB_TOKEN") { - return Some(("x-access-token".to_string(), token)); + if is_gitlab { + let token = gitlab_token?; + let user = GitLabToken::of(&token).git_username(); + return Some((user.to_string(), token)); } - None + github_token.map(|token| ("x-access-token".to_string(), token)) } /// Where the credential reaches git. `/proc//cmdline` is world-readable, @@ -68,9 +81,9 @@ pub(super) const GIT_PASSWORD_VAR: &str = "FERRFLOW_GIT_PASSWORD"; const CREDENTIAL_HELPER: &str = r#"!f() { echo "username=$FERRFLOW_GIT_USER"; echo "password=$FERRFLOW_GIT_PASSWORD"; }; f"#; -pub(super) fn configure_git_command(cmd: &mut Command, url: &str) { +pub(super) fn configure_git_command(cmd: &mut Command, url: &str, forge: ForgeKind) { scrub_trace_env(cmd); - if let Some((user, token)) = token_for_url(url) { + if let Some((user, token)) = token_for_url(url, forge) { cmd.env(GIT_USER_VAR, user); cmd.env(GIT_PASSWORD_VAR, token); cmd.arg("-c") diff --git a/src/git/fetch.rs b/src/git/fetch.rs index a4d9c041..a9a0835c 100644 --- a/src/git/fetch.rs +++ b/src/git/fetch.rs @@ -1,9 +1,14 @@ use anyhow::{Context, Result, anyhow}; use super::auth::{configure_git_command, get_remote_url}; +use super::remote::Remote; use super::repo::Repository; -pub fn fetch_tags(repo: &Repository, remote_name: &str) -> Result<()> { +pub fn fetch_tags(repo: &Repository, remote: Remote<'_>) -> Result<()> { + let Remote { + name: remote_name, + forge, + } = remote; let workdir = repo .workdir() .ok_or_else(|| anyhow!("Bare repositories are not supported"))?; @@ -12,7 +17,7 @@ pub fn fetch_tags(repo: &Repository, remote_name: &str) -> Result<()> { let mut cmd = std::process::Command::new("git"); cmd.current_dir(workdir); - configure_git_command(&mut cmd, &url); + configure_git_command(&mut cmd, &url, forge); cmd.args(["fetch", "--tags", remote_name]); let output = cmd diff --git a/src/git/mod.rs b/src/git/mod.rs index 5bced1a3..c13adae9 100644 --- a/src/git/mod.rs +++ b/src/git/mod.rs @@ -5,6 +5,7 @@ mod commits; mod diff; mod fetch; mod push; +mod remote; mod repo; mod retry; mod shell; @@ -32,6 +33,7 @@ pub use push::{ delete_tag_if_unchanged, force_push_branch, force_push_tags, push, push_tags, release_branch_foreign_commit, reset_branch_to_remote, revert_commit, verify_remote_branch, }; +pub use remote::Remote; pub use repo::{Repository, get_repo_root, open_repo, resolve_current_branch}; pub use retry::is_push_rejected_error; pub use tags::{ diff --git a/src/git/push.rs b/src/git/push.rs index b33e97f3..cc60fc54 100644 --- a/src/git/push.rs +++ b/src/git/push.rs @@ -6,9 +6,11 @@ use std::path::Path; use crate::error_code::{self, ErrorCodeExt}; use super::auth::{configure_git_command, get_remote_url}; +use super::remote::Remote; use super::repo::Repository; use super::retry::retry_transient; use super::shell::run_git; +use crate::config::ForgeKind; pub(super) fn local_tag_target_sha(repo: &Repository, tag: &str) -> Result { let reference = repo @@ -47,13 +49,14 @@ pub(super) fn remote_tag_target_shas( workdir: &Path, push_url: &str, tags: &[&str], + forge: ForgeKind, ) -> Result> { if tags.is_empty() { return Ok(HashMap::new()); } let mut cmd = std::process::Command::new("git"); cmd.current_dir(workdir); - configure_git_command(&mut cmd, push_url); + configure_git_command(&mut cmd, push_url, forge); cmd.arg("ls-remote").arg("--tags").arg(push_url); for tag in tags { cmd.arg(format!("refs/tags/{tag}")); @@ -75,25 +78,29 @@ pub(super) fn remote_tag_target_shas( ))) } -pub fn force_push_tags(repo: &Repository, remote_name: &str, tags: &[&str]) -> Result<()> { +pub fn force_push_tags(repo: &Repository, remote: Remote<'_>, tags: &[&str]) -> Result<()> { if tags.is_empty() { return Ok(()); } retry_transient("force-push floating tags", || { - try_force_push_tags_once(repo, remote_name, tags) + try_force_push_tags_once(repo, remote, tags) }) } -fn try_force_push_tags_once(repo: &Repository, remote_name: &str, tags: &[&str]) -> Result<()> { - shell_push_tags(repo, remote_name, tags, true).error_code(error_code::GIT_FLOATING_TAGS) +fn try_force_push_tags_once(repo: &Repository, remote: Remote<'_>, tags: &[&str]) -> Result<()> { + shell_push_tags(repo, remote, tags, true).error_code(error_code::GIT_FLOATING_TAGS) } pub fn verify_remote_branch( repo: &Repository, - remote_name: &str, + remote: Remote<'_>, branch: &str, expected_oid: ObjectId, ) -> Result<()> { + let Remote { + name: remote_name, + forge, + } = remote; super::validate::ensure_safe_refname_fragment(remote_name, "remote name")?; super::validate::ensure_safe_refname_fragment(branch, "branch name")?; let workdir = repo @@ -104,7 +111,7 @@ pub fn verify_remote_branch( let mut cmd = std::process::Command::new("git"); cmd.current_dir(workdir); - configure_git_command(&mut cmd, &url); + configure_git_command(&mut cmd, &url, forge); cmd.args([ "ls-remote", "--heads", @@ -155,15 +162,20 @@ fn resolve_push_source(repo: &Repository, branch: &str) -> String { } } -pub fn force_push_branch(repo: &Repository, remote_name: &str, branch: &str) -> Result<()> { +pub fn force_push_branch(repo: &Repository, remote: Remote<'_>, branch: &str) -> Result<()> { + let remote_name = remote.name; super::validate::ensure_safe_refname_fragment(remote_name, "remote name")?; super::validate::ensure_safe_refname_fragment(branch, "branch name")?; retry_transient(&format!("force-push branch '{branch}'"), || { - try_force_push_branch_once(repo, remote_name, branch) + try_force_push_branch_once(repo, remote, branch) }) } -fn try_force_push_branch_once(repo: &Repository, remote_name: &str, branch: &str) -> Result<()> { +fn try_force_push_branch_once(repo: &Repository, remote: Remote<'_>, branch: &str) -> Result<()> { + let Remote { + name: remote_name, + forge, + } = remote; let workdir = repo .workdir() .ok_or_else(|| anyhow!("bare repos are not supported"))?; @@ -174,7 +186,7 @@ fn try_force_push_branch_once(repo: &Repository, remote_name: &str, branch: &str let mut cmd = std::process::Command::new("git"); cmd.current_dir(workdir); - configure_git_command(&mut cmd, &push_url); + configure_git_command(&mut cmd, &push_url, forge); cmd.arg("push").arg(&push_url).arg(&refspec); let output = cmd @@ -193,10 +205,14 @@ fn try_force_push_branch_once(repo: &Repository, remote_name: &str, branch: &str // SAFETY GUARD for the persistent release PR: before force-pushing the pub fn release_branch_foreign_commit( repo: &Repository, - remote_name: &str, + remote: Remote<'_>, branch: &str, base: &str, ) -> Result> { + let Remote { + name: remote_name, + forge, + } = remote; super::validate::ensure_safe_refname_fragment(remote_name, "remote name")?; super::validate::ensure_safe_refname_fragment(branch, "branch name")?; super::validate::ensure_safe_refname_fragment(base, "target branch")?; @@ -208,7 +224,7 @@ pub fn release_branch_foreign_commit( let mut ls = std::process::Command::new("git"); ls.current_dir(workdir); - configure_git_command(&mut ls, &url); + configure_git_command(&mut ls, &url, forge); ls.args([ "ls-remote", "--heads", @@ -232,7 +248,7 @@ pub fn release_branch_foreign_commit( let mut fetch = std::process::Command::new("git"); fetch.current_dir(workdir); - configure_git_command(&mut fetch, &url); + configure_git_command(&mut fetch, &url, forge); fetch.args(["fetch", "--quiet", &url, &format!("refs/heads/{branch}")]); let fetch_out = fetch .output() @@ -269,7 +285,8 @@ pub fn release_branch_foreign_commit( Ok(None) } -pub fn push_tags(repo: &Repository, remote_name: &str, tags: &[&str]) -> Result<()> { +pub fn push_tags(repo: &Repository, remote: Remote<'_>, tags: &[&str]) -> Result<()> { + let remote_name = remote.name; if tags.is_empty() { return Ok(()); } @@ -277,21 +294,30 @@ pub fn push_tags(repo: &Repository, remote_name: &str, tags: &[&str]) -> Result< for tag in tags { super::validate::ensure_safe_refname_fragment(tag, "tag name")?; } - retry_transient("push tags", || try_push_tags_once(repo, remote_name, tags)) + retry_transient("push tags", || try_push_tags_once(repo, remote, tags)) } -fn try_push_tags_once(repo: &Repository, remote_name: &str, tags: &[&str]) -> Result<()> { - shell_push_tags(repo, remote_name, tags, false).error_code(error_code::GIT_PUSH_TAGS) +fn try_push_tags_once(repo: &Repository, remote: Remote<'_>, tags: &[&str]) -> Result<()> { + shell_push_tags(repo, remote, tags, false).error_code(error_code::GIT_PUSH_TAGS) } -fn shell_push_tags(repo: &Repository, remote_name: &str, tags: &[&str], force: bool) -> Result<()> { +fn shell_push_tags( + repo: &Repository, + remote: Remote<'_>, + tags: &[&str], + force: bool, +) -> Result<()> { + let Remote { + name: remote_name, + forge, + } = remote; let workdir = repo .workdir() .ok_or_else(|| anyhow!("bare repos are not supported"))?; let push_url = get_remote_url(repo, remote_name) .ok_or_else(|| anyhow!("Remote '{remote_name}' has no URL"))?; - let remote_shas = remote_tag_target_shas(workdir, &push_url, tags).unwrap_or_else(|err| { + let remote_shas = remote_tag_target_shas(workdir, &push_url, tags, forge).unwrap_or_else(|err| { tracing::warn!( " Warning: could not enumerate remote tag state ({err}); falling back to a plain push" ); @@ -343,7 +369,7 @@ fn shell_push_tags(repo: &Repository, remote_name: &str, tags: &[&str], force: b let prefix = if force { "+" } else { "" }; let mut cmd = std::process::Command::new("git"); cmd.current_dir(workdir); - configure_git_command(&mut cmd, &push_url); + configure_git_command(&mut cmd, &push_url, forge); cmd.arg("push").arg(&push_url); for tag in &to_push { cmd.arg(format!("{prefix}refs/tags/{tag}:refs/tags/{tag}")); @@ -367,13 +393,17 @@ fn shell_push_tags(repo: &Repository, remote_name: &str, tags: &[&str], force: b Ok(()) } -fn try_push_branch(repo: &Repository, remote_name: &str, branch: &str) -> Result<()> { +fn try_push_branch(repo: &Repository, remote: Remote<'_>, branch: &str) -> Result<()> { retry_transient(&format!("push branch '{branch}'"), || { - try_push_branch_once(repo, remote_name, branch) + try_push_branch_once(repo, remote, branch) }) } -fn try_push_branch_once(repo: &Repository, remote_name: &str, branch: &str) -> Result<()> { +fn try_push_branch_once(repo: &Repository, remote: Remote<'_>, branch: &str) -> Result<()> { + let Remote { + name: remote_name, + forge, + } = remote; let workdir = repo .workdir() .ok_or_else(|| anyhow!("bare repos are not supported"))?; @@ -384,7 +414,7 @@ fn try_push_branch_once(repo: &Repository, remote_name: &str, branch: &str) -> R let mut cmd = std::process::Command::new("git"); cmd.current_dir(workdir); - configure_git_command(&mut cmd, &push_url); + configure_git_command(&mut cmd, &push_url, forge); cmd.arg("push").arg(&push_url).arg(&refspec); let output = cmd @@ -400,7 +430,11 @@ fn try_push_branch_once(repo: &Repository, remote_name: &str, branch: &str) -> R Ok(()) } -pub fn reset_branch_to_remote(repo: &Repository, remote_name: &str, branch: &str) -> Result<()> { +pub fn reset_branch_to_remote(repo: &Repository, remote: Remote<'_>, branch: &str) -> Result<()> { + let Remote { + name: remote_name, + forge, + } = remote; super::validate::ensure_safe_refname_fragment(remote_name, "remote name")?; super::validate::ensure_safe_refname_fragment(branch, "branch name")?; let workdir = repo @@ -411,7 +445,7 @@ pub fn reset_branch_to_remote(repo: &Repository, remote_name: &str, branch: &str let mut fetch_cmd = std::process::Command::new("git"); fetch_cmd.current_dir(workdir); - configure_git_command(&mut fetch_cmd, &push_url); + configure_git_command(&mut fetch_cmd, &push_url, forge); fetch_cmd.arg("fetch").arg(remote_name).arg(format!( "+refs/heads/{branch}:refs/remotes/{remote_name}/{branch}" )); @@ -448,8 +482,8 @@ pub fn reset_branch_to_remote(repo: &Repository, remote_name: &str, branch: &str Ok(()) } -pub fn push(repo: &Repository, remote_name: &str, branch: &str) -> Result<()> { - try_push_branch(repo, remote_name, branch) +pub fn push(repo: &Repository, remote: Remote<'_>, branch: &str) -> Result<()> { + try_push_branch(repo, remote, branch) .with_context(|| format!("Failed to push branch '{branch}'")) .error_code(error_code::GIT_PUSH_BRANCH)?; @@ -459,7 +493,7 @@ pub fn push(repo: &Repository, remote_name: &str, branch: &str) -> Result<()> { let head_str = run_git(workdir, &["rev-parse", "HEAD"])?.trim().to_string(); let head_oid = ObjectId::from_hex(head_str.as_bytes()) .with_context(|| format!("invalid HEAD sha: {head_str}"))?; - verify_remote_branch(repo, remote_name, branch, head_oid) + verify_remote_branch(repo, remote, branch, head_oid) .with_context(|| "Post-push verification failed: release commit not on remote branch") .error_code(error_code::GIT_PUSH_VERIFY_FAILED)?; @@ -480,10 +514,14 @@ pub fn push(repo: &Repository, remote_name: &str, branch: &str) -> Result<()> { /// is deleting someone else's. pub fn delete_tag_if_unchanged( repo: &Repository, - remote_name: &str, + remote: Remote<'_>, tag: &str, expected_sha: &str, ) -> Result<()> { + let Remote { + name: remote_name, + forge, + } = remote; super::validate::ensure_safe_refname_fragment(remote_name, "remote name")?; super::validate::ensure_safe_refname_fragment(tag, "tag name")?; let workdir = repo @@ -496,7 +534,7 @@ pub fn delete_tag_if_unchanged( // an annotated tag would come back as its tag object rather than the commit // the checkpoint recorded, and every annotated tag would look moved. let pattern = format!("{tag}*"); - let remote_shas = match remote_tag_target_shas(workdir, &url, &[&pattern]) { + let remote_shas = match remote_tag_target_shas(workdir, &url, &[&pattern], forge) { Ok(shas) => shas, Err(err) => { tracing::warn!( @@ -530,7 +568,7 @@ pub fn delete_tag_if_unchanged( let mut cmd = std::process::Command::new("git"); cmd.current_dir(workdir); - configure_git_command(&mut cmd, &url); + configure_git_command(&mut cmd, &url, forge); cmd.args(["push", "--delete", &url, &format!("refs/tags/{tag}")]); let out = cmd .output() diff --git a/src/git/remote.rs b/src/git/remote.rs new file mode 100644 index 00000000..f98f05e7 --- /dev/null +++ b/src/git/remote.rs @@ -0,0 +1,16 @@ +use crate::config::{ForgeKind, WorkspaceConfig}; + +#[derive(Debug, Clone, Copy)] +pub struct Remote<'a> { + pub name: &'a str, + pub forge: ForgeKind, +} + +impl<'a> Remote<'a> { + pub fn of(workspace: &'a WorkspaceConfig) -> Self { + Self { + name: &workspace.remote, + forge: workspace.forge, + } + } +} diff --git a/src/git/tests.rs b/src/git/tests.rs index e7a1fac1..e7373af6 100644 --- a/src/git/tests.rs +++ b/src/git/tests.rs @@ -4,11 +4,16 @@ use super::repo::Repository; use super::retry::{is_transient_git_error, retry_transient}; use super::tags::{find_highest_semver_tag, find_last_tag, is_floating_tag, is_prerelease_tag}; use super::*; -use crate::config::OrphanedTagStrategy; +use crate::config::{ForgeKind, OrphanedTagStrategy}; use crate::error_code; use crate::test_utils::{git, git_with_env, init_repo_at}; use std::path::{Path, PathBuf}; +const ORIGIN: Remote<'static> = Remote { + name: "origin", + forge: ForgeKind::Auto, +}; + #[test] fn is_transient_classifies_network_errors_as_retryable() { let err = anyhow::anyhow!("Failed to push tags").context("connection reset by peer"); @@ -974,8 +979,8 @@ fn extract_url_password_ssh_url() { #[test] fn token_for_url_uses_ferrflow_token_when_set() { let _guard = EnvGuard::new().set("FERRFLOW_TOKEN", "ff_secret"); - let (user, token) = - token_for_url("https://github.com/owner/repo.git").expect("should find token"); + let (user, token) = token_for_url("https://github.com/owner/repo.git", ForgeKind::Auto) + .expect("should find token"); assert_eq!(user, "x-access-token"); assert_eq!(token, "ff_secret"); } @@ -983,8 +988,8 @@ fn token_for_url_uses_ferrflow_token_when_set() { #[test] fn token_for_url_picks_gitlab_user_for_gitlab_urls() { let _guard = EnvGuard::new().set("FERRFLOW_TOKEN", "gl_secret"); - let (user, _) = - token_for_url("https://gitlab.com/group/project.git").expect("should find token"); + let (user, _) = token_for_url("https://gitlab.com/group/project.git", ForgeKind::Auto) + .expect("should find token"); assert_eq!(user, "oauth2"); } @@ -995,7 +1000,7 @@ fn a_ci_job_token_pushes_as_gitlab_ci_token() { .set("CI_JOB_TOKEN", "job_secret") .set("GITLAB_TOKEN", "job_secret"); assert_eq!( - token_for_url("https://gitlab.com/group/project.git"), + token_for_url("https://gitlab.com/group/project.git", ForgeKind::Auto), Some(("gitlab-ci-token".to_string(), "job_secret".to_string())) ); } @@ -1005,7 +1010,8 @@ fn a_ci_job_token_passed_as_ferrflow_token_pushes_as_gitlab_ci_token() { let _guard = EnvGuard::new() .set("CI_JOB_TOKEN", "job_secret") .set("FERRFLOW_TOKEN", "job_secret"); - let (user, _) = token_for_url("https://gitlab.com/group/project.git").expect("token"); + let (user, _) = + token_for_url("https://gitlab.com/group/project.git", ForgeKind::Auto).expect("token"); assert_eq!(user, "gitlab-ci-token"); } @@ -1015,17 +1021,57 @@ fn a_personal_token_in_a_ci_job_still_pushes_as_oauth2() { .unset("FERRFLOW_TOKEN") .set("CI_JOB_TOKEN", "job_secret") .set("GITLAB_TOKEN", "glpat_secret"); - let (user, _) = token_for_url("https://gitlab.com/group/project.git").expect("token"); + let (user, _) = + token_for_url("https://gitlab.com/group/project.git", ForgeKind::Auto).expect("token"); assert_eq!(user, "oauth2"); } +#[test] +fn a_configured_gitlab_forge_pushes_with_gitlab_token_whatever_the_host() { + let _guard = EnvGuard::new() + .unset("FERRFLOW_TOKEN") + .unset("CI_JOB_TOKEN") + .set("GITHUB_TOKEN", "gh_secret") + .set("GITLAB_TOKEN", "gl_secret"); + assert_eq!( + token_for_url("https://git.company.com/team/repo.git", ForgeKind::Gitlab), + Some(("oauth2".to_string(), "gl_secret".to_string())) + ); +} + +#[test] +fn a_configured_gitlab_forge_pushes_a_job_token_as_gitlab_ci_token() { + let _guard = EnvGuard::new() + .unset("FERRFLOW_TOKEN") + .set("CI_JOB_TOKEN", "job_secret") + .set("GITLAB_TOKEN", "job_secret"); + let (user, _) = + token_for_url("https://git.company.com/team/repo.git", ForgeKind::Gitlab).expect("token"); + assert_eq!(user, "gitlab-ci-token"); +} + +#[test] +fn a_configured_forge_overrides_the_host_name_heuristic() { + let _guard = EnvGuard::new() + .unset("FERRFLOW_TOKEN") + .set("GITHUB_TOKEN", "gh_secret") + .set("GITLAB_TOKEN", "gl_secret"); + assert_eq!( + token_for_url( + "https://gitlab-mirror.acme.com/team/repo.git", + ForgeKind::Github + ), + Some(("x-access-token".to_string(), "gh_secret".to_string())) + ); +} + #[test] fn token_for_url_falls_back_to_provider_env() { let _guard = EnvGuard::new() .unset("FERRFLOW_TOKEN") .set("GITHUB_TOKEN", "gh_secret"); - let (user, token) = - token_for_url("https://github.com/owner/repo.git").expect("should find token"); + let (user, token) = token_for_url("https://github.com/owner/repo.git", ForgeKind::Auto) + .expect("should find token"); assert_eq!(user, "x-access-token"); assert_eq!(token, "gh_secret"); } @@ -1036,7 +1082,10 @@ fn token_for_url_returns_none_without_env() { .unset("FERRFLOW_TOKEN") .unset("GITHUB_TOKEN") .unset("GITLAB_TOKEN"); - assert_eq!(token_for_url("https://github.com/owner/repo.git"), None); + assert_eq!( + token_for_url("https://github.com/owner/repo.git", ForgeKind::Auto), + None + ); } fn command_env(cmd: &std::process::Command, key: &str) -> Option { @@ -1063,7 +1112,7 @@ fn a_repository_name_does_not_pick_the_forge() { "https://x-access-token:tok@github.com/acme/gitlab-migration-tool.git", ] { assert_eq!( - token_for_url(url), + token_for_url(url, ForgeKind::Auto), Some(("x-access-token".to_string(), "gh_secret".to_string())), "{url} should use the GitHub token" ); @@ -1086,7 +1135,7 @@ fn a_gitlab_host_still_picks_gitlab() { "ssh://git@gitlab.acme.com:2222/team/repo.git", ] { assert_eq!( - token_for_url(url), + token_for_url(url, ForgeKind::Auto), Some(("oauth2".to_string(), "gl_secret".to_string())), "{url} should use the GitLab token" ); @@ -1136,7 +1185,11 @@ fn host_of_handles_the_remote_shapes_git_accepts() { fn configure_git_command_passes_the_credential_through_the_environment() { let _guard = EnvGuard::new().set("FERRFLOW_TOKEN", "ff_secret"); let mut cmd = std::process::Command::new("git"); - configure_git_command(&mut cmd, "https://github.com/owner/repo.git"); + configure_git_command( + &mut cmd, + "https://github.com/owner/repo.git", + ForgeKind::Auto, + ); let args: Vec = cmd .get_args() .map(|a| a.to_string_lossy().into_owned()) @@ -1167,7 +1220,11 @@ fn configure_git_command_keeps_the_token_out_of_argv() { // any argument is readable by every other process on a shared runner. let _guard = EnvGuard::new().set("FERRFLOW_TOKEN", "ff_secret"); let mut cmd = std::process::Command::new("git"); - configure_git_command(&mut cmd, "https://github.com/owner/repo.git"); + configure_git_command( + &mut cmd, + "https://github.com/owner/repo.git", + ForgeKind::Auto, + ); for arg in cmd.get_args().map(|a| a.to_string_lossy().into_owned()) { assert!(!arg.contains("ff_secret"), "token leaked into argv: {arg}"); @@ -1182,7 +1239,11 @@ fn an_awkward_token_reaches_git_intact_and_never_enters_argv() { let awkward = "evil';rm -rf /;# \"quoted\" $VAR"; let _guard = EnvGuard::new().set("FERRFLOW_TOKEN", awkward); let mut cmd = std::process::Command::new("git"); - configure_git_command(&mut cmd, "https://github.com/owner/repo.git"); + configure_git_command( + &mut cmd, + "https://github.com/owner/repo.git", + ForgeKind::Auto, + ); assert_eq!( command_env(&cmd, "FERRFLOW_GIT_PASSWORD").as_deref(), @@ -1200,7 +1261,11 @@ fn a_running_git_process_does_not_expose_the_token_in_proc() { use std::io::Read; let _guard = EnvGuard::new().set("FERRFLOW_TOKEN", "ff_proc_secret"); let mut cmd = std::process::Command::new("git"); - configure_git_command(&mut cmd, "https://github.com/owner/repo.git"); + configure_git_command( + &mut cmd, + "https://github.com/owner/repo.git", + ForgeKind::Auto, + ); // `hash-object --stdin` blocks until stdin closes, which gives a real git // process to inspect rather than a race against a fast exit. let mut child = cmd @@ -1233,7 +1298,11 @@ fn configure_git_command_strips_git_trace_env() { cmd.env("GIT_TRACE", "1"); cmd.env("GIT_CURL_VERBOSE", "1"); cmd.env("GIT_TRACE_CURL", "1"); - configure_git_command(&mut cmd, "https://github.com/owner/repo.git"); + configure_git_command( + &mut cmd, + "https://github.com/owner/repo.git", + ForgeKind::Auto, + ); let removed: std::collections::HashSet = cmd .get_envs() .filter(|(_, v)| v.is_none()) @@ -1248,7 +1317,11 @@ fn configure_git_command_strips_git_trace_env() { fn configure_git_command_resets_checkout_extraheader_when_authenticated() { let _guard = EnvGuard::new().set("FERRFLOW_TOKEN", "ff_secret"); let mut cmd = std::process::Command::new("git"); - configure_git_command(&mut cmd, "https://github.com/owner/repo.git"); + configure_git_command( + &mut cmd, + "https://github.com/owner/repo.git", + ForgeKind::Auto, + ); let args: Vec = cmd .get_args() .map(|a| a.to_string_lossy().into_owned()) @@ -1267,7 +1340,11 @@ fn configure_git_command_does_not_reset_extraheader_without_token() { .unset("GITHUB_TOKEN") .unset("GITLAB_TOKEN"); let mut cmd = std::process::Command::new("git"); - configure_git_command(&mut cmd, "https://github.com/owner/repo.git"); + configure_git_command( + &mut cmd, + "https://github.com/owner/repo.git", + ForgeKind::Auto, + ); assert!( !cmd.get_args() .any(|a| a.to_string_lossy().contains("extraheader")), @@ -1296,7 +1373,11 @@ fn configure_git_command_skips_helper_without_token() { .unset("GITHUB_TOKEN") .unset("GITLAB_TOKEN"); let mut cmd = std::process::Command::new("git"); - configure_git_command(&mut cmd, "https://github.com/owner/repo.git"); + configure_git_command( + &mut cmd, + "https://github.com/owner/repo.git", + ForgeKind::Auto, + ); let args: Vec = cmd .get_args() .map(|a| a.to_string_lossy().into_owned()) @@ -1665,7 +1746,7 @@ fn push_rejects_when_remote_advanced_instead_of_rebasing() { let local_head_before = git(&local_path, &["rev-parse", "HEAD"]); let repo = open_repo(&local_path).unwrap(); - let err = super::push::push(&repo, "origin", "main") + let err = super::push::push(&repo, ORIGIN, "main") .expect_err("push onto an advanced remote must be rejected, not rebased"); assert!( @@ -1730,7 +1811,7 @@ fn reset_branch_to_remote_drops_local_commit_and_dirty_tree() { std::fs::write(local_path.join("dirty.txt"), "stale hook output").unwrap(); let repo = open_repo(&local_path).unwrap(); - reset_branch_to_remote(&repo, "origin", "main").expect("reset must succeed"); + reset_branch_to_remote(&repo, ORIGIN, "main").expect("reset must succeed"); let new_head = git(&local_path, &["rev-parse", "HEAD"]).trim().to_string(); assert_eq!(new_head, remote_b_oid, "HEAD must be at remote B"); @@ -2015,7 +2096,7 @@ fn a_tag_the_remote_has_moved_is_left_alone_even_when_the_local_ref_still_matche "precondition: the local ref must still look unchanged" ); - delete_tag_if_unchanged(&repo, "origin", "v1.0.0", &recorded).unwrap(); + delete_tag_if_unchanged(&repo, ORIGIN, "v1.0.0", &recorded).unwrap(); let remote_tags = git(&remote, &["tag", "-l"]); assert!( @@ -2047,7 +2128,7 @@ fn a_tag_absent_from_this_checkout_is_not_deleted_from_the_remote() { delete_tag_if_unchanged( &repo, - "origin", + ORIGIN, "theirs-v9.9.9", "deadbeefdeadbeefdeadbeefdeadbeef", ) @@ -2073,7 +2154,7 @@ fn an_annotated_tag_still_at_its_recorded_commit_is_deleted() { let repo = open_repo(&local).unwrap(); let commit = local_tag_target_sha(&repo, "v1.0.0").unwrap(); - delete_tag_if_unchanged(&repo, "origin", "v1.0.0", &commit).unwrap(); + delete_tag_if_unchanged(&repo, ORIGIN, "v1.0.0", &commit).unwrap(); let remote_tags = git(&remote, &["tag", "-l"]); assert!( @@ -2092,7 +2173,7 @@ fn a_tag_still_at_the_recorded_sha_on_the_remote_is_deleted() { let repo = open_repo(&local).unwrap(); let sha = local_tag_target_sha(&repo, "v1.0.0").unwrap(); - delete_tag_if_unchanged(&repo, "origin", "v1.0.0", &sha).unwrap(); + delete_tag_if_unchanged(&repo, ORIGIN, "v1.0.0", &sha).unwrap(); assert!(!tag_exists(&repo, "v1.0.0"), "the local tag should go"); let remote_tags = git(&remote, &["tag", "-l"]); @@ -2111,7 +2192,7 @@ fn a_tag_that_never_reached_the_remote_is_cleaned_up_locally() { let repo = open_repo(&local).unwrap(); let sha = local_tag_target_sha(&repo, "v1.0.0").unwrap(); - delete_tag_if_unchanged(&repo, "origin", "v1.0.0", &sha).unwrap(); + delete_tag_if_unchanged(&repo, ORIGIN, "v1.0.0", &sha).unwrap(); assert!( !tag_exists(&repo, "v1.0.0"), @@ -2179,7 +2260,13 @@ fn remote_tag_shas_peel_an_annotated_tag_to_its_commit() { "precondition: an annotated tag's object is not its commit" ); - let shas = remote_tag_target_shas(&local, remote.to_str().unwrap(), &["v1.0.0"]).unwrap(); + let shas = remote_tag_target_shas( + &local, + remote.to_str().unwrap(), + &["v1.0.0"], + ForgeKind::Auto, + ) + .unwrap(); assert_eq!( shas.get("v1.0.0").map(String::as_str), @@ -2201,8 +2288,7 @@ fn pushing_an_annotated_tag_already_on_the_remote_is_a_no_op() { let repo = open_repo(&local).unwrap(); - push_tags(&repo, "origin", &["v1.0.0"]) - .expect("re-pushing the same annotated tag must succeed"); + push_tags(&repo, ORIGIN, &["v1.0.0"]).expect("re-pushing the same annotated tag must succeed"); } #[test] @@ -2214,8 +2300,7 @@ fn a_lightweight_tag_already_on_the_remote_is_still_a_no_op() { let repo = open_repo(&local).unwrap(); - push_tags(&repo, "origin", &["v1.0.0"]) - .expect("the case that already worked must keep working"); + push_tags(&repo, ORIGIN, &["v1.0.0"]).expect("the case that already worked must keep working"); } #[test] @@ -2239,7 +2324,7 @@ fn an_annotated_tag_the_remote_holds_at_another_commit_is_still_rejected() { git(&local, &["tag", "-a", "v1.0.0", "-m", "Ours"]); let repo = open_repo(&local).unwrap(); - let err = push_tags(&repo, "origin", &["v1.0.0"]) + let err = push_tags(&repo, ORIGIN, &["v1.0.0"]) .expect_err("a genuinely divergent tag must still be refused"); let rendered = format!("{err:#}"); assert!( diff --git a/src/monorepo/release.rs b/src/monorepo/release.rs index d22430aa..c3d583eb 100644 --- a/src/monorepo/release.rs +++ b/src/monorepo/release.rs @@ -153,7 +153,11 @@ fn cleanup_failed_release_attempt( } let target_branch = crate::git::resolve_current_branch(&repo, &config.workspace.branch); - crate::git::reset_branch_to_remote(&repo, &config.workspace.remote, &target_branch)?; + crate::git::reset_branch_to_remote( + &repo, + crate::git::Remote::of(&config.workspace), + &target_branch, + )?; Checkpoint::delete(root)?; Ok(()) diff --git a/src/monorepo/run/authored_commit.rs b/src/monorepo/run/authored_commit.rs index e1b125c5..96c50e07 100644 --- a/src/monorepo/run/authored_commit.rs +++ b/src/monorepo/run/authored_commit.rs @@ -51,7 +51,7 @@ pub(super) fn author_on_branch( forge: &dyn Forge, repo: &Repository, root: &Path, - remote: &str, + remote: crate::git::Remote<'_>, branch: &str, expected_head_oid: &str, files: &[&str], diff --git a/src/monorepo/run/execute.rs b/src/monorepo/run/execute.rs index ef14aa5c..625e3040 100644 --- a/src/monorepo/run/execute.rs +++ b/src/monorepo/run/execute.rs @@ -7,7 +7,7 @@ use std::path::Path; use crate::config::{Config, ReleaseCommitMode, ReleaseCommitScope}; use crate::error_code::{self, ErrorCodeExt}; use crate::git::{ - Repository, create_branch_and_commit, create_branch_and_commits, create_commit, + Remote, Repository, create_branch_and_commit, create_branch_and_commits, create_commit, create_or_move_tag, create_tag, force_push_branch, force_push_tags, get_tag_message, push, push_tags, release_branch_foreign_commit, tag_exists, }; @@ -223,7 +223,7 @@ fn run_commit_or_pr( forge.as_ref(), plan.repo, plan.root, - &plan.config.workspace.remote, + Remote::of(&plan.config.workspace), plan.target_branch, &head, file_refs, @@ -239,7 +239,7 @@ fn run_commit_or_pr( } ReleaseCommitMode::Pr => { let branch_name = release_branch_name(plan.target_branch); - let remote = &plan.config.workspace.remote; + let remote = Remote::of(&plan.config.workspace); match release_branch_foreign_commit(plan.repo, remote, &branch_name, plan.target_branch) { @@ -556,7 +556,11 @@ fn push_refs( let tag_refs: Vec<&str> = plan.tags_to_create.iter().map(|t| t.tag.as_str()).collect(); if let ReleaseCommitMode::Commit = mode { - push(plan.repo, &plan.config.workspace.remote, plan.target_branch)?; + push( + plan.repo, + Remote::of(&plan.config.workspace), + plan.target_branch, + )?; plan.shared_outputs.push(format!( "✓ Pushed and verified on {}/{}", plan.config.workspace.remote, plan.target_branch @@ -564,13 +568,13 @@ fn push_refs( } if !tag_refs.is_empty() && mode != ReleaseCommitMode::Pr { - push_tags(plan.repo, &plan.config.workspace.remote, &tag_refs)?; + push_tags(plan.repo, Remote::of(&plan.config.workspace), &tag_refs)?; plan.shared_outputs.push("✓ Pushed tags".to_string()); } if !floating_tag_names.is_empty() { let float_refs: Vec<&str> = floating_tag_names.iter().map(String::as_str).collect(); - force_push_tags(plan.repo, &plan.config.workspace.remote, &float_refs)?; + force_push_tags(plan.repo, Remote::of(&plan.config.workspace), &float_refs)?; plan.shared_outputs .push("✓ Pushed floating tags".to_string()); } diff --git a/src/monorepo/run/mod.rs b/src/monorepo/run/mod.rs index b4ca2962..06103fcd 100644 --- a/src/monorepo/run/mod.rs +++ b/src/monorepo/run/mod.rs @@ -124,7 +124,7 @@ pub(super) fn run_release_logic( if !dry_run { let start = std::time::Instant::now(); - let fetch = fetch_tags(&repo, &config.workspace.remote); + let fetch = fetch_tags(&repo, crate::git::Remote::of(&config.workspace)); timing.record("fetch_tags", start.elapsed()); if let Err(e) = fetch && verbose diff --git a/src/rollback/mod.rs b/src/rollback/mod.rs index 8075d1a5..cc3df034 100644 --- a/src/rollback/mod.rs +++ b/src/rollback/mod.rs @@ -112,7 +112,7 @@ fn apply( config: &Config, config_path: Option<&Path>, ) -> Result<()> { - let remote = &config.workspace.remote; + let remote = crate::git::Remote::of(&config.workspace); let forge = crate::monorepo::preview::build_forge_instance(repo, config); for step in &plan.steps {