Skip to main content

runtime_cli/commands/
repo.rs

1//! `runtime repo` — list / view / clone.
2//!
3//! `list` returns repositories visible to the configured account
4//! (filtered down to those whose `ownerSlug` matches the configured
5//! username for the "my repos" feel). `view` prints the repo's
6//! metadata. `clone` shells out to `git clone` with the HTTPS URL
7//! derived from the configured host (`<host>/<owner>/<name>.git`).
8
9use std::io::Write;
10
11use anyhow::{anyhow, Context, Result};
12use clap::{Args, Subcommand};
13use serde_json::{json, Value};
14
15use crate::client;
16use crate::commands::resolve_repo_slug;
17use crate::output::{render_record, render_table, FormatChoice};
18
19#[derive(Debug, Subcommand)]
20pub enum RepoCmd {
21    /// List repositories owned by the configured account.
22    List(ListArgs),
23    /// Show metadata for a single repository.
24    View(ViewArgs),
25    /// Clone a repository via `git clone`.
26    Clone(CloneArgs),
27}
28
29#[derive(Debug, Args)]
30pub struct ListArgs {
31    /// Include repositories visible to the viewer but not owned by them.
32    #[arg(long)]
33    pub all: bool,
34    #[arg(long, default_value_t = false)]
35    pub json: bool,
36    #[arg(long, default_value_t = false)]
37    pub tsv: bool,
38}
39
40#[derive(Debug, Args)]
41pub struct ViewArgs {
42    /// `owner/name` slug.
43    pub slug: String,
44    #[arg(long, default_value_t = false)]
45    pub json: bool,
46    #[arg(long, default_value_t = false)]
47    pub tsv: bool,
48}
49
50#[derive(Debug, Args)]
51pub struct CloneArgs {
52    /// `owner/name` slug.
53    pub slug: String,
54    /// Destination directory. Defaults to `name`.
55    pub dest: Option<String>,
56}
57
58pub fn run(cmd: RepoCmd) -> Result<()> {
59    match cmd {
60        RepoCmd::List(a) => list(a),
61        RepoCmd::View(a) => view(a),
62        RepoCmd::Clone(a) => clone(a),
63    }
64}
65
66fn list(args: ListArgs) -> Result<()> {
67    let (cli, cfg) = client::authenticated()?;
68    let data = cli.execute(LIST_REPOS, json!({}))?;
69    let repos = data["repositories"]
70        .as_array()
71        .ok_or_else(|| anyhow!("repositories field missing"))?;
72    let rows = repo_rows(repos, &cfg.username, args.all);
73    let mut stdout = std::io::stdout().lock();
74    let fmt = FormatChoice {
75        json: args.json,
76        tsv: args.tsv,
77    }
78    .resolve();
79    stdout
80        .write_all(render_table(&["repo", "visibility", "description"], &rows, fmt).as_bytes())?;
81    Ok(())
82}
83
84fn view(args: ViewArgs) -> Result<()> {
85    let (cli, _cfg) = client::authenticated()?;
86    let (owner, name) = resolve_repo_slug(&args.slug)?;
87    let data = cli.execute(LIST_REPOS, json!({}))?;
88    let repos = data["repositories"]
89        .as_array()
90        .ok_or_else(|| anyhow!("repositories field missing"))?;
91    let r = repos
92        .iter()
93        .find(|r| r["ownerSlug"].as_str() == Some(&owner) && r["name"].as_str() == Some(&name))
94        .ok_or_else(|| anyhow!("no repository named {}/{}", owner, name))?;
95    let pairs = repo_record_pairs(r, &owner, &name);
96    let mut stdout = std::io::stdout().lock();
97    let fmt = FormatChoice {
98        json: args.json,
99        tsv: args.tsv,
100    }
101    .resolve();
102    stdout.write_all(render_record(&pairs, fmt).as_bytes())?;
103    Ok(())
104}
105
106fn clone(args: CloneArgs) -> Result<()> {
107    let (cli, _cfg) = client::authenticated()?;
108    // clone always needs an explicit slug — there's no "the repo we're
109    // in" semantics for the thing we're about to materialize.
110    let (owner, name) = crate::commands::parse_slug(&args.slug)?;
111    let url = format!("{}/{}/{}.git", cli.host(), owner, name);
112    let dest = args.dest.unwrap_or_else(|| name.clone());
113    let status = std::process::Command::new("git")
114        .arg("clone")
115        .arg(&url)
116        .arg(&dest)
117        .status()
118        .context("run `git clone`")?;
119    if !status.success() {
120        return Err(anyhow!("git clone exited with status {status}"));
121    }
122    Ok(())
123}
124
125const LIST_REPOS: &str =
126    "{ repositories { id ownerSlug name description visibility defaultBranch updatedAt } }";
127
128fn repo_rows(repos: &[Value], username: &str, include_all: bool) -> Vec<Vec<String>> {
129    repos
130        .iter()
131        .filter(|r| include_all || r["ownerSlug"].as_str() == Some(username))
132        .map(|r| {
133            vec![
134                format!(
135                    "{}/{}",
136                    r["ownerSlug"].as_str().unwrap_or(""),
137                    r["name"].as_str().unwrap_or("")
138                ),
139                r["visibility"].as_str().unwrap_or("").to_string(),
140                r["description"].as_str().unwrap_or("").to_string(),
141            ]
142        })
143        .collect()
144}
145
146fn repo_record_pairs(repo: &Value, owner: &str, name: &str) -> Vec<(&'static str, String)> {
147    vec![
148        ("Repo", format!("{owner}/{name}")),
149        (
150            "Visibility",
151            repo["visibility"].as_str().unwrap_or("").to_string(),
152        ),
153        (
154            "Default branch",
155            repo["defaultBranch"].as_str().unwrap_or("").to_string(),
156        ),
157        (
158            "Description",
159            repo["description"].as_str().unwrap_or("").to_string(),
160        ),
161        (
162            "Updated",
163            repo["updatedAt"].as_str().unwrap_or("").to_string(),
164        ),
165    ]
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    #[test]
173    fn repo_rows_filter_to_viewer_unless_all_is_set() {
174        let repos = vec![
175            json!({
176                "ownerSlug": "bri",
177                "name": "runtime",
178                "visibility": "PUBLIC",
179                "description": "Runtime platform"
180            }),
181            json!({
182                "ownerSlug": "runtime-labs",
183                "name": "ops",
184                "visibility": "PRIVATE",
185                "description": "Operations"
186            }),
187            json!({}),
188        ];
189
190        assert_eq!(
191            repo_rows(&repos, "bri", false),
192            vec![vec!["bri/runtime", "PUBLIC", "Runtime platform"]]
193        );
194        assert_eq!(
195            repo_rows(&repos, "bri", true),
196            vec![
197                vec!["bri/runtime", "PUBLIC", "Runtime platform"],
198                vec!["runtime-labs/ops", "PRIVATE", "Operations"],
199                vec!["/", "", ""],
200            ]
201        );
202    }
203
204    #[test]
205    fn repo_record_pairs_format_defaults_without_leaking_api_shape() {
206        let repo = json!({
207            "visibility": "PRIVATE",
208            "defaultBranch": "trunk",
209            "description": "A repo",
210            "updatedAt": "2026-06-18T01:00:00Z"
211        });
212
213        assert_eq!(
214            repo_record_pairs(&repo, "bri", "runtime"),
215            vec![
216                ("Repo", "bri/runtime".to_string()),
217                ("Visibility", "PRIVATE".to_string()),
218                ("Default branch", "trunk".to_string()),
219                ("Description", "A repo".to_string()),
220                ("Updated", "2026-06-18T01:00:00Z".to_string()),
221            ]
222        );
223
224        assert_eq!(
225            repo_record_pairs(&json!({}), "owner", "repo"),
226            vec![
227                ("Repo", "owner/repo".to_string()),
228                ("Visibility", String::new()),
229                ("Default branch", String::new()),
230                ("Description", String::new()),
231                ("Updated", String::new()),
232            ]
233        );
234    }
235}