Skip to content
17 changes: 17 additions & 0 deletions docs/content/list.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,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` |

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand this, vs. the "never includes remote" phrase below? Can you explain?

Ideally we would canonicalize onto repo, deprecate the repo_url variable in favor of repo.url.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remote here isn't repo data — it's a single string naming the local git remote the metadata was derived from (e.g. "origin"). See GitRepoInfo: the field is Option<String>, "Set only for the top-level repo of wt list; absent for PR/MR-URL-derived metadata such as ci.repo."

The two phrases describe where that name can exist:

  • Top-level repo is built in Repository::repo_info() from self.primary_remote() → info.remote = Some(remote). It came from a local remote, so it can report which one → "includes remote".
  • ci.repo is derived from the PR/MR URL (for fork PRs, the upstream target), not from any local remote, so there's no remote name to attach → "never includes remote".

So the asymmetry is exactly that one field. I can make the docs clearer — the column descriptions read like remote is a sub-object. Something like "names the local git remote it was derived from (e.g. origin)" on the repo row, dropping the bare "includes remote" / "never includes remote" phrasing, would be less ambiguous.

On canonicalizing onto repo and deprecating repo_url/ci.repo_url in favor of repo.url/ci.repo.url: agreed that's the cleaner end state. It's orthogonal to this PR (which keeps them as compat fields), so I'd suggest a follow-up rather than folding it in here — and since these are documented JSON fields, dropping them is a soft break for consumers, so a deprecation-noted transition (keep emitting, document as deprecated, remove later) reads better than a hard cut. Happy to open that follow-up PR if you'd like it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree that canonicalizing onto repo would improve the API but yeah, I wouldn't want anything to break

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@max-sixty Just checking, do you want me to close this? I can make a PR canonicalizing onto repo or just leave the API alone for now and parse everything on my end

| `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 |
Expand Down Expand Up @@ -245,8 +247,23 @@ Query structured data with `--format=json`:
| `stale` | boolean | Local HEAD differs from remote (unpushed changes) |
| `url` | string | URL to the PR/MR page |
| `repo_url` | string | Web URL of the repo the PR/MR targets (the upstream for fork PRs); absent when `url` is absent or unrecognized |
| `repo` | object | Structured metadata for the repository the PR/MR targets; never includes `remote` |
| `review_state` | string | Review state (see below); absent when the forge reports no review signal |

### 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

These values describe the relation to the default branch.
Expand Down
17 changes: 17 additions & 0 deletions skills/worktrunk/reference/list.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 17 additions & 0 deletions src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -881,6 +881,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 |
Expand Down Expand Up @@ -943,8 +945,23 @@ $ wt list --format=json --full | jq '.[] | select(.ci.stale) | .branch'
| `stale` | boolean | Local HEAD differs from remote (unpushed changes) |
| `url` | string | URL to the PR/MR page |
| `repo_url` | string | Web URL of the repo the PR/MR targets (the upstream for fork PRs); absent when `url` is absent or unrecognized |
| `repo` | object | Structured metadata for the repository the PR/MR targets; never includes `remote` |
| `review_state` | string | Review state (see below); absent when the forge reports no review signal |

### 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

These values describe the relation to the default branch.
Expand Down
111 changes: 90 additions & 21 deletions src/commands/list/json_output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ use std::path::PathBuf;

use schemars::JsonSchema;
use serde::Serialize;
use worktrunk::git::{LineDiff, Repository};
use worktrunk::git::{GitRepoInfo, LineDiff, Repository};

use super::ci_status::{CiSource, PrStatus, ReviewState};
use super::model::{ItemKind, ListItem, UpstreamStatus};
Expand Down Expand Up @@ -84,12 +84,16 @@ pub struct JsonItem {
#[serde(skip_serializing_if = "Option::is_none")]
pub ci: Option<JsonCi>,

/// 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<String>,

/// Structured repository metadata derived from the primary remote.
#[serde(skip_serializing_if = "Option::is_none")]
pub repo: Option<GitRepoInfo>,

/// Dev server URL from project config template
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
Expand Down Expand Up @@ -242,6 +246,10 @@ pub struct JsonCi {
#[serde(skip_serializing_if = "Option::is_none")]
pub repo_url: Option<String>,

/// Structured metadata for the repository the PR/MR targets, derived from `url`.
#[serde(skip_serializing_if = "Option::is_none")]
pub repo: Option<GitRepoInfo>,

/// Review state: "approved", "changes_requested", "pending", "draft"
/// (absent when the forge reports no review signal)
#[serde(skip_serializing_if = "Option::is_none")]
Expand All @@ -254,12 +262,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<String, BTreeMap<String, String>>,
repo_url: Option<&str>,
repo: Option<&GitRepoInfo>,
ci_provider_override: Option<&str>,
) -> Self {
let (kind_str, worktree_data) = match &item.kind {
ItemKind::Worktree(data) => ("worktree", Some(data.as_ref())),
Expand Down Expand Up @@ -361,7 +370,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();
Expand Down Expand Up @@ -393,7 +402,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,
Expand Down Expand Up @@ -453,15 +463,22 @@ 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)
});
Self {
status: pr.ci_status.into(),
source: pr.source,
number: pr.number.map(|r| r.number),
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(),
review_state: pr.review_state,
}
Expand Down Expand Up @@ -527,16 +544,29 @@ 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<JsonItem> {
let mut all_vars = repo.all_vars_entries();
let repo_url = repo.repo_web_url();
let repo_metadata = repo.repo_info();
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()
}

#[cfg(test)]
mod tests {
use insta::assert_snapshot;
use worktrunk::git::GitRepoProvider;

use super::*;
use crate::commands::list::ci_status::{CiStatus, PrRef};
Expand Down Expand Up @@ -593,6 +623,18 @@ mod tests {
passed.repo_url,
Some("https://github.com/org/repo".to_string())
);
assert_eq!(
passed.repo,
Some(GitRepoInfo {
url: "https://github.com/org/repo".to_string(),
provider: GitRepoProvider::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 {
Expand All @@ -607,8 +649,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 = [
Expand Down Expand Up @@ -958,17 +1001,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 = GitRepoInfo {
url: "https://github.com/owner/repo".to_string(),
provider: GitRepoProvider::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]
Expand All @@ -979,14 +1032,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()
);
Expand All @@ -1002,6 +1055,15 @@ mod tests {
url: Some("https://github.com/org/repo/pull/7".to_string()),
review_state: Some(ReviewState::ChangesRequested),
repo_url: Some("https://github.com/org/repo".to_string()),
repo: Some(GitRepoInfo {
url: "https://github.com/org/repo".to_string(),
provider: GitRepoProvider::GitHub,
host: "github.com".to_string(),
owner: "org".to_string(),
name: "repo".to_string(),
project: None,
remote: None,
}),
})
.unwrap();
// review_state vocabulary matches Claude Code's statusline `pr.review_state`
Expand All @@ -1013,6 +1075,13 @@ mod tests {
"stale": false,
"url": "https://github.com/org/repo/pull/7",
"repo_url": "https://github.com/org/repo",
"repo": {
"url": "https://github.com/org/repo",
"provider": "github",
"host": "github.com",
"owner": "org",
"name": "repo"
},
"review_state": "changes_requested"
}
"#);
Expand Down
14 changes: 12 additions & 2 deletions src/commands/statusline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -748,8 +748,18 @@ 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();
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])?;
Expand Down
2 changes: 1 addition & 1 deletion src/git/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
Loading
Loading