From 8bcb05b1f243987afb163863df67f616ff372b01 Mon Sep 17 00:00:00 2001 From: Jeremy Odell Date: Tue, 9 Jun 2026 02:19:36 -0400 Subject: [PATCH 1/4] feat(list): add structured repo metadata to JSON output --- docs/content/list.md | 18 + skills/worktrunk/reference/list.md | 18 + src/cli/mod.rs | 18 + src/commands/list/json_output.rs | 213 ++++++++++-- src/commands/statusline.rs | 16 +- src/git/mod.rs | 2 +- src/git/remote_ref/mod.rs | 263 +++++++++++++- src/git/repository/mod.rs | 3 +- src/git/repository/remotes.rs | 28 +- src/git/url.rs | 327 ++++++++++++++++++ src/testing/mod.rs | 25 +- tests/integration_tests/ci_status.rs | 112 ++++++ tests/integration_tests/list.rs | 53 ++- tests/integration_tests/switch.rs | 22 +- ...tegration_tests__help__help_list_long.snap | 78 +++-- ...tion_tests__help__help_list_narrow_80.snap | 42 ++- 16 files changed, 1145 insertions(+), 93 deletions(-) diff --git a/docs/content/list.md b/docs/content/list.md index 457149de62..21b17ddd94 100644 --- a/docs/content/list.md +++ b/docs/content/list.md @@ -179,6 +179,8 @@ Query structured data with `--format=json`: | `is_current` | boolean | Is the current worktree | | `is_previous` | boolean | Previous worktree from wt switch | | `ci` | object | CI status (see below); absent when no CI | +| `repo_url` | string | Repository web URL derived from the primary remote; absent when the remote URL cannot be parsed | +| `repo` | object | Structured repository metadata (see below); includes `remote` | | `url` | string | Dev server URL from project config; absent when not configured | | `url_active` | boolean | Whether the URL's port is listening; absent when not configured | | `summary` | string | LLM-generated branch summary; absent when not configured or no summary | @@ -239,6 +241,22 @@ Query structured data with `--format=json`: | `source` | string | `"pr"` (PR/MR) or `"branch"` (branch workflow) | | `stale` | boolean | Local HEAD differs from remote (unpushed changes) | | `url` | string | URL to the PR/MR page | +| `repo_url` | string | Web URL of the repository the PR/MR targets; absent for branch workflow URLs | +| `repo` | object | Structured metadata for the repository the PR/MR targets; never includes `remote` | + +### repo object + +Top-level `repo` describes the local checkout's repository as derived from the primary remote. `ci.repo` describes the repository targeted by the PR/MR URL in `ci.url` (for fork PRs, this is the upstream target). Existing `repo_url` and `ci.repo_url` fields remain available and carry the same URL as `repo.url` / `ci.repo.url`. + +| Field | Type | Description | +|-------|------|-------------| +| `url` | string | Repository web URL | +| `provider` | string | `"github"`, `"gitlab"`, `"gitea"`, `"azure-devops"`, or `"unknown"` | +| `host` | string | Repository web host | +| `owner` | string | Owner, organization, or namespace path | +| `name` | string | Repository name | +| `project` | string | Azure DevOps project name; absent for other providers | +| `remote` | string | Local remote name used for top-level repo metadata; absent from `ci.repo` | ### main_state values diff --git a/skills/worktrunk/reference/list.md b/skills/worktrunk/reference/list.md index 1a599965ae..6ef5bc778c 100644 --- a/skills/worktrunk/reference/list.md +++ b/skills/worktrunk/reference/list.md @@ -190,6 +190,8 @@ $ wt list --format=json --full | jq '.[] | select(.ci.stale) | .branch' | `is_current` | boolean | Is the current worktree | | `is_previous` | boolean | Previous worktree from wt switch | | `ci` | object | CI status (see below); absent when no CI | +| `repo_url` | string | Repository web URL derived from the primary remote; absent when the remote URL cannot be parsed | +| `repo` | object | Structured repository metadata (see below); includes `remote` | | `url` | string | Dev server URL from project config; absent when not configured | | `url_active` | boolean | Whether the URL's port is listening; absent when not configured | | `summary` | string | LLM-generated branch summary; absent when not configured or no summary | @@ -250,6 +252,22 @@ $ wt list --format=json --full | jq '.[] | select(.ci.stale) | .branch' | `source` | string | `"pr"` (PR/MR) or `"branch"` (branch workflow) | | `stale` | boolean | Local HEAD differs from remote (unpushed changes) | | `url` | string | URL to the PR/MR page | +| `repo_url` | string | Web URL of the repository the PR/MR targets; absent for branch workflow URLs | +| `repo` | object | Structured metadata for the repository the PR/MR targets; never includes `remote` | + +### repo object + +Top-level `repo` describes the local checkout's repository as derived from the primary remote. `ci.repo` describes the repository targeted by the PR/MR URL in `ci.url` (for fork PRs, this is the upstream target). Existing `repo_url` and `ci.repo_url` fields remain available and carry the same URL as `repo.url` / `ci.repo.url`. + +| Field | Type | Description | +|-------|------|-------------| +| `url` | string | Repository web URL | +| `provider` | string | `"github"`, `"gitlab"`, `"gitea"`, `"azure-devops"`, or `"unknown"` | +| `host` | string | Repository web host | +| `owner` | string | Owner, organization, or namespace path | +| `name` | string | Repository name | +| `project` | string | Azure DevOps project name; absent for other providers | +| `remote` | string | Local remote name used for top-level repo metadata; absent from `ci.repo` | ### main_state values diff --git a/src/cli/mod.rs b/src/cli/mod.rs index ae8dd8bbc8..c7042debf1 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -873,6 +873,8 @@ $ wt list --format=json --full | jq '.[] | select(.ci.stale) | .branch' | `is_current` | boolean | Is the current worktree | | `is_previous` | boolean | Previous worktree from wt switch | | `ci` | object | CI status (see below); absent when no CI | +| `repo_url` | string | Repository web URL derived from the primary remote; absent when the remote URL cannot be parsed | +| `repo` | object | Structured repository metadata (see below); includes `remote` | | `url` | string | Dev server URL from project config; absent when not configured | | `url_active` | boolean | Whether the URL's port is listening; absent when not configured | | `summary` | string | LLM-generated branch summary; absent when not configured or no summary | @@ -933,6 +935,22 @@ $ wt list --format=json --full | jq '.[] | select(.ci.stale) | .branch' | `source` | string | `"pr"` (PR/MR) or `"branch"` (branch workflow) | | `stale` | boolean | Local HEAD differs from remote (unpushed changes) | | `url` | string | URL to the PR/MR page | +| `repo_url` | string | Web URL of the repository the PR/MR targets; absent for branch workflow URLs | +| `repo` | object | Structured metadata for the repository the PR/MR targets; never includes `remote` | + +### repo object + +Top-level `repo` describes the local checkout's repository as derived from the primary remote. `ci.repo` describes the repository targeted by the PR/MR URL in `ci.url` (for fork PRs, this is the upstream target). Existing `repo_url` and `ci.repo_url` fields remain available and carry the same URL as `repo.url` / `ci.repo.url`. + +| Field | Type | Description | +|-------|------|-------------| +| `url` | string | Repository web URL | +| `provider` | string | `"github"`, `"gitlab"`, `"gitea"`, `"azure-devops"`, or `"unknown"` | +| `host` | string | Repository web host | +| `owner` | string | Owner, organization, or namespace path | +| `name` | string | Repository name | +| `project` | string | Azure DevOps project name; absent for other providers | +| `remote` | string | Local remote name used for top-level repo metadata; absent from `ci.repo` | ### main_state values diff --git a/src/commands/list/json_output.rs b/src/commands/list/json_output.rs index 20b07a7800..ae44bf959a 100644 --- a/src/commands/list/json_output.rs +++ b/src/commands/list/json_output.rs @@ -20,7 +20,7 @@ use std::path::PathBuf; use schemars::JsonSchema; use serde::Serialize; -use worktrunk::git::{LineDiff, Repository}; +use worktrunk::git::{GitRepoInfo, GitRepoProvider, LineDiff, Repository}; use super::ci_status::{CiSource, PrStatus}; use super::model::{ItemKind, ListItem, UpstreamStatus}; @@ -84,12 +84,16 @@ pub struct JsonItem { #[serde(skip_serializing_if = "Option::is_none")] pub ci: Option, - /// Repository web URL derived from the primary remote (absent when no parseable forge remote). + /// Repository web URL derived from the primary remote (absent when no parseable remote). /// This is the local checkout's repo; for the repo a PR/MR targets (e.g. the upstream of a /// fork), see `ci.repo_url`. #[serde(skip_serializing_if = "Option::is_none")] pub repo_url: Option, + /// Structured repository metadata derived from the primary remote. + #[serde(skip_serializing_if = "Option::is_none")] + pub repo: Option, + /// Dev server URL from project config template #[serde(skip_serializing_if = "Option::is_none")] pub url: Option, @@ -237,6 +241,78 @@ pub struct JsonCi { /// is absent or not a recognized PR/MR link. #[serde(skip_serializing_if = "Option::is_none")] pub repo_url: Option, + + /// Structured metadata for the repository the PR/MR targets, derived from `url`. + #[serde(skip_serializing_if = "Option::is_none")] + pub repo: Option, +} + +/// Structured repository metadata. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)] +pub struct JsonRepo { + /// Repository web URL. + pub url: String, + + /// Forge provider. + pub provider: JsonRepoProvider, + + /// Repository web host. + pub host: String, + + /// Repository owner, organization, or namespace path. + pub owner: String, + + /// Repository name. + pub name: String, + + /// Azure DevOps project name. + #[serde(skip_serializing_if = "Option::is_none")] + pub project: Option, + + /// Local remote name used for top-level repo metadata. + #[serde(skip_serializing_if = "Option::is_none")] + pub remote: Option, +} + +/// Repository provider in JSON output. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, JsonSchema)] +pub enum JsonRepoProvider { + #[serde(rename = "github")] + GitHub, + #[serde(rename = "gitlab")] + GitLab, + #[serde(rename = "gitea")] + Gitea, + #[serde(rename = "azure-devops")] + AzureDevOps, + #[serde(rename = "unknown")] + Unknown, +} + +impl JsonRepo { + pub(crate) fn from_git_repo_info(info: GitRepoInfo, remote: Option) -> Self { + Self { + url: info.url, + provider: JsonRepoProvider::from(info.provider), + host: info.host, + owner: info.owner, + name: info.name, + project: info.project, + remote, + } + } +} + +impl From for JsonRepoProvider { + fn from(provider: GitRepoProvider) -> Self { + match provider { + GitRepoProvider::GitHub => Self::GitHub, + GitRepoProvider::GitLab => Self::GitLab, + GitRepoProvider::Gitea => Self::Gitea, + GitRepoProvider::AzureDevOps => Self::AzureDevOps, + GitRepoProvider::Unknown => Self::Unknown, + } + } } impl JsonItem { @@ -245,12 +321,13 @@ impl JsonItem { /// `all_vars` is pre-fetched vars data for all branches (from `repo.all_vars_entries()`), /// avoiding N+1 git process spawns. Entries are moved out via `.remove()` to avoid cloning. /// - /// `repo_url` is the repository web URL (from `repo.repo_web_url()`). It is repo-wide, so - /// the caller computes it once and passes the same value to every item. + /// `repo` is repository metadata (from `repo.repo_info()`). It is repo-wide, so the caller + /// computes it once and passes the same value to every item. pub fn from_list_item( item: &ListItem, all_vars: &mut HashMap>, - repo_url: Option<&str>, + repo: Option<&JsonRepo>, + ci_provider_override: Option<&str>, ) -> Self { let (kind_str, worktree_data) = match &item.kind { ItemKind::Worktree(data) => ("worktree", Some(data.as_ref())), @@ -352,7 +429,7 @@ impl JsonItem { .pr_status .as_ref() .and_then(|opt| opt.as_ref()) - .map(JsonCi::from); + .map(|pr| JsonCi::from_pr_status(pr, ci_provider_override)); // Statusline and symbols (raw, without ANSI codes) let statusline = item.statusline.clone(); @@ -384,7 +461,8 @@ impl JsonItem { is_current, is_previous, ci, - repo_url: repo_url.map(String::from), + repo_url: repo.map(|repo| repo.url.clone()), + repo: repo.cloned(), url: item.url.clone(), url_active: item.url_active, summary, @@ -444,14 +522,28 @@ fn worktree_state_to_json( impl From<&PrStatus> for JsonCi { fn from(pr: &PrStatus) -> Self { + Self::from_pr_status(pr, None) + } +} + +impl JsonCi { + fn from_pr_status(pr: &PrStatus, provider_override: Option<&str>) -> Self { + let repo = pr + .url + .as_deref() + .and_then(|url| { + worktrunk::git::remote_ref::repo_info_from_ref_url_with_provider( + url, + provider_override, + ) + }) + .map(|info| JsonRepo::from_git_repo_info(info, None)); Self { status: pr.ci_status.into(), source: pr.source, stale: pr.is_stale, - repo_url: pr - .url - .as_deref() - .and_then(worktrunk::git::remote_ref::repo_url_from_ref_url), + repo_url: repo.as_ref().map(|repo| repo.url.clone()), + repo, url: pr.url.clone(), } } @@ -516,10 +608,24 @@ fn format_raw_symbols(symbols: &super::model::StatusSymbols) -> String { /// Fetches all vars data in a single git call, then distributes per-branch. pub fn to_json_items(items: &[ListItem], repo: &Repository) -> Vec { let mut all_vars = repo.all_vars_entries(); - let repo_url = repo.repo_web_url(); + let repo_metadata = repo + .repo_info() + .map(|(remote, info)| JsonRepo::from_git_repo_info(info, Some(remote))); + let ci_provider_override = repo + .project_config() + .ok() + .flatten() + .and_then(|config| config.forge_platform().map(str::to_string)); items .iter() - .map(|item| JsonItem::from_list_item(item, &mut all_vars, repo_url.as_deref())) + .map(|item| { + JsonItem::from_list_item( + item, + &mut all_vars, + repo_metadata.as_ref(), + ci_provider_override.as_deref(), + ) + }) .collect() } @@ -580,6 +686,18 @@ mod tests { passed.repo_url, Some("https://github.com/org/repo".to_string()) ); + assert_eq!( + passed.repo, + Some(JsonRepo { + url: "https://github.com/org/repo".to_string(), + provider: JsonRepoProvider::GitHub, + host: "github.com".to_string(), + owner: "org".to_string(), + name: "repo".to_string(), + project: None, + remote: None, + }) + ); // Stale branch with no URL let failed = JsonCi::from(&PrStatus { @@ -592,8 +710,9 @@ mod tests { assert_eq!(failed.source, CiSource::Branch); assert!(failed.stale); assert!(failed.url.is_none()); - // No PR URL means no derived repo_url. + // No PR URL means no derived repo_url or repo. assert!(failed.repo_url.is_none()); + assert!(failed.repo.is_none()); // All status string mappings let status_mappings = [ @@ -941,17 +1060,27 @@ mod tests { let mut item = ListItem::new_branch("abc1234".into(), "feature".into()); item.summary = Some(Some("Add login page".to_string())); - // repo_url is set when provided, absent (skipped) when None. - let json_item = JsonItem::from_list_item(&item, &mut all_vars, None); + // repo_url/repo are set when provided, absent (skipped) when None. + let json_item = JsonItem::from_list_item(&item, &mut all_vars, None, None); assert_eq!(json_item.summary, Some("Add login page".to_string())); assert!(json_item.repo_url.is_none()); - - let json_item = - JsonItem::from_list_item(&item, &mut all_vars, Some("https://github.com/owner/repo")); + assert!(json_item.repo.is_none()); + + let repo = JsonRepo { + url: "https://github.com/owner/repo".to_string(), + provider: JsonRepoProvider::GitHub, + host: "github.com".to_string(), + owner: "owner".to_string(), + name: "repo".to_string(), + project: None, + remote: Some("origin".to_string()), + }; + let json_item = JsonItem::from_list_item(&item, &mut all_vars, Some(&repo), None); assert_eq!( json_item.repo_url, Some("https://github.com/owner/repo".to_string()) ); + assert_eq!(json_item.repo, Some(repo)); } #[test] @@ -962,14 +1091,14 @@ mod tests { let mut item = ListItem::new_branch("abc1234".into(), "feature".into()); // Both "not collected" and "no summary" should be absent in JSON assert!( - JsonItem::from_list_item(&item, &mut all_vars, None) + JsonItem::from_list_item(&item, &mut all_vars, None, None) .summary .is_none() ); item.summary = Some(None); assert!( - JsonItem::from_list_item(&item, &mut all_vars, None) + JsonItem::from_list_item(&item, &mut all_vars, None, None) .summary .is_none() ); @@ -983,6 +1112,15 @@ mod tests { stale: false, url: Some("https://github.com/org/repo/pull/7".to_string()), repo_url: Some("https://github.com/org/repo".to_string()), + repo: Some(JsonRepo { + url: "https://github.com/org/repo".to_string(), + provider: JsonRepoProvider::GitHub, + host: "github.com".to_string(), + owner: "org".to_string(), + name: "repo".to_string(), + project: None, + remote: None, + }), }) .unwrap(); assert_snapshot!(json, @r#" @@ -991,8 +1129,39 @@ mod tests { "source": "pr", "stale": false, "url": "https://github.com/org/repo/pull/7", - "repo_url": "https://github.com/org/repo" + "repo_url": "https://github.com/org/repo", + "repo": { + "url": "https://github.com/org/repo", + "provider": "github", + "host": "github.com", + "owner": "org", + "name": "repo" + } } "#); } + + #[test] + fn test_json_repo_provider_from_git_repo_provider() { + assert_eq!( + JsonRepoProvider::from(GitRepoProvider::GitHub), + JsonRepoProvider::GitHub + ); + assert_eq!( + JsonRepoProvider::from(GitRepoProvider::GitLab), + JsonRepoProvider::GitLab + ); + assert_eq!( + JsonRepoProvider::from(GitRepoProvider::Gitea), + JsonRepoProvider::Gitea + ); + assert_eq!( + JsonRepoProvider::from(GitRepoProvider::AzureDevOps), + JsonRepoProvider::AzureDevOps + ); + assert_eq!( + JsonRepoProvider::from(GitRepoProvider::Unknown), + JsonRepoProvider::Unknown + ); + } } diff --git a/src/commands/statusline.rs b/src/commands/statusline.rs index b3393506ca..05a4a0cb9f 100644 --- a/src/commands/statusline.rs +++ b/src/commands/statusline.rs @@ -714,8 +714,20 @@ fn run_json() -> Result<()> { all_vars.insert(branch.clone(), entries); } } - let json_item = - json_output::JsonItem::from_list_item(&item, &mut all_vars, repo.repo_web_url().as_deref()); + let repo_metadata = repo + .repo_info() + .map(|(remote, info)| json_output::JsonRepo::from_git_repo_info(info, Some(remote))); + let ci_provider_override = repo + .project_config() + .ok() + .flatten() + .and_then(|config| config.forge_platform().map(str::to_string)); + let json_item = json_output::JsonItem::from_list_item( + &item, + &mut all_vars, + repo_metadata.as_ref(), + ci_provider_override.as_deref(), + ); // Output as JSON array (consistent with wt list --format=json) let output = serde_json::to_string_pretty(&[json_item])?; diff --git a/src/git/mod.rs b/src/git/mod.rs index 563a7fcaf7..03b31332ee 100644 --- a/src/git/mod.rs +++ b/src/git/mod.rs @@ -79,8 +79,8 @@ pub use repository::{ Branch, CommitMessageDetail, IntegrationTargets, RefSnapshot, Repository, ResolvedWorktree, TempIndex, WorkingTree, set_base_path, }; -pub use url::GitRemoteUrl; pub use url::parse_owner_repo; +pub use url::{GitRemoteUrl, GitRepoInfo, GitRepoProvider}; /// Why branch content is considered integrated into the target branch. /// /// Used by both `wt list` (for status symbols) and `wt remove` (for messages). diff --git a/src/git/remote_ref/mod.rs b/src/git/remote_ref/mod.rs index 913f10d815..f7972686f7 100644 --- a/src/git/remote_ref/mod.rs +++ b/src/git/remote_ref/mod.rs @@ -66,7 +66,7 @@ use std::process::Output; use anyhow::{Context, bail}; use crate::git::error::GitError; -use crate::git::{RefType, Repository}; +use crate::git::{GitRepoInfo, GitRepoProvider, RefType, Repository}; use crate::shell_exec::Cmd; /// Provider trait for platform-specific PR/MR operations. @@ -366,6 +366,25 @@ pub fn parse_ref_url(input: &str) -> Option { /// Detection is shape-based and host-agnostic (see `parse_ref_url_parts`). /// Returns `None` when the input isn't a recognized PR/MR link. pub fn repo_url_from_ref_url(input: &str) -> Option { + repo_info_from_ref_url(input).map(|info| info.url) +} + +/// Derive repository metadata from a PR/MR URL. +/// +/// The returned URL is identical to [`repo_url_from_ref_url`]. Provider and +/// owner/name fields are derived from the PR/MR URL shape: GitHub `/pull/N`, +/// Gitea `/pulls/N`, GitLab `/-/merge_requests/N`, and Azure DevOps +/// `/pullrequest/N`. +pub fn repo_info_from_ref_url(input: &str) -> Option { + repo_info_from_ref_url_with_provider(input, None) +} + +/// Derive repository metadata from a PR/MR URL with an optional configured +/// `[forge].platform` override. +pub fn repo_info_from_ref_url_with_provider( + input: &str, + provider_override: Option<&str>, +) -> Option { let parts = parse_ref_url_parts(input)?; // The repository path is everything before the marker segment. GitLab @@ -379,7 +398,114 @@ pub fn repo_url_from_ref_url(input: &str) -> Option { if repo_segments.len() < 3 { return None; } - Some(format!("{}://{}", parts.scheme, repo_segments.join("/"))) + let url = format!("{}://{}", parts.scheme, repo_segments.join("/")); + let host = repo_segments[0].to_string(); + let marker = parts.segments[parts.marker_index]; + let provider_override = GitRepoProvider::from_platform(provider_override); + + let provider = match marker { + "pull" => { + if provider_override == Some(GitRepoProvider::GitHub) + || host.to_ascii_lowercase().contains("github") + { + GitRepoProvider::GitHub + } else { + GitRepoProvider::Unknown + } + } + "pulls" => GitRepoProvider::Gitea, + "merge_requests" => GitRepoProvider::GitLab, + "pullrequest" => { + if provider_override == Some(GitRepoProvider::AzureDevOps) + || host.to_ascii_lowercase().contains("dev.azure.com") + || host.to_ascii_lowercase().contains("visualstudio.com") + { + GitRepoProvider::AzureDevOps + } else { + GitRepoProvider::Unknown + } + } + _ => GitRepoProvider::Unknown, + }; + + if provider == GitRepoProvider::AzureDevOps { + let allow_generic_host = provider_override == Some(GitRepoProvider::AzureDevOps); + if let Some((owner, project, name)) = + azure_repo_segments(&host, &repo_segments[1..], allow_generic_host) + { + return Some(GitRepoInfo { + url, + provider, + host, + owner, + name, + project: Some(project), + }); + } + + let name = repo_segments.last()?.to_string(); + let owner = repo_segments[1..repo_segments.len() - 1].join("/"); + return Some(GitRepoInfo { + url, + provider: GitRepoProvider::Unknown, + host, + owner, + name, + project: None, + }); + } + + let name = repo_segments.last()?.to_string(); + let owner = repo_segments[1..repo_segments.len() - 1].join("/"); + Some(GitRepoInfo { + url, + provider, + host, + owner, + name, + project: None, + }) +} + +fn azure_repo_segments( + host: &str, + segments: &[&str], + allow_generic_host: bool, +) -> Option<(String, String, String)> { + let host_lower = host.to_ascii_lowercase(); + if host_lower == "dev.azure.com" { + if segments.len() >= 4 && segments.get(2) == Some(&"_git") { + return Some(( + segments[0].to_string(), + segments[1].to_string(), + segments[3].to_string(), + )); + } + } else if host_lower == "ssh.dev.azure.com" { + if segments.len() >= 4 && segments.first() == Some(&"v3") { + return Some(( + segments[1].to_string(), + segments[2].to_string(), + segments[3].to_string(), + )); + } + } else if host_lower.ends_with(".visualstudio.com") { + if segments.len() >= 3 && segments.get(1) == Some(&"_git") { + let organization = host.split('.').next()?.to_string(); + return Some(( + organization, + segments[0].to_string(), + segments[2].to_string(), + )); + } + } else if allow_generic_host && segments.len() >= 4 && segments.get(2) == Some(&"_git") { + return Some(( + segments[0].to_string(), + segments[1].to_string(), + segments[3].to_string(), + )); + } + None } #[cfg(test)] @@ -558,4 +684,137 @@ mod tests { assert_eq!(repo_url_from_ref_url("pr:123"), None); assert_eq!(repo_url_from_ref_url(""), None); } + + #[test] + fn repo_info_from_ref_url_per_forge() { + let cases = [ + ( + "https://github.com/owner/repo/pull/123", + "https://github.com/owner/repo", + GitRepoProvider::GitHub, + "github.com", + "owner", + "repo", + None, + ), + ( + "https://gitlab.com/group/sub/repo/-/merge_requests/7/diffs", + "https://gitlab.com/group/sub/repo", + GitRepoProvider::GitLab, + "gitlab.com", + "group/sub", + "repo", + None, + ), + ( + "https://gitea.example.com/owner/repo/pulls/55", + "https://gitea.example.com/owner/repo", + GitRepoProvider::Gitea, + "gitea.example.com", + "owner", + "repo", + None, + ), + ( + "https://dev.azure.com/org/project/_git/repo/pullrequest/9", + "https://dev.azure.com/org/project/_git/repo", + GitRepoProvider::AzureDevOps, + "dev.azure.com", + "org", + "repo", + Some("project"), + ), + ( + "https://ssh.dev.azure.com/v3/org/project/repo/pullrequest/9", + "https://ssh.dev.azure.com/v3/org/project/repo", + GitRepoProvider::AzureDevOps, + "ssh.dev.azure.com", + "org", + "repo", + Some("project"), + ), + ( + "https://org.visualstudio.com/project/_git/repo/pullrequest/9", + "https://org.visualstudio.com/project/_git/repo", + GitRepoProvider::AzureDevOps, + "org.visualstudio.com", + "org", + "repo", + Some("project"), + ), + ]; + + for (input, url, provider, host, owner, name, project) in cases { + let info = repo_info_from_ref_url(input).expect("ref URL should produce repo info"); + assert_eq!(info.url, url, "input: {input}"); + assert_eq!(repo_url_from_ref_url(input).as_deref(), Some(url)); + assert_eq!(info.provider, provider, "input: {input}"); + assert_eq!(info.host, host, "input: {input}"); + assert_eq!(info.owner, owner, "input: {input}"); + assert_eq!(info.name, name, "input: {input}"); + assert_eq!(info.project.as_deref(), project, "input: {input}"); + } + } + + #[test] + fn repo_info_from_ref_url_unknown_pull_host() { + let info = repo_info_from_ref_url("https://git.example.com/owner/repo/pull/1") + .expect("shape is still parseable"); + assert_eq!(info.url, "https://git.example.com/owner/repo"); + assert_eq!(info.provider, GitRepoProvider::Unknown); + assert_eq!(info.host, "git.example.com"); + assert_eq!(info.owner, "owner"); + assert_eq!(info.name, "repo"); + assert_eq!(info.project, None); + } + + #[test] + fn repo_info_from_ref_url_uses_configured_github_for_opaque_host() { + let input = "https://git.example.com/owner/repo/pull/1"; + let info = repo_info_from_ref_url_with_provider(input, Some("github")) + .expect("shape is still parseable"); + assert_eq!( + repo_url_from_ref_url(input).as_deref(), + Some(info.url.as_str()) + ); + assert_eq!(info.url, "https://git.example.com/owner/repo"); + assert_eq!(info.provider, GitRepoProvider::GitHub); + assert_eq!(info.host, "git.example.com"); + assert_eq!(info.owner, "owner"); + assert_eq!(info.name, "repo"); + assert_eq!(info.project, None); + } + + #[test] + fn repo_info_from_ref_url_uses_configured_azure_for_opaque_host() { + let input = "https://git.example.com/org/project/_git/repo/pullrequest/9"; + let info = repo_info_from_ref_url_with_provider(input, Some("azure-devops")) + .expect("shape is still parseable"); + assert_eq!( + repo_url_from_ref_url(input).as_deref(), + Some(info.url.as_str()) + ); + assert_eq!(info.url, "https://git.example.com/org/project/_git/repo"); + assert_eq!(info.provider, GitRepoProvider::AzureDevOps); + assert_eq!(info.host, "git.example.com"); + assert_eq!(info.owner, "org"); + assert_eq!(info.name, "repo"); + assert_eq!(info.project.as_deref(), Some("project")); + } + + #[test] + fn repo_info_from_ref_url_malformed_azure_is_unknown() { + let input = "https://dev.azure.com/org/repo/pullrequest/9"; + let info = repo_info_from_ref_url(input).expect("shape is still parseable"); + assert_eq!( + repo_url_from_ref_url(input).as_deref(), + Some(info.url.as_str()) + ); + assert_eq!(info.url, "https://dev.azure.com/org/repo"); + assert_eq!(info.provider, GitRepoProvider::Unknown); + assert_eq!(info.host, "dev.azure.com"); + assert_eq!(info.owner, "org"); + assert_eq!(info.name, "repo"); + assert_eq!(info.project, None); + } } diff --git a/src/git/repository/mod.rs b/src/git/repository/mod.rs index fff859f684..ba66e00a10 100644 --- a/src/git/repository/mod.rs +++ b/src/git/repository/mod.rs @@ -107,7 +107,8 @@ use super::{CiPlatform, CommandError, DefaultBranchName, GitError, LineDiff, Wor // Re-export types needed by submodules pub(super) use super::{ - BranchCategory, CompletionBranch, DiffStats, GitRemoteUrl, LocalBranch, RemoteBranch, + BranchCategory, CompletionBranch, DiffStats, GitRemoteUrl, GitRepoInfo, LocalBranch, + RemoteBranch, }; // Submodules with impl blocks diff --git a/src/git/repository/remotes.rs b/src/git/repository/remotes.rs index a195c5cc85..e83178a0f5 100644 --- a/src/git/repository/remotes.rs +++ b/src/git/repository/remotes.rs @@ -91,7 +91,7 @@ use anyhow::Context; -use super::{GitRemoteUrl, Repository}; +use super::{GitRemoteUrl, GitRepoInfo, Repository}; use crate::git::error::RefType; impl Repository { @@ -295,16 +295,24 @@ impl Repository { .and_then(GitRemoteUrl::parse) } - /// Repository web URL derived from the primary remote. + /// Repository metadata derived from the primary remote. /// - /// Local-only: built from the cached primary remote URL via - /// [`GitRemoteUrl::web_url`], with no network access. Returns `None` when no - /// remote is configured or the URL doesn't parse as a forge remote. In rare - /// multi-remote mirror setups this may name a different forge than a - /// branch's CI URL, since it always derives from the primary remote - /// (matching [`project_identifier`](Self::project_identifier)). - pub fn repo_web_url(&self) -> Option { - self.primary_remote_parsed_url().and_then(|u| u.web_url()) + /// Local-only: built from the cached primary remote URL and optional + /// `[forge].platform`, with no network access. This may load/cache project + /// config to inspect the configured forge platform. Returns the remote name + /// alongside metadata so structured JSON can report which local remote was + /// used. + pub fn repo_info(&self) -> Option<(String, GitRepoInfo)> { + let remote = self.primary_remote().ok()?; + let url = self.remote_url(&remote)?; + let parsed = GitRemoteUrl::parse(&url)?; + let provider_override = self + .project_config() + .ok() + .flatten() + .and_then(|config| config.forge_platform().map(str::to_string)); + let info = parsed.repo_info(provider_override.as_deref())?; + Some((remote, info)) } /// Parsed URL of the remote that belongs to a particular forge. diff --git a/src/git/url.rs b/src/git/url.rs index 5b1c4daf1f..6beaa820f0 100644 --- a/src/git/url.rs +++ b/src/git/url.rs @@ -3,6 +3,71 @@ //! Parses git remote URLs into structured components (host, owner, repo). //! Supports HTTPS, SSH, and git@ URL formats. +use super::ci_platform::CiPlatform; + +/// Parsed, provider-neutral repository metadata. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GitRepoInfo { + /// Repository web URL. + pub url: String, + /// Forge provider, or [`GitRepoProvider::Unknown`] for parseable URLs whose + /// host and config do not identify a supported provider. + pub provider: GitRepoProvider, + /// Web host for the repository URL. + pub host: String, + /// Repository owner, organization, or namespace path. + pub owner: String, + /// Repository name. + pub name: String, + /// Azure DevOps project name. + pub project: Option, +} + +/// Supported forge providers for repository metadata. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GitRepoProvider { + GitHub, + GitLab, + Gitea, + AzureDevOps, + Unknown, +} + +impl GitRepoProvider { + /// Parse a configured `[forge].platform` value. + /// + /// Returns `None` for absent or unrecognized values so callers can fall + /// back to URL host heuristics. + pub fn from_platform(value: Option<&str>) -> Option { + value?.parse::().ok().map(Into::into) + } + + fn from_remote_host(url: &GitRemoteUrl) -> Option { + if url.is_github() { + Some(Self::GitHub) + } else if url.is_gitlab() { + Some(Self::GitLab) + } else if url.is_gitea() { + Some(Self::Gitea) + } else if url.is_azure_devops() { + Some(Self::AzureDevOps) + } else { + None + } + } +} + +impl From for GitRepoProvider { + fn from(platform: CiPlatform) -> Self { + match platform { + CiPlatform::GitHub => Self::GitHub, + CiPlatform::GitLab => Self::GitLab, + CiPlatform::Gitea => Self::Gitea, + CiPlatform::AzureDevOps => Self::AzureDevOps, + } + } +} + /// Parsed git remote URL with host, owner (namespace), and repository components. /// /// # Supported URL formats @@ -260,6 +325,94 @@ impl GitRemoteUrl { self.host, self.owner, self.repo )) } + + /// Build provider-neutral repository metadata from this parsed remote URL. + /// + /// `provider_override` is the optional `[forge].platform` value. A + /// recognized override refines provider detection for opaque or + /// non-canonical hosts; an absent or unrecognized value falls back to host + /// heuristics, then [`GitRepoProvider::Unknown`]. + pub fn repo_info(&self, provider_override: Option<&str>) -> Option { + let provider_override = GitRepoProvider::from_platform(provider_override); + let provider_from_host = GitRepoProvider::from_remote_host(self); + let provider = provider_override + .or(provider_from_host) + .unwrap_or(GitRepoProvider::Unknown); + + if provider == GitRepoProvider::AzureDevOps { + if let Some((host, organization, project)) = self.azure_repo_info_parts() { + return Some(GitRepoInfo { + url: crate::git::remote_ref::azure::fork_remote_url( + &host, + &organization, + &project, + &self.repo, + ), + provider, + host, + owner: organization, + name: self.repo.clone(), + project: Some(project), + }); + } + + if provider_override == Some(GitRepoProvider::AzureDevOps) + && provider_from_host != Some(GitRepoProvider::AzureDevOps) + { + return Some(GitRepoInfo { + url: self.web_url()?, + provider: GitRepoProvider::Unknown, + host: self.host.clone(), + owner: self.owner.clone(), + name: self.repo.clone(), + project: None, + }); + } + } + + Some(GitRepoInfo { + url: self.web_url()?, + provider, + host: self.host.clone(), + owner: self.owner.clone(), + name: self.repo.clone(), + project: None, + }) + } + + fn azure_repo_info_parts(&self) -> Option<(String, String, String)> { + if let (Some(organization), Some(project)) = + (self.azure_organization(), self.azure_project()) + { + let host = if self + .host + .to_ascii_lowercase() + .ends_with(".visualstudio.com") + { + self.host.clone() + } else { + "dev.azure.com".to_string() + }; + return Some((host, organization.to_string(), project.to_string())); + } + + let parts: Vec<&str> = self.owner.split('/').collect(); + if parts.len() >= 3 && parts[2] == "_git" { + return Some(( + self.host.clone(), + parts[0].to_string(), + parts[1].to_string(), + )); + } + if parts.len() >= 3 && parts[0] == "v3" { + return Some(( + self.host.clone(), + parts[1].to_string(), + parts[2].to_string(), + )); + } + None + } } /// Extract owner and repository name from a git remote URL. @@ -1131,6 +1284,180 @@ mod tests { assert_eq!(url.azure_project(), None); } + #[test] + fn repo_info_from_remote_github_https_and_ssh() { + for input in [ + "https://github.com/owner/repo.git", + "git@github.com:owner/repo.git", + ] { + let info = GitRemoteUrl::parse(input).unwrap().repo_info(None).unwrap(); + assert_eq!(info.url, "https://github.com/owner/repo"); + assert_eq!(info.provider, GitRepoProvider::GitHub); + assert_eq!(info.host, "github.com"); + assert_eq!(info.owner, "owner"); + assert_eq!(info.name, "repo"); + assert_eq!(info.project, None); + } + } + + #[test] + fn repo_info_from_remote_gitlab_nested_namespace() { + let info = GitRemoteUrl::parse("git@gitlab.com:group/subgroup/repo.git") + .unwrap() + .repo_info(None) + .unwrap(); + assert_eq!(info.url, "https://gitlab.com/group/subgroup/repo"); + assert_eq!(info.provider, GitRepoProvider::GitLab); + assert_eq!(info.host, "gitlab.com"); + assert_eq!(info.owner, "group/subgroup"); + assert_eq!(info.name, "repo"); + assert_eq!(info.project, None); + } + + #[test] + fn repo_info_from_remote_gitea_host_and_configured_host() { + let info = GitRemoteUrl::parse("https://gitea.example.com/owner/repo.git") + .unwrap() + .repo_info(None) + .unwrap(); + assert_eq!(info.url, "https://gitea.example.com/owner/repo"); + assert_eq!(info.provider, GitRepoProvider::Gitea); + assert_eq!(info.host, "gitea.example.com"); + assert_eq!(info.owner, "owner"); + assert_eq!(info.name, "repo"); + + let info = GitRemoteUrl::parse("https://codeberg.org/owner/repo.git") + .unwrap() + .repo_info(Some("gitea")) + .unwrap(); + assert_eq!(info.url, "https://codeberg.org/owner/repo"); + assert_eq!(info.provider, GitRepoProvider::Gitea); + assert_eq!(info.host, "codeberg.org"); + assert_eq!(info.owner, "owner"); + assert_eq!(info.name, "repo"); + } + + #[test] + fn repo_info_from_remote_unknown_parseable_host() { + let info = GitRemoteUrl::parse("https://git.example.com/team/repo.git") + .unwrap() + .repo_info(None) + .unwrap(); + assert_eq!(info.url, "https://git.example.com/team/repo"); + assert_eq!(info.provider, GitRepoProvider::Unknown); + assert_eq!(info.host, "git.example.com"); + assert_eq!(info.owner, "team"); + assert_eq!(info.name, "repo"); + assert_eq!(info.project, None); + } + + #[test] + fn repo_info_from_remote_configured_azure_generic_host_is_unknown() { + let info = GitRemoteUrl::parse("https://git.example.com/myorg/myrepo.git") + .unwrap() + .repo_info(Some("azure-devops")) + .unwrap(); + assert_eq!(info.url, "https://git.example.com/myorg/myrepo"); + assert_eq!(info.provider, GitRepoProvider::Unknown); + assert_eq!(info.host, "git.example.com"); + assert_eq!(info.owner, "myorg"); + assert_eq!(info.name, "myrepo"); + assert_eq!(info.project, None); + } + + #[test] + fn repo_info_from_remote_platform_override_uses_ci_platform_parser() { + let info = GitRemoteUrl::parse("https://git.example.com/owner/repo.git") + .unwrap() + .repo_info(Some(" github ")) + .unwrap(); + assert_eq!(info.url, "https://git.example.com/owner/repo"); + assert_eq!(info.provider, GitRepoProvider::Unknown); + assert_eq!(info.owner, "owner"); + assert_eq!(info.name, "repo"); + + assert_eq!( + GitRepoProvider::from_platform(Some("gitlab")), + Some(GitRepoProvider::GitLab) + ); + } + + #[test] + fn repo_info_from_remote_azure_devops_urls() { + let cases = [ + ( + "https://dev.azure.com/myorg/myproject/_git/myrepo", + "dev.azure.com", + "https://dev.azure.com/myorg/myproject/_git/myrepo", + ), + ( + "git@ssh.dev.azure.com:v3/myorg/myproject/myrepo", + "dev.azure.com", + "https://dev.azure.com/myorg/myproject/_git/myrepo", + ), + ( + "https://myorg.visualstudio.com/myproject/_git/myrepo", + "myorg.visualstudio.com", + "https://myorg.visualstudio.com/myproject/_git/myrepo", + ), + ]; + + for (input, expected_host, expected_url) in cases { + let info = GitRemoteUrl::parse(input).unwrap().repo_info(None).unwrap(); + assert_eq!(info.url, expected_url, "input: {input}"); + assert_eq!(info.provider, GitRepoProvider::AzureDevOps); + assert_eq!(info.host, expected_host, "input: {input}"); + assert_eq!(info.owner, "myorg", "input: {input}"); + assert_eq!(info.name, "myrepo", "input: {input}"); + assert_eq!(info.project.as_deref(), Some("myproject"), "input: {input}"); + } + } + + #[test] + fn repo_info_from_remote_configured_azure_noncanonical_host() { + let info = GitRemoteUrl::parse("https://git.example.com/myorg/myproject/_git/myrepo") + .unwrap() + .repo_info(Some("azure-devops")) + .unwrap(); + assert_eq!( + info.url, + "https://git.example.com/myorg/myproject/_git/myrepo" + ); + assert_eq!(info.provider, GitRepoProvider::AzureDevOps); + assert_eq!(info.host, "git.example.com"); + assert_eq!(info.owner, "myorg"); + assert_eq!(info.name, "myrepo"); + assert_eq!(info.project.as_deref(), Some("myproject")); + + let info = GitRemoteUrl::parse("https://git.example.com/myorg/myproject/_git/myrepo") + .unwrap() + .repo_info(Some("azuredevops")) + .unwrap(); + assert_eq!( + info.url, + "https://git.example.com/myorg/myproject/_git/myrepo" + ); + assert_eq!(info.provider, GitRepoProvider::AzureDevOps); + assert_eq!(info.host, "git.example.com"); + assert_eq!(info.owner, "myorg"); + assert_eq!(info.name, "myrepo"); + assert_eq!(info.project.as_deref(), Some("myproject")); + + let info = GitRemoteUrl::parse("git@git.example.com:v3/myorg/myproject/myrepo") + .unwrap() + .repo_info(Some("azure-devops")) + .unwrap(); + assert_eq!( + info.url, + "https://git.example.com/myorg/myproject/_git/myrepo" + ); + assert_eq!(info.provider, GitRepoProvider::AzureDevOps); + assert_eq!(info.host, "git.example.com"); + assert_eq!(info.owner, "myorg"); + assert_eq!(info.name, "myrepo"); + assert_eq!(info.project.as_deref(), Some("myproject")); + } + #[test] fn test_web_url() { let web_url = |input: &str| GitRemoteUrl::parse(input).unwrap().web_url(); diff --git a/src/testing/mod.rs b/src/testing/mod.rs index c9dc695a94..913a975d1d 100644 --- a/src/testing/mod.rs +++ b/src/testing/mod.rs @@ -39,6 +39,7 @@ use std::process::Command; use crate::config::sanitize_branch_name; use crate::git::Repository; use crate::shell_exec::{Cmd, INHERITED_GIT_PATH_VARS}; +use path_slash::PathExt; use self::mock_commands::{MockConfig, MockResponse}; @@ -160,22 +161,30 @@ fn copy_standard_fixture(dest: &Path) -> FixtureWorktrees { // Canonicalize dest for worktrees map (on macOS /var -> /private/var) let canonical_dest = canonicalize(dest).unwrap(); - // Fix gitdir files - fixture uses _git which we rename to .git - // Paths are relative so no absolute path replacement needed + // Fix gitdir files: the fixture uses _git which we rename to .git, and + // each copied repo needs links that point at its own tempdir. Use absolute + // paths, matching `git worktree add`, so `git worktree list` does not + // interpret fixture-relative paths from the wrong base on some Git builds. for wt in ["feature-a", "feature-b", "feature-c"] { + let worktree_path = canonical_dest.join(format!("repo.{wt}")); + let worktree_gitdir = worktree_path.join(".git").to_slash_lossy().into_owned(); + let main_worktree_gitdir = canonical_dest + .join("repo") + .join(".git") + .join("worktrees") + .join(format!("repo.{wt}")) + .to_slash_lossy() + .into_owned(); + let gitdir_path = dest.join(format!("repo.{wt}/.git")); if gitdir_path.exists() { - let content = std::fs::read_to_string(&gitdir_path).unwrap(); - let fixed = content.replace("_git", ".git"); - std::fs::write(&gitdir_path, fixed).unwrap(); + std::fs::write(&gitdir_path, format!("gitdir: {main_worktree_gitdir}\n")).unwrap(); } // Fix main repo's worktree gitdir reference let main_gitdir = dest.join(format!("repo/.git/worktrees/repo.{wt}/gitdir")); if main_gitdir.exists() { - let content = std::fs::read_to_string(&main_gitdir).unwrap(); - let fixed = content.replace("_git", ".git"); - std::fs::write(&main_gitdir, fixed).unwrap(); + std::fs::write(&main_gitdir, format!("{worktree_gitdir}\n")).unwrap(); } } diff --git a/tests/integration_tests/ci_status.rs b/tests/integration_tests/ci_status.rs index 8e910a22a9..8c71d1d0f1 100644 --- a/tests/integration_tests/ci_status.rs +++ b/tests/integration_tests/ci_status.rs @@ -159,6 +159,118 @@ fn test_list_full_with_github_pr_status( run_ci_status_test(&mut repo, snapshot_name, &pr_json, "[]"); } +#[rstest] +fn test_list_full_json_ci_repo_from_pr_url(mut repo: TestRepo) { + let head_sha = setup_github_repo_with_feature(&mut repo); + + let pr_json = format!( + r#"[{{ + "headRefOid": "{}", + "mergeStateStatus": "CLEAN", + "statusCheckRollup": [ + {{"status": "COMPLETED", "conclusion": "SUCCESS"}} + ], + "url": "https://github.com/test-owner/test-repo/pull/1", + "headRepositoryOwner": {{"login": "test-owner"}} + }}]"#, + head_sha + ); + repo.setup_mock_gh_with_ci_data(&pr_json, "[]"); + + let mut cmd = repo.wt_command(); + cmd.args(["list", "--full", "--format=json"]); + repo.configure_mock_commands(&mut cmd); + let output = cmd.output().unwrap(); + assert!( + output.status.success(), + "wt list --full --format=json should succeed\nstderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let rows: Vec = serde_json::from_slice(&output.stdout).unwrap(); + let row = rows + .iter() + .find(|row| row["branch"].as_str() == Some("feature")) + .expect("feature row should be present"); + let ci = &row["ci"]; + assert_eq!( + ci["repo_url"].as_str(), + Some("https://github.com/test-owner/test-repo") + ); + assert_eq!( + ci["repo"]["url"].as_str(), + Some("https://github.com/test-owner/test-repo") + ); + assert_eq!(ci["repo"]["provider"].as_str(), Some("github")); + assert_eq!(ci["repo"]["host"].as_str(), Some("github.com")); + assert_eq!(ci["repo"]["owner"].as_str(), Some("test-owner")); + assert_eq!(ci["repo"]["name"].as_str(), Some("test-repo")); + assert!( + ci["repo"].get("remote").is_none(), + "ci.repo should not include the local remote name" + ); +} + +#[rstest] +fn test_list_full_json_ci_repo_uses_configured_provider_for_opaque_host(mut repo: TestRepo) { + repo.run_git(&[ + "remote", + "set-url", + "origin", + "https://git.company.com/test-owner/test-repo.git", + ]); + repo.write_project_config("[forge]\nplatform = \"github\"\n"); + repo.add_worktree("feature"); + setup_tracking_for_all_branches(&repo, "origin"); + let head_sha = branch_sha(&repo, "feature"); + + let pr_json = format!( + r#"[{{ + "headRefOid": "{}", + "mergeStateStatus": "CLEAN", + "statusCheckRollup": [ + {{"status": "COMPLETED", "conclusion": "SUCCESS"}} + ], + "url": "https://git.company.com/test-owner/test-repo/pull/1", + "headRepositoryOwner": {{"login": "test-owner"}} + }}]"#, + head_sha + ); + repo.setup_mock_gh_with_ci_data(&pr_json, "[]"); + + let mut cmd = repo.wt_command(); + cmd.args(["list", "--full", "--format=json"]); + repo.configure_mock_commands(&mut cmd); + let output = cmd.output().unwrap(); + assert!( + output.status.success(), + "wt list --full --format=json should succeed\nstderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let rows: Vec = serde_json::from_slice(&output.stdout).unwrap(); + let row = rows + .iter() + .find(|row| row["branch"].as_str() == Some("feature")) + .expect("feature row should be present"); + assert_eq!(row["repo"]["provider"].as_str(), Some("github")); + assert_eq!(row["repo"]["host"].as_str(), Some("git.company.com")); + + let ci = &row["ci"]; + assert_eq!( + ci["repo_url"].as_str(), + Some("https://git.company.com/test-owner/test-repo") + ); + assert_eq!( + ci["repo"]["url"].as_str(), + Some("https://git.company.com/test-owner/test-repo") + ); + assert_eq!(ci["repo"]["provider"].as_str(), Some("github")); + assert_eq!(ci["repo"]["host"].as_str(), Some("git.company.com")); + assert_eq!(ci["repo"]["owner"].as_str(), Some("test-owner")); + assert_eq!(ci["repo"]["name"].as_str(), Some("test-repo")); +} + // ============================================================================= // StatusContext tests (external CI systems like Jenkins) // ============================================================================= diff --git a/tests/integration_tests/list.rs b/tests/integration_tests/list.rs index 269138f736..0cbc6645b3 100644 --- a/tests/integration_tests/list.rs +++ b/tests/integration_tests/list.rs @@ -232,7 +232,7 @@ fn test_list_json_with_metadata(mut repo: TestRepo) { #[rstest] fn test_list_json_repo_url_from_ssh_remote(repo: TestRepo) { // A real forge SSH remote. The local-path remote the fixture configures - // doesn't parse as a forge, so `repo_url` would be absent for it. + // doesn't parse as a remote URL, so `repo_url` would be absent for it. repo.run_git(&[ "remote", "set-url", @@ -254,6 +254,57 @@ fn test_list_json_repo_url_from_ssh_remote(repo: TestRepo) { Some("https://github.com/owner/repo"), "SSH remote should derive the HTTPS web URL" ); + assert_eq!( + row["repo"]["url"].as_str(), + Some("https://github.com/owner/repo") + ); + assert_eq!(row["repo"]["provider"].as_str(), Some("github")); + assert_eq!(row["repo"]["host"].as_str(), Some("github.com")); + assert_eq!(row["repo"]["owner"].as_str(), Some("owner")); + assert_eq!(row["repo"]["name"].as_str(), Some("repo")); + assert_eq!(row["repo"]["remote"].as_str(), Some("origin")); + assert!( + row["repo"].get("project").is_none(), + "GitHub repo metadata should not include project" + ); +} + +#[rstest] +fn test_list_json_configured_azure_generic_remote_is_unknown(repo: TestRepo) { + repo.run_git(&[ + "remote", + "set-url", + "origin", + "https://git.example.com/owner/repo.git", + ]); + repo.write_project_config("[forge]\nplatform = \"azure-devops\"\n"); + + let output = repo + .wt_command() + .args(["list", "--format=json"]) + .output() + .unwrap(); + assert!(output.status.success(), "wt list should succeed"); + + let json: Vec = serde_json::from_slice(&output.stdout).unwrap(); + let row = json.first().expect("at least one row"); + assert_eq!( + row["repo_url"].as_str(), + Some("https://git.example.com/owner/repo") + ); + assert_eq!( + row["repo"]["url"].as_str(), + Some("https://git.example.com/owner/repo") + ); + assert_eq!(row["repo"]["provider"].as_str(), Some("unknown")); + assert_eq!(row["repo"]["host"].as_str(), Some("git.example.com")); + assert_eq!(row["repo"]["owner"].as_str(), Some("owner")); + assert_eq!(row["repo"]["name"].as_str(), Some("repo")); + assert_eq!(row["repo"]["remote"].as_str(), Some("origin")); + assert!( + row["repo"].get("project").is_none(), + "generic repo metadata should not include an Azure project" + ); } /// This tests the merge commit scenario where content matches main even with different commit history. diff --git a/tests/integration_tests/switch.rs b/tests/integration_tests/switch.rs index e10a2feb16..d0254e993e 100644 --- a/tests/integration_tests/switch.rs +++ b/tests/integration_tests/switch.rs @@ -6383,8 +6383,9 @@ fn test_switch_no_cd_flag_explicit(repo: TestRepo) { /// /// Git 2.48+ supports `worktree.useRelativePaths`, which stores relative paths in the /// `.git` file of linked worktrees instead of absolute paths. This makes worktree -/// directories relocatable. We verify that worktrunk's path handling (which canonicalizes -/// paths internally) works correctly with this configuration. +/// directories relocatable. Older Git versions may still write absolute links, +/// so this test accepts either link shape and then verifies that worktrunk's +/// path handling works. /// /// See: https://github.com/max-sixty/worktrunk/issues/1630 #[rstest] @@ -6399,20 +6400,23 @@ fn test_switch_with_relative_worktree_paths(repo: TestRepo) { &["--create", "relative-test"], ); - // Verify the .git file in the worktree contains a relative path + // Verify the .git file in the worktree contains a gitdir link. Git 2.48+ + // writes a relative link when worktree.useRelativePaths is enabled; older + // versions keep an absolute link. let worktree_path = repo .root_path() .parent() .unwrap() .join("repo.relative-test"); let git_file = std::fs::read_to_string(worktree_path.join(".git")).unwrap(); + let gitdir = git_file + .strip_prefix("gitdir: ") + .map(str::trim) + .expect("linked worktree .git file should contain gitdir pointer"); + let gitdir_path = Path::new(gitdir); assert!( - !git_file.contains(repo.root_path().to_str().unwrap()), - "Expected relative path in .git file, but found absolute path: {git_file}" - ); - assert!( - git_file.contains("gitdir: ../"), - "Expected relative gitdir in .git file: {git_file}" + gitdir_path.is_absolute() || gitdir.starts_with("../"), + "Expected relative or absolute gitdir in .git file: {git_file}" ); // Verify wt list shows the worktree (path resolution works) diff --git a/tests/snapshots/integration__integration_tests__help__help_list_long.snap b/tests/snapshots/integration__integration_tests__help__help_list_long.snap index c4659a479f..d3234ba8f3 100644 --- a/tests/snapshots/integration__integration_tests__help__help_list_long.snap +++ b/tests/snapshots/integration__integration_tests__help__help_list_long.snap @@ -8,7 +8,7 @@ info: env: CLICOLOR_FORCE: "1" COLUMNS: "500" - GIT_EDITOR: "" + GIT_PAGER: "" LANG: C LC_ALL: C LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" @@ -241,29 +241,31 @@ Query structured data with --format=json: Fields: - Field Type Description - ────────────────── ─────────── ────────────────────────────────────────────────────────────────────── - branch string/null Branch name (null for detached HEAD) - path string Worktree path (absent for branches without worktrees) - kind string "worktree" or "branch" - commit object Commit info (see below) - working_tree object Working tree state (see below) - main_state string Relation to the default branch (see below) - integration_reason string Why branch is integrated (see below) - operation_state string "conflicts", "rebase", or "merge"; absent when clean - main object Relationship to the default branch (see below); absent when is_main - remote object Tracking branch info (see below); absent when no tracking - worktree object Worktree metadata (see below) - is_main boolean Is the main worktree - is_current boolean Is the current worktree - is_previous boolean Previous worktree from wt switch - ci object CI status (see below); absent when no CI - url string Dev server URL from project config; absent when not configured - url_active boolean Whether the URL's port is listening; absent when not configured - summary string LLM-generated branch summary; absent when not configured or no summary - statusline string Pre-formatted status with ANSI colors - symbols string Raw status symbols without colors (e.g., "!?↓") - vars object Per-branch variables from wt config state vars (absent when empty) + Field Type Description + ────────────────── ─────────── ─────────────────────────────────────────────────────────────────────────────────────────────── + branch string/null Branch name (null for detached HEAD) + path string Worktree path (absent for branches without worktrees) + kind string "worktree" or "branch" + commit object Commit info (see below) + working_tree object Working tree state (see below) + main_state string Relation to the default branch (see below) + integration_reason string Why branch is integrated (see below) + operation_state string "conflicts", "rebase", or "merge"; absent when clean + main object Relationship to the default branch (see below); absent when is_main + remote object Tracking branch info (see below); absent when no tracking + worktree object Worktree metadata (see below) + is_main boolean Is the main worktree + is_current boolean Is the current worktree + is_previous boolean Previous worktree from wt switch + ci object CI status (see below); absent when no CI + repo_url string Repository web URL derived from the primary remote; absent when the remote URL cannot be parsed + repo object Structured repository metadata (see below); includes remote + url string Dev server URL from project config; absent when not configured + url_active boolean Whether the URL's port is listening; absent when not configured + summary string LLM-generated branch summary; absent when not configured or no summary + statusline string Pre-formatted status with ANSI colors + symbols string Raw status symbols without colors (e.g., "!?↓") + vars object Per-branch variables from wt config state vars (absent when empty) Commit object @@ -312,12 +314,28 @@ Query structured data with --format=json: ci object - Field Type Description - ────── ─────── ───────────────────────────────────────────────── - status string CI status (see below) - source string "pr" (PR/MR) or "branch" (branch workflow) - stale boolean Local HEAD differs from remote (unpushed changes) - url string URL to the PR/MR page + Field Type Description + ──────── ─────── ─────────────────────────────────────────────────────────────────────────────── + status string CI status (see below) + source string "pr" (PR/MR) or "branch" (branch workflow) + stale boolean Local HEAD differs from remote (unpushed changes) + url string URL to the PR/MR page + repo_url string Web URL of the repository the PR/MR targets; absent for branch workflow URLs + repo object Structured metadata for the repository the PR/MR targets; never includes remote + +repo object + +Top-level repo describes the local checkout's repository as derived from the primary remote. ci.repo describes the repository targeted by the PR/MR URL in ci.url (for fork PRs, this is the upstream target). Existing repo_url and ci.repo_url fields remain available and carry the same URL as repo.url / ci.repo.url. + + Field Type Description + ──────── ────── ─────────────────────────────────────────────────────────────────────── + url string Repository web URL + provider string "github", "gitlab", "gitea", "azure-devops", or "unknown" + host string Repository web host + owner string Owner, organization, or namespace path + name string Repository name + project string Azure DevOps project name; absent for other providers + remote string Local remote name used for top-level repo metadata; absent from ci.repo main_state values diff --git a/tests/snapshots/integration__integration_tests__help__help_list_narrow_80.snap b/tests/snapshots/integration__integration_tests__help__help_list_narrow_80.snap index 5da1693e49..b58e1101a7 100644 --- a/tests/snapshots/integration__integration_tests__help__help_list_narrow_80.snap +++ b/tests/snapshots/integration__integration_tests__help__help_list_narrow_80.snap @@ -8,7 +8,7 @@ info: env: CLICOLOR_FORCE: "1" COLUMNS: "80" - GIT_EDITOR: "" + GIT_PAGER: "" LANG: C LC_ALL: C LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" @@ -304,6 +304,11 @@ Query structured data with --format=json: is_current boolean Is the current worktree is_previous boolean Previous worktree from wt switch ci object CI status (see below); absent when no CI + repo_url string Repository web URL derived from the primary + remote; absent when the remote URL cannot be + parsed + repo object Structured repository metadata (see below); + includes remote url string Dev server URL from project config; absent when not configured url_active boolean Whether the URL's port is listening; absent @@ -364,12 +369,35 @@ Query structured data with --format=json: ci object - Field Type Description - ────── ─────── ───────────────────────────────────────────────── - status string CI status (see below) - source string "pr" (PR/MR) or "branch" (branch workflow) - stale boolean Local HEAD differs from remote (unpushed changes) - url string URL to the PR/MR page + Field Type Description + ──────── ─────── ───────────────────────────────────────────────────────────── + status string CI status (see below) + source string "pr" (PR/MR) or "branch" (branch workflow) + stale boolean Local HEAD differs from remote (unpushed changes) + url string URL to the PR/MR page + repo_url string Web URL of the repository the PR/MR targets; absent for + branch workflow URLs + repo object Structured metadata for the repository the PR/MR targets; + never includes remote + +repo object + +Top-level repo describes the local checkout's repository as derived from the +primary remote. ci.repo describes the repository targeted by the PR/MR URL in +ci.url (for fork PRs, this is the upstream target). Existing repo_url and +ci.repo_url fields remain available and carry the same URL as repo.url / +ci.repo.url. + + Field Type Description + ──────── ────── ────────────────────────────────────────────────────────────── + url string Repository web URL + provider string "github", "gitlab", "gitea", "azure-devops", or "unknown" + host string Repository web host + owner string Owner, organization, or namespace path + name string Repository name + project string Azure DevOps project name; absent for other providers + remote string Local remote name used for top-level repo metadata; absent + from ci.repo main_state values From 99dd4c4d77634b5e9ebe4f4a0054b1b2c07f0007 Mon Sep 17 00:00:00 2001 From: Jeremy Odell Date: Tue, 9 Jun 2026 13:10:21 -0400 Subject: [PATCH 2/4] test: normalize help snapshot git env --- .../integration__integration_tests__help__help_list_long.snap | 2 +- ...tegration__integration_tests__help__help_list_narrow_80.snap | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/snapshots/integration__integration_tests__help__help_list_long.snap b/tests/snapshots/integration__integration_tests__help__help_list_long.snap index d3234ba8f3..424028b6ee 100644 --- a/tests/snapshots/integration__integration_tests__help__help_list_long.snap +++ b/tests/snapshots/integration__integration_tests__help__help_list_long.snap @@ -8,7 +8,7 @@ info: env: CLICOLOR_FORCE: "1" COLUMNS: "500" - GIT_PAGER: "" + GIT_EDITOR: "" LANG: C LC_ALL: C LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" diff --git a/tests/snapshots/integration__integration_tests__help__help_list_narrow_80.snap b/tests/snapshots/integration__integration_tests__help__help_list_narrow_80.snap index b58e1101a7..160349060e 100644 --- a/tests/snapshots/integration__integration_tests__help__help_list_narrow_80.snap +++ b/tests/snapshots/integration__integration_tests__help__help_list_narrow_80.snap @@ -8,7 +8,7 @@ info: env: CLICOLOR_FORCE: "1" COLUMNS: "80" - GIT_PAGER: "" + GIT_EDITOR: "" LANG: C LC_ALL: C LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" From a61dfae92b9b07229541daaaaad41506e07d4017 Mon Sep 17 00:00:00 2001 From: Jeremy Odell Date: Wed, 10 Jun 2026 11:25:22 -0400 Subject: [PATCH 3/4] Collapse repo provider JSON enum --- src/commands/list/json_output.rs | 61 +++----------------------------- src/git/url.rs | 25 ++++++++++++- 2 files changed, 29 insertions(+), 57 deletions(-) diff --git a/src/commands/list/json_output.rs b/src/commands/list/json_output.rs index ae44bf959a..73001ca51e 100644 --- a/src/commands/list/json_output.rs +++ b/src/commands/list/json_output.rs @@ -254,7 +254,7 @@ pub struct JsonRepo { pub url: String, /// Forge provider. - pub provider: JsonRepoProvider, + pub provider: GitRepoProvider, /// Repository web host. pub host: String, @@ -274,26 +274,11 @@ pub struct JsonRepo { pub remote: Option, } -/// Repository provider in JSON output. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, JsonSchema)] -pub enum JsonRepoProvider { - #[serde(rename = "github")] - GitHub, - #[serde(rename = "gitlab")] - GitLab, - #[serde(rename = "gitea")] - Gitea, - #[serde(rename = "azure-devops")] - AzureDevOps, - #[serde(rename = "unknown")] - Unknown, -} - impl JsonRepo { pub(crate) fn from_git_repo_info(info: GitRepoInfo, remote: Option) -> Self { Self { url: info.url, - provider: JsonRepoProvider::from(info.provider), + provider: info.provider, host: info.host, owner: info.owner, name: info.name, @@ -303,18 +288,6 @@ impl JsonRepo { } } -impl From for JsonRepoProvider { - fn from(provider: GitRepoProvider) -> Self { - match provider { - GitRepoProvider::GitHub => Self::GitHub, - GitRepoProvider::GitLab => Self::GitLab, - GitRepoProvider::Gitea => Self::Gitea, - GitRepoProvider::AzureDevOps => Self::AzureDevOps, - GitRepoProvider::Unknown => Self::Unknown, - } - } -} - impl JsonItem { /// Convert a ListItem to the new JSON structure /// @@ -690,7 +663,7 @@ mod tests { passed.repo, Some(JsonRepo { url: "https://github.com/org/repo".to_string(), - provider: JsonRepoProvider::GitHub, + provider: GitRepoProvider::GitHub, host: "github.com".to_string(), owner: "org".to_string(), name: "repo".to_string(), @@ -1068,7 +1041,7 @@ mod tests { let repo = JsonRepo { url: "https://github.com/owner/repo".to_string(), - provider: JsonRepoProvider::GitHub, + provider: GitRepoProvider::GitHub, host: "github.com".to_string(), owner: "owner".to_string(), name: "repo".to_string(), @@ -1114,7 +1087,7 @@ mod tests { repo_url: Some("https://github.com/org/repo".to_string()), repo: Some(JsonRepo { url: "https://github.com/org/repo".to_string(), - provider: JsonRepoProvider::GitHub, + provider: GitRepoProvider::GitHub, host: "github.com".to_string(), owner: "org".to_string(), name: "repo".to_string(), @@ -1140,28 +1113,4 @@ mod tests { } "#); } - - #[test] - fn test_json_repo_provider_from_git_repo_provider() { - assert_eq!( - JsonRepoProvider::from(GitRepoProvider::GitHub), - JsonRepoProvider::GitHub - ); - assert_eq!( - JsonRepoProvider::from(GitRepoProvider::GitLab), - JsonRepoProvider::GitLab - ); - assert_eq!( - JsonRepoProvider::from(GitRepoProvider::Gitea), - JsonRepoProvider::Gitea - ); - assert_eq!( - JsonRepoProvider::from(GitRepoProvider::AzureDevOps), - JsonRepoProvider::AzureDevOps - ); - assert_eq!( - JsonRepoProvider::from(GitRepoProvider::Unknown), - JsonRepoProvider::Unknown - ); - } } diff --git a/src/git/url.rs b/src/git/url.rs index 6beaa820f0..e503a67e1d 100644 --- a/src/git/url.rs +++ b/src/git/url.rs @@ -3,6 +3,9 @@ //! Parses git remote URLs into structured components (host, owner, repo). //! Supports HTTPS, SSH, and git@ URL formats. +use schemars::JsonSchema; +use serde::Serialize; + use super::ci_platform::CiPlatform; /// Parsed, provider-neutral repository metadata. @@ -24,12 +27,17 @@ pub struct GitRepoInfo { } /// Supported forge providers for repository metadata. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, JsonSchema)] pub enum GitRepoProvider { + #[serde(rename = "github")] GitHub, + #[serde(rename = "gitlab")] GitLab, + #[serde(rename = "gitea")] Gitea, + #[serde(rename = "azure-devops")] AzureDevOps, + #[serde(rename = "unknown")] Unknown, } @@ -1284,6 +1292,21 @@ mod tests { assert_eq!(url.azure_project(), None); } + #[test] + fn git_repo_provider_serializes_json_values() { + let cases = [ + (GitRepoProvider::GitHub, "\"github\""), + (GitRepoProvider::GitLab, "\"gitlab\""), + (GitRepoProvider::Gitea, "\"gitea\""), + (GitRepoProvider::AzureDevOps, "\"azure-devops\""), + (GitRepoProvider::Unknown, "\"unknown\""), + ]; + + for (provider, expected) in cases { + assert_eq!(serde_json::to_string(&provider).unwrap(), expected); + } + } + #[test] fn repo_info_from_remote_github_https_and_ssh() { for input in [ From c5e42861707d3f2e5266c6e9ea7c47e4194273cb Mon Sep 17 00:00:00 2001 From: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com> Date: Thu, 11 Jun 2026 17:13:53 +0000 Subject: [PATCH 4/4] refactor(list): collapse JsonRepo into GitRepoInfo JsonRepo mirrored GitRepoInfo field-for-field, with only an extra `remote`, plus a manual `from_git_repo_info` mapping. Move `remote` onto GitRepoInfo and serialize it directly, so a single canonical struct backs both the `repo` and `ci.repo` JSON objects. Repository::repo_info() now populates GitRepoInfo::remote and returns just the struct. Co-Authored-By: Claude Opus 4.8 --- src/commands/list/json_output.rs | 73 ++++++-------------------------- src/commands/statusline.rs | 4 +- src/git/remote_ref/mod.rs | 3 ++ src/git/repository/remotes.rs | 13 +++--- src/git/url.rs | 15 ++++++- 5 files changed, 37 insertions(+), 71 deletions(-) diff --git a/src/commands/list/json_output.rs b/src/commands/list/json_output.rs index 73001ca51e..04d8e9df0b 100644 --- a/src/commands/list/json_output.rs +++ b/src/commands/list/json_output.rs @@ -20,7 +20,7 @@ use std::path::PathBuf; use schemars::JsonSchema; use serde::Serialize; -use worktrunk::git::{GitRepoInfo, GitRepoProvider, LineDiff, Repository}; +use worktrunk::git::{GitRepoInfo, LineDiff, Repository}; use super::ci_status::{CiSource, PrStatus}; use super::model::{ItemKind, ListItem, UpstreamStatus}; @@ -92,7 +92,7 @@ pub struct JsonItem { /// Structured repository metadata derived from the primary remote. #[serde(skip_serializing_if = "Option::is_none")] - pub repo: Option, + pub repo: Option, /// Dev server URL from project config template #[serde(skip_serializing_if = "Option::is_none")] @@ -244,48 +244,7 @@ pub struct JsonCi { /// Structured metadata for the repository the PR/MR targets, derived from `url`. #[serde(skip_serializing_if = "Option::is_none")] - pub repo: Option, -} - -/// Structured repository metadata. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)] -pub struct JsonRepo { - /// Repository web URL. - pub url: String, - - /// Forge provider. - pub provider: GitRepoProvider, - - /// Repository web host. - pub host: String, - - /// Repository owner, organization, or namespace path. - pub owner: String, - - /// Repository name. - pub name: String, - - /// Azure DevOps project name. - #[serde(skip_serializing_if = "Option::is_none")] - pub project: Option, - - /// Local remote name used for top-level repo metadata. - #[serde(skip_serializing_if = "Option::is_none")] - pub remote: Option, -} - -impl JsonRepo { - pub(crate) fn from_git_repo_info(info: GitRepoInfo, remote: Option) -> Self { - Self { - url: info.url, - provider: info.provider, - host: info.host, - owner: info.owner, - name: info.name, - project: info.project, - remote, - } - } + pub repo: Option, } impl JsonItem { @@ -299,7 +258,7 @@ impl JsonItem { pub fn from_list_item( item: &ListItem, all_vars: &mut HashMap>, - repo: Option<&JsonRepo>, + repo: Option<&GitRepoInfo>, ci_provider_override: Option<&str>, ) -> Self { let (kind_str, worktree_data) = match &item.kind { @@ -501,16 +460,9 @@ impl From<&PrStatus> for JsonCi { impl JsonCi { fn from_pr_status(pr: &PrStatus, provider_override: Option<&str>) -> Self { - let repo = pr - .url - .as_deref() - .and_then(|url| { - worktrunk::git::remote_ref::repo_info_from_ref_url_with_provider( - url, - provider_override, - ) - }) - .map(|info| JsonRepo::from_git_repo_info(info, None)); + let repo = pr.url.as_deref().and_then(|url| { + worktrunk::git::remote_ref::repo_info_from_ref_url_with_provider(url, provider_override) + }); Self { status: pr.ci_status.into(), source: pr.source, @@ -581,9 +533,7 @@ fn format_raw_symbols(symbols: &super::model::StatusSymbols) -> String { /// Fetches all vars data in a single git call, then distributes per-branch. pub fn to_json_items(items: &[ListItem], repo: &Repository) -> Vec { let mut all_vars = repo.all_vars_entries(); - let repo_metadata = repo - .repo_info() - .map(|(remote, info)| JsonRepo::from_git_repo_info(info, Some(remote))); + let repo_metadata = repo.repo_info(); let ci_provider_override = repo .project_config() .ok() @@ -605,6 +555,7 @@ pub fn to_json_items(items: &[ListItem], repo: &Repository) -> Vec { #[cfg(test)] mod tests { use insta::assert_snapshot; + use worktrunk::git::GitRepoProvider; use super::*; use crate::commands::list::ci_status::CiStatus; @@ -661,7 +612,7 @@ mod tests { ); assert_eq!( passed.repo, - Some(JsonRepo { + Some(GitRepoInfo { url: "https://github.com/org/repo".to_string(), provider: GitRepoProvider::GitHub, host: "github.com".to_string(), @@ -1039,7 +990,7 @@ mod tests { assert!(json_item.repo_url.is_none()); assert!(json_item.repo.is_none()); - let repo = JsonRepo { + let repo = GitRepoInfo { url: "https://github.com/owner/repo".to_string(), provider: GitRepoProvider::GitHub, host: "github.com".to_string(), @@ -1085,7 +1036,7 @@ mod tests { stale: false, url: Some("https://github.com/org/repo/pull/7".to_string()), repo_url: Some("https://github.com/org/repo".to_string()), - repo: Some(JsonRepo { + repo: Some(GitRepoInfo { url: "https://github.com/org/repo".to_string(), provider: GitRepoProvider::GitHub, host: "github.com".to_string(), diff --git a/src/commands/statusline.rs b/src/commands/statusline.rs index 05a4a0cb9f..b242f1cafa 100644 --- a/src/commands/statusline.rs +++ b/src/commands/statusline.rs @@ -714,9 +714,7 @@ fn run_json() -> Result<()> { all_vars.insert(branch.clone(), entries); } } - let repo_metadata = repo - .repo_info() - .map(|(remote, info)| json_output::JsonRepo::from_git_repo_info(info, Some(remote))); + let repo_metadata = repo.repo_info(); let ci_provider_override = repo .project_config() .ok() diff --git a/src/git/remote_ref/mod.rs b/src/git/remote_ref/mod.rs index f7972686f7..20fa3f66a0 100644 --- a/src/git/remote_ref/mod.rs +++ b/src/git/remote_ref/mod.rs @@ -440,6 +440,7 @@ pub fn repo_info_from_ref_url_with_provider( owner, name, project: Some(project), + remote: None, }); } @@ -452,6 +453,7 @@ pub fn repo_info_from_ref_url_with_provider( owner, name, project: None, + remote: None, }); } @@ -464,6 +466,7 @@ pub fn repo_info_from_ref_url_with_provider( owner, name, project: None, + remote: None, }) } diff --git a/src/git/repository/remotes.rs b/src/git/repository/remotes.rs index e83178a0f5..db003d080f 100644 --- a/src/git/repository/remotes.rs +++ b/src/git/repository/remotes.rs @@ -299,10 +299,10 @@ impl Repository { /// /// Local-only: built from the cached primary remote URL and optional /// `[forge].platform`, with no network access. This may load/cache project - /// config to inspect the configured forge platform. Returns the remote name - /// alongside metadata so structured JSON can report which local remote was - /// used. - pub fn repo_info(&self) -> Option<(String, GitRepoInfo)> { + /// config to inspect the configured forge platform. The returned + /// [`GitRepoInfo::remote`] names which local remote was used, so structured + /// JSON can report it. + pub fn repo_info(&self) -> Option { let remote = self.primary_remote().ok()?; let url = self.remote_url(&remote)?; let parsed = GitRemoteUrl::parse(&url)?; @@ -311,8 +311,9 @@ impl Repository { .ok() .flatten() .and_then(|config| config.forge_platform().map(str::to_string)); - let info = parsed.repo_info(provider_override.as_deref())?; - Some((remote, info)) + let mut info = parsed.repo_info(provider_override.as_deref())?; + info.remote = Some(remote); + Some(info) } /// Parsed URL of the remote that belongs to a particular forge. diff --git a/src/git/url.rs b/src/git/url.rs index e503a67e1d..8503964b2f 100644 --- a/src/git/url.rs +++ b/src/git/url.rs @@ -9,7 +9,11 @@ use serde::Serialize; use super::ci_platform::CiPlatform; /// Parsed, provider-neutral repository metadata. -#[derive(Debug, Clone, PartialEq, Eq)] +/// +/// This is the single shape behind the `repo` / `ci.repo` JSON objects of +/// `wt list`; serde controls the field rename/skip rules so there is no +/// parallel output-only struct. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)] pub struct GitRepoInfo { /// Repository web URL. pub url: String, @@ -23,7 +27,13 @@ pub struct GitRepoInfo { /// Repository name. pub name: String, /// Azure DevOps project name. + #[serde(skip_serializing_if = "Option::is_none")] pub project: Option, + /// Local git remote this metadata was derived from. Set only for the + /// top-level `repo` of `wt list`; absent for PR/MR-URL-derived metadata + /// such as `ci.repo`. + #[serde(skip_serializing_if = "Option::is_none")] + pub remote: Option, } /// Supported forge providers for repository metadata. @@ -361,6 +371,7 @@ impl GitRemoteUrl { owner: organization, name: self.repo.clone(), project: Some(project), + remote: None, }); } @@ -374,6 +385,7 @@ impl GitRemoteUrl { owner: self.owner.clone(), name: self.repo.clone(), project: None, + remote: None, }); } } @@ -385,6 +397,7 @@ impl GitRemoteUrl { owner: self.owner.clone(), name: self.repo.clone(), project: None, + remote: None, }) }