Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 14 additions & 6 deletions src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ pub(crate) use worktree::{
pub(crate) use worktrunk::shell::Shell;

use color_print::cformat;
use worktrunk::git::LegacyForgeAlias;
use worktrunk::git::{LegacyForgeAlias, LegacyForgeAliasShape};
use worktrunk::styling::{eprintln, format_with_gutter};

pub(crate) fn flag_pair(positive: bool, negative: bool) -> Option<bool> {
Expand All @@ -82,14 +82,22 @@ pub(crate) fn flag_pair(positive: bool, negative: bool) -> Option<bool> {
}
}

/// Actionable compatibility diagnostic for branded SSH aliases that no longer
/// cross the exact-label forge-classification boundary.
/// Actionable compatibility diagnostic for hosts that no longer cross the
/// exact-label forge-classification boundary.
///
/// The lead-in reflects the shape: a branded SSH alias is named as such, a
/// hyphenated or forge-named hostname (`github-enterprise.acme.com`) as a host.
pub(crate) fn legacy_forge_alias_diagnostic(alias: &LegacyForgeAlias) -> String {
let platform = alias.platform().to_string();
let host = alias.host();
cformat!(
"SSH host alias <bold>{host}</> is not auto-detected as a forge; enable CI status and <bold>wt switch --prs</> with <bold>forge.platform = \"{platform}\"</> @ <bold>.config/wt.toml</>"
)
match alias.shape() {
LegacyForgeAliasShape::SshAlias => cformat!(
"SSH host alias <bold>{host}</> is not auto-detected as a forge; enable CI status and <bold>wt switch --prs</> with <bold>forge.platform = \"{platform}\"</> @ <bold>.config/wt.toml</>"
),
LegacyForgeAliasShape::Hostname => cformat!(
"Host <bold>{host}</> is not auto-detected as a forge; enable CI status and <bold>wt switch --prs</> with <bold>forge.platform = \"{platform}\"</> @ <bold>.config/wt.toml</>"
),
}
}

/// Format command execution label with optional command name.
Expand Down
22 changes: 22 additions & 0 deletions src/commands/picker/prs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1859,6 +1859,28 @@ mod tests {
);
}

#[test]
fn fetch_open_prs_explains_legacy_forge_hostname_configuration() {
// A self-hosted host that carries the forge name only inside a label is
// worded as a host (not an SSH alias) but points at the same remedy.
let test = worktrunk::testing::TestRepo::with_initial_commit();
test.run_git(&[
"remote",
"add",
"origin",
"https://gitlab-internal.company.com/owner/repo.git",
]);
let err = fetch_open_prs(&test.repo)
.expect_err("expected --prs to require explicit forge config");
let message = err.to_string();
assert!(
message.contains("Host")
&& message.contains("gitlab-internal.company.com")
&& message.contains("forge.platform = \"gitlab\""),
"unexpected error: {err:#}"
);
}

#[test]
fn fetch_open_prs_bails_on_an_unsupported_forge() {
// A recognized-but-unsupported forge (here Gitea, by its host) reports
Expand Down
176 changes: 145 additions & 31 deletions src/git/ci_platform.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,21 @@ pub enum ForgeKind {
AzureDevOps,
}

/// A branded SSH host alias that the former substring classifier recognized.
/// Which shape produced a [`LegacyForgeAlias`], so the diagnostic can word
/// itself for the case at hand.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LegacyForgeAliasShape {
/// A single-label, forge-prefixed SSH host alias (`git@github-personal:…`),
/// the multi-account setup form.
SshAlias,
/// A DNS hostname that carries the forge name only inside a label
/// (`github-enterprise.acme.com`, `mygithub.life-white.uk`): recognized by the
/// pre-0.71.0 substring rule but not the exact-label classifier.
Hostname,
}

/// A host the former substring classifier recognized but the exact-label
/// boundary no longer does.
///
/// This is diagnostic-only: callers can explain the compatibility change and
/// suggest `forge.platform`, but must not use it to select a forge provider.
Expand All @@ -33,6 +47,7 @@ pub enum ForgeKind {
pub struct LegacyForgeAlias {
host: String,
platform: ForgeKind,
shape: LegacyForgeAliasShape,
}

impl LegacyForgeAlias {
Expand All @@ -44,40 +59,68 @@ impl LegacyForgeAlias {
self.platform
}

/// Recognize the common multi-account SSH alias form (`github-personal`).
pub fn shape(&self) -> LegacyForgeAliasShape {
self.shape
}

/// Recognize a host that the pre-0.71.0 substring rule classified but the
/// exact-label boundary now drops.
///
/// Two shapes qualify, both diagnostic-only:
///
/// Only SSH URLs with a single-label, forge-prefixed host qualify. Dotted
/// hosts stay out even when they contain a forge name: those are exactly
/// the security-sensitive lookalikes the exact-label classifier rejects.
/// - The multi-account SSH alias (`git@github-personal:…`) — a single DNS
/// label, forge-prefixed. Worded as an SSH alias.
/// - Any host carrying the forge name only inside a label
/// (`github-enterprise.acme.com`, `mygithub.life-white.uk`, or an Azure service
/// domain present as a substring) — worded as a hostname.
///
/// A host that still classifies on the exact-label boundary needs no
/// diagnostic and returns `None`.
fn from_url(url: &str) -> Option<Self> {
let url = url.trim();
let parsed = GitRemoteUrl::parse(url)?;
let host = normalized_hostname(parsed.host());

// Still classifies normally — a provider resolves, so no diagnostic.
if ForgeKind::from_host(&host).is_some() {
return None;
}

// The branded multi-account SSH alias: single-label, forge-prefixed.
let is_ssh = url.starts_with("ssh://")
|| url
.split_once(':')
.is_some_and(|(authority, _)| !authority.contains('/') && authority.contains('@'));
if !is_ssh {
return None;
}

let parsed = GitRemoteUrl::parse(url)?;
let host = normalized_hostname(parsed.host());
if host.contains('.') || ForgeKind::from_host(&host).is_some() {
return None;
if is_ssh && !host.contains('.') {
let ssh_platform = [
("github-", ForgeKind::GitHub),
("gitlab-", ForgeKind::GitLab),
("gitea-", ForgeKind::Gitea),
]
.into_iter()
.find_map(|(prefix, platform)| {
host.strip_prefix(prefix)
.filter(|suffix| !suffix.is_empty())
.map(|_| platform)
});
if let Some(platform) = ssh_platform {
return Some(Self {
host,
platform,
shape: LegacyForgeAliasShape::SshAlias,
});
}
}

let platform = [
("github-", ForgeKind::GitHub),
("gitlab-", ForgeKind::GitLab),
("gitea-", ForgeKind::Gitea),
]
.into_iter()
.find_map(|(prefix, platform)| {
host.strip_prefix(prefix)
.filter(|suffix| !suffix.is_empty())
.map(|_| platform)
})?;

Some(Self { host, platform })
// Any other host the pre-0.71.0 substring rule would have matched — the
// forge name appears only inside a label. Additive: it changes no
// classification and never feeds provider dispatch.
let platform = legacy_substring_platform(&host)?;
Some(Self {
host,
platform,
shape: LegacyForgeAliasShape::Hostname,
})
}
}

Expand Down Expand Up @@ -134,6 +177,29 @@ fn host_has_label(host: &str, label: &str) -> bool {
host.split('.').any(|candidate| candidate == label)
}

/// The platform the pre-0.71.0 substring classifier would have picked for a
/// host that no longer classifies on the exact-label boundary.
///
/// `host` must already be [`normalized_hostname`]d (lowercased), so the
/// lowercase literals compare case-insensitively. Azure DevOps is checked
/// first — its former rule was a `dev.azure.com` / `visualstudio.com`
/// substring — then the `github`/`gitlab`/`gitea` name substrings, mirroring
/// [`ForgeKind::from_host`]'s precedence. Diagnostic only: this must never feed
/// provider dispatch.
fn legacy_substring_platform(host: &str) -> Option<ForgeKind> {
if host.contains("dev.azure.com") || host.contains("visualstudio.com") {
Some(ForgeKind::AzureDevOps)
} else if host.contains("github") {
Some(ForgeKind::GitHub)
} else if host.contains("gitlab") {
Some(ForgeKind::GitLab)
} else if host.contains("gitea") {
Some(ForgeKind::Gitea)
} else {
None
}
}

fn normalized_host_is_within(host: &str, domain: &str) -> bool {
host == domain || host.strip_suffix(domain).is_some_and(|p| p.ends_with('.'))
}
Expand Down Expand Up @@ -183,7 +249,7 @@ impl Repository {
None
}

/// Return a diagnostic for a legacy branded SSH alias on the primary remote.
/// Return a diagnostic for a legacy forge host on the primary remote.
///
/// A configured platform resolves the ambiguity and suppresses the
/// diagnostic. The returned value never participates in provider dispatch.
Expand Down Expand Up @@ -350,21 +416,69 @@ mod tests {
] {
let alias = LegacyForgeAlias::from_url(url).expect(url);
assert_eq!(alias.platform(), platform, "{url}");
assert_eq!(alias.shape(), LegacyForgeAliasShape::SshAlias, "{url}");
assert_eq!(ForgeKind::from_host(alias.host()), None, "{url}");
}

// A host that still classifies on the exact label, or carries no forge
// name at all, gets no diagnostic.
for url in [
"git@github.com:owner/repo.git",
"git@work:owner/repo.git",
"git@notgithub-personal:owner/repo.git",
"git@github-mirror.example:owner/repo.git",
"git@github-personal.attacker.example:owner/repo.git",
"https://github-personal/owner/repo.git",
"https://bitbucket.org/owner/repo.git",
"https://codeberg.org/owner/repo.git",
] {
assert_eq!(LegacyForgeAlias::from_url(url), None, "{url}");
}
}

#[test]
fn test_legacy_forge_alias_widens_to_hostnames_that_lost_exact_label() {
// Self-hosted instances (and lookalikes) whose host carries the forge
// name only inside a label were classified by the pre-0.71.0 substring
// rule but not the exact-label boundary. They now announce the
// `forge.platform` remedy as hostnames, over HTTPS and SSH alike.
for (url, platform) in [
(
"https://gitlab-internal.company.com/owner/repo.git",
ForgeKind::GitLab,
),
(
"https://github-enterprise.acme.com/owner/repo.git",
ForgeKind::GitHub,
),
("https://mygithub.life-white.uk/owner/repo.git", ForgeKind::GitHub),
(
"git@github-mirror.example:owner/repo.git",
ForgeKind::GitHub,
),
(
"git@gitea-host.example.com:owner/repo.git",
ForgeKind::Gitea,
),
// The lookalikes #3662 rejects for dispatch still surface a
// diagnostic — the hint suggests config; it never dispatches.
(
"git@github-personal.attacker.example:owner/repo.git",
ForgeKind::GitHub,
),
] {
let alias = LegacyForgeAlias::from_url(url).expect(url);
assert_eq!(alias.platform(), platform, "{url}");
assert_eq!(alias.shape(), LegacyForgeAliasShape::Hostname, "{url}");
// Dispatch is unchanged: the exact-label classifier still rejects it.
assert_eq!(ForgeKind::from_host(alias.host()), None, "{url}");
}

// Azure DevOps: a host carrying a service domain only as a substring
// (the equivalent of the branded-label case for the fixed domains).
let alias = LegacyForgeAlias::from_url("https://dev.azure.com.mirror.example/o/_git/r")
.expect("azure substring host");
assert_eq!(alias.platform(), ForgeKind::AzureDevOps);
assert_eq!(alias.shape(), LegacyForgeAliasShape::Hostname);
assert_eq!(ForgeKind::from_host(alias.host()), None);
}

#[test]
fn test_configured_platform_suppresses_legacy_alias_diagnostic() {
let test = crate::testing::TestRepo::new();
Expand Down
2 changes: 1 addition & 1 deletion src/git/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ static HEAVY_OPS_SEMAPHORE: LazyLock<Semaphore> = LazyLock::new(|| Semaphore::ne
pub const NULL_OID: &str = "0000000000000000000000000000000000000000";

// Re-exports from submodules
pub use ci_platform::{ForgeKind, LegacyForgeAlias};
pub use ci_platform::{ForgeKind, LegacyForgeAlias, LegacyForgeAliasShape};
pub(crate) use diff::DiffStats;
pub use diff::{LineDiff, parse_numstat_line};
pub use error::{
Expand Down
Loading