diff --git a/.claude/skills/detail-bugs/SKILL.md b/.claude/skills/detail-bugs/SKILL.md index 1e2aac9..0c91405 100644 --- a/.claude/skills/detail-bugs/SKILL.md +++ b/.claude/skills/detail-bugs/SKILL.md @@ -18,7 +18,7 @@ The user must be authenticated. Assume that the user is authed and run commands ## Repository Inference -The Detail CLI infers the repository from the git remote; if the user specifies a different repo, pass it explicitly to the CLI commands. +The Detail CLI infers the repository from the git or jj remote; if the user specifies a different repo, pass it explicitly to the CLI commands. ## Subcommands diff --git a/.claude/skills/detail-create-rules/SKILL.md b/.claude/skills/detail-create-rules/SKILL.md index d430549..724421c 100644 --- a/.claude/skills/detail-create-rules/SKILL.md +++ b/.claude/skills/detail-create-rules/SKILL.md @@ -9,7 +9,7 @@ Guide the user through creating one or more new rules for their repository. ## Determining the Repository -The Detail CLI infers the repository from the git remote; if the user specifies a different repo, pass it explicitly to the CLI commands. +The Detail CLI infers the repository from the git or jj remote; if the user specifies a different repo, pass it explicitly to the CLI commands. ## Prerequisites diff --git a/docs/HELP.md b/docs/HELP.md index 825b980..1120ef9 100644 --- a/docs/HELP.md +++ b/docs/HELP.md @@ -126,7 +126,7 @@ List bugs for a given repository ###### **Arguments:** -* `` — Repository by owner/repo (e.g., usedetail/cli) or repo (e.g., cli). If omitted, inferred from the git remote (origin) +* `` — Repository by owner/repo (e.g., usedetail/cli) or repo (e.g., cli). If omitted, inferred from the git or jj remote (origin) ###### **Options:** @@ -274,7 +274,7 @@ Submit a rule creation request for a repository ###### **Arguments:** -* `` — Repository by owner/repo (e.g., usedetail/cli) or repo name. If omitted, inferred from the git remote (origin) +* `` — Repository by owner/repo (e.g., usedetail/cli) or repo name. If omitted, inferred from the git or jj remote (origin) ###### **Options:** @@ -292,7 +292,7 @@ Ask Detail to propose rules for a repository ###### **Arguments:** -* `` — Repository by owner/repo (e.g., usedetail/cli) or repo name. If omitted, inferred from the git remote (origin) +* `` — Repository by owner/repo (e.g., usedetail/cli) or repo name. If omitted, inferred from the git or jj remote (origin) @@ -317,7 +317,7 @@ List rule creation requests for a repository ###### **Arguments:** -* `` — Repository by owner/repo (e.g., usedetail/cli) or repo name. If omitted, inferred from the git remote (origin) +* `` — Repository by owner/repo (e.g., usedetail/cli) or repo name. If omitted, inferred from the git or jj remote (origin) ###### **Options:** @@ -350,7 +350,7 @@ List completed rules for a repository ###### **Arguments:** -* `` — Repository by owner/repo (e.g., usedetail/cli) or repo name. If omitted, inferred from the git remote (origin) +* `` — Repository by owner/repo (e.g., usedetail/cli) or repo name. If omitted, inferred from the git or jj remote (origin) ###### **Options:** @@ -454,7 +454,7 @@ List recent scans for a repository ###### **Arguments:** -* `` — Repository in owner/repo format or just repo name. If omitted, inferred from the git remote (origin) +* `` — Repository in owner/repo format or just repo name. If omitted, inferred from the git or jj remote (origin) ###### **Options:** diff --git a/src/commands/bugs.rs b/src/commands/bugs.rs index d749145..5c26271 100644 --- a/src/commands/bugs.rs +++ b/src/commands/bugs.rs @@ -13,9 +13,9 @@ use crate::api::types::{ }; use crate::output::{output_list, SectionRenderer}; use crate::utils::datetime::{format_datetime, parse_time_spec}; -use crate::utils::git::resolve_repo_arg; use crate::utils::pagination::page_to_offset; use crate::utils::repos::resolve_repo_id; +use crate::utils::vcs::resolve_repo_arg; /// Return only bugs where `isSecurityVulnerability` is `true`. fn filter_vulns_only(bugs: &[Bug]) -> Vec { @@ -116,7 +116,7 @@ pub enum BugCommands { /// List bugs for a given repository List { /// Repository by owner/repo (e.g., usedetail/cli) or repo (e.g., cli). - /// If omitted, inferred from the git remote (origin). + /// If omitted, inferred from the git or jj remote (origin). repo: Option, /// Status filter — repeat the flag or comma-separate values to diff --git a/src/commands/rules.rs b/src/commands/rules.rs index cd12e03..f86db72 100644 --- a/src/commands/rules.rs +++ b/src/commands/rules.rs @@ -12,15 +12,15 @@ use crate::api::types::{ }; use crate::output::{output_list, Formattable, SectionRenderer}; use crate::utils::datetime::{format_date, format_datetime}; -use crate::utils::git::resolve_repo_arg; use crate::utils::repos::resolve_repo_id; +use crate::utils::vcs::resolve_repo_arg; #[derive(Subcommand)] pub enum RuleCommands { /// Submit a rule creation request for a repository Create { /// Repository by owner/repo (e.g., usedetail/cli) or repo name. - /// If omitted, inferred from the git remote (origin). + /// If omitted, inferred from the git or jj remote (origin). repo: Option, /// Description of the rule to create @@ -39,7 +39,7 @@ pub enum RuleCommands { /// Ask Detail to propose rules for a repository Propose { /// Repository by owner/repo (e.g., usedetail/cli) or repo name. - /// If omitted, inferred from the git remote (origin). + /// If omitted, inferred from the git or jj remote (origin). repo: Option, }, @@ -50,7 +50,7 @@ pub enum RuleCommands { /// List completed rules for a repository List { /// Repository by owner/repo (e.g., usedetail/cli) or repo name. - /// If omitted, inferred from the git remote (origin). + /// If omitted, inferred from the git or jj remote (origin). repo: Option, /// Output format @@ -80,7 +80,7 @@ pub enum RuleRequestCommands { /// List rule creation requests for a repository List { /// Repository by owner/repo (e.g., usedetail/cli) or repo name. - /// If omitted, inferred from the git remote (origin). + /// If omitted, inferred from the git or jj remote (origin). repo: Option, /// Output format diff --git a/src/commands/scans.rs b/src/commands/scans.rs index 128309f..a2081c2 100644 --- a/src/commands/scans.rs +++ b/src/commands/scans.rs @@ -5,16 +5,16 @@ use crate::api::client::ApiClient; use crate::api::types::{RepoId, Scan, ScanType, ScansResponse, WorkflowStatus}; use crate::output::output_list; use crate::utils::datetime::parse_time_spec; -use crate::utils::git::resolve_repo_arg; use crate::utils::pagination::page_to_offset; use crate::utils::repos::resolve_repo_id; +use crate::utils::vcs::resolve_repo_arg; #[derive(Subcommand)] pub enum ScanCommands { /// List recent scans for a repository List { /// Repository in owner/repo format or just repo name. - /// If omitted, inferred from the git remote (origin). + /// If omitted, inferred from the git or jj remote (origin). repo: Option, /// Filter by workflow status (e.g. failed scans in the last day). diff --git a/src/commands/skill.rs b/src/commands/skill.rs index 9505a8b..fe38aaf 100644 --- a/src/commands/skill.rs +++ b/src/commands/skill.rs @@ -1,12 +1,12 @@ use std::fs; use std::path::{Path, PathBuf}; -use std::process::Command; -use std::str; use anyhow::{Context, Result}; use clap::Subcommand; use homedir::my_home; +use crate::utils::vcs::repo_root; + const BUGS_SKILL_CONTENT: &str = include_str!("../../.claude/skills/detail-bugs/SKILL.md"); const RULES_SKILL_CONTENT: &str = include_str!("../../.claude/skills/detail-create-rules/SKILL.md"); @@ -17,12 +17,6 @@ pub enum SkillCommands { Rules, } -fn parse_git_root_output(success: bool, stdout: &[u8]) -> Result { - anyhow::ensure!(success, "not inside a git repository"); - let root = str::from_utf8(stdout).context("git output was not valid UTF-8")?; - Ok(PathBuf::from(root.trim())) -} - fn skill_install_path(base: &Path, skill_name: &str) -> PathBuf { base.join(".claude") .join("skills") @@ -36,14 +30,6 @@ fn user_home() -> Result { .context("home directory not found") } -fn git_root() -> Result { - let output = Command::new("git") - .args(["rev-parse", "--show-toplevel"]) - .output() - .context("failed to run git")?; - parse_git_root_output(output.status.success(), &output.stdout) -} - fn install_skill(repo_root: &Path, skill_name: &str, content: &str) -> Result<()> { let path = skill_install_path(repo_root, skill_name); let dir = path @@ -59,7 +45,7 @@ fn install_skill(repo_root: &Path, skill_name: &str, content: &str) -> Result<() } pub fn handle(command: Option<&SkillCommands>, user: bool) -> Result<()> { - let base = if user { user_home()? } else { git_root()? }; + let base = if user { user_home()? } else { repo_root()? }; match command { None => install_skill(&base, "detail-bugs", BUGS_SKILL_CONTENT), Some(SkillCommands::Rules) => { @@ -72,24 +58,6 @@ pub fn handle(command: Option<&SkillCommands>, user: bool) -> Result<()> { mod tests { use super::*; - #[test] - fn parse_git_root_trims_newline() { - let root = parse_git_root_output(true, b"/tmp/repo\n").unwrap(); - assert_eq!(root, PathBuf::from("/tmp/repo")); - } - - #[test] - fn parse_git_root_errors_when_not_in_repo() { - let err = parse_git_root_output(false, b"").unwrap_err(); - assert!(err.to_string().contains("not inside a git repository")); - } - - #[test] - fn parse_git_root_errors_on_invalid_utf8() { - let err = parse_git_root_output(true, &[0xff]).unwrap_err(); - assert!(err.to_string().contains("git output was not valid UTF-8")); - } - #[test] fn skill_install_path_is_repo_relative() { let path = skill_install_path(Path::new("/work/repo"), "detail-bugs"); diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 9103b85..e7de917 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,4 +1,4 @@ pub mod datetime; -pub mod git; pub mod pagination; pub mod repos; +pub mod vcs; diff --git a/src/utils/git.rs b/src/utils/vcs.rs similarity index 69% rename from src/utils/git.rs rename to src/utils/vcs.rs index 6fcae85..2c02fb8 100644 --- a/src/utils/git.rs +++ b/src/utils/vcs.rs @@ -1,6 +1,38 @@ -use anyhow::{bail, Result}; +use anyhow::{bail, Context, Result}; +use std::path::PathBuf; use std::process::Command; +/// Run `program` with `args`, returning trimmed stdout when the command +/// spawns, exits successfully, and prints valid UTF-8. +fn run_capture(program: &str, args: &[&str]) -> Option { + let output = Command::new(program).args(args).output().ok()?; + if !output.status.success() { + return None; + } + let stdout = String::from_utf8(output.stdout).ok()?; + Some(stdout.trim().to_string()) +} + +/// Find the root of the enclosing repository. +/// +/// Tries git first (which also covers colocated jj repos, since those have +/// a real `.git` directory), then falls back to jj for non-colocated jj +/// workspaces. +pub fn repo_root() -> Result { + run_capture("git", &["rev-parse", "--show-toplevel"]) + .or_else(|| run_capture("jj", &["root"])) + .map(PathBuf::from) + .context("not inside a git or jj repository") +} + +/// Extract the `origin` remote's URL from `jj git remote list` output, +/// which prints one `name url` pair per line. +fn parse_jj_remote_list(list: &str) -> Option { + list.lines() + .find_map(|line| line.strip_prefix("origin ")) + .map(|url| url.trim().to_string()) +} + /// Extract `owner/repo` from a GitHub remote URL. /// /// Supports HTTPS (`https://github.com/owner/repo.git`), @@ -70,41 +102,109 @@ fn strip_http_credentials(url: &str) -> Option { None } -/// Infer the `owner/repo` identifier from the current git repository by -/// checking the `origin` remote. +/// Infer the `owner/repo` identifier from the current repository by checking +/// the `origin` remote — via git first, then jj for non-colocated jj +/// workspaces. /// /// Returns `Ok(owner/repo)` on success, or an error if we are not inside a -/// git repository or the `origin` remote is not a recognisable GitHub URL. -pub fn infer_repo_from_git_remote() -> Result { - let output = Command::new("git") - .args(["remote", "get-url", "origin"]) - .output(); - - if let Ok(output) = output { - if output.status.success() { - let url = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if let Some(owner_repo) = parse_github_remote_url(&url) { - return Ok(owner_repo); - } - } +/// git or jj repository or the `origin` remote is not a recognisable GitHub +/// URL. +pub fn infer_repo_from_remote() -> Result { + let url = run_capture("git", &["remote", "get-url", "origin"]).or_else(|| { + run_capture("jj", &["git", "remote", "list"]) + .as_deref() + .and_then(parse_jj_remote_list) + }); + + if let Some(owner_repo) = url.as_deref().and_then(parse_github_remote_url) { + return Ok(owner_repo); } bail!( - "Could not infer repository from git remotes. \ + "Could not infer repository from git or jj remotes. \ Please pass a repo argument explicitly (e.g. owner/repo)." ) } -/// If `explicit` is `Some`, return it. Otherwise try to infer from the git -/// remote. Wraps the inference error to tell the user to supply the argument. +/// If `explicit` is `Some`, return it. Otherwise try to infer from the +/// repository remote. Wraps the inference error to tell the user to supply +/// the argument. pub fn resolve_repo_arg(explicit: Option<&str>) -> Result { - explicit.map_or_else(infer_repo_from_git_remote, |r| Ok(r.to_string())) + explicit.map_or_else(infer_repo_from_remote, |r| Ok(r.to_string())) } #[cfg(test)] mod tests { use super::*; + // ── run_capture ───────────────────────────────────────────────── + + #[test] + #[cfg(unix)] + fn run_capture_trims_trailing_newline() { + assert_eq!( + run_capture("echo", &["/tmp/repo"]), + Some("/tmp/repo".to_string()), + ); + } + + #[test] + #[cfg(unix)] + fn run_capture_returns_none_on_failure_status() { + assert_eq!(run_capture("false", &[]), None); + } + + #[test] + fn run_capture_returns_none_for_missing_program() { + assert_eq!( + run_capture("definitely-not-a-real-program-detail", &[]), + None + ); + } + + // ── parse_jj_remote_list ──────────────────────────────────────── + + #[test] + fn jj_remote_list_single_origin() { + assert_eq!( + parse_jj_remote_list("origin https://github.com/usedetail/cli.git"), + Some("https://github.com/usedetail/cli.git".to_string()), + ); + } + + #[test] + fn jj_remote_list_origin_among_multiple_remotes() { + let list = "fork git@github.com:someone/cli.git\n\ + origin git@github.com:usedetail/cli.git\n\ + upstream https://github.com/other/cli.git"; + assert_eq!( + parse_jj_remote_list(list), + Some("git@github.com:usedetail/cli.git".to_string()), + ); + } + + #[test] + fn jj_remote_list_without_origin() { + assert_eq!( + parse_jj_remote_list("upstream https://github.com/other/cli.git"), + None, + ); + } + + #[test] + fn jj_remote_list_empty() { + assert_eq!(parse_jj_remote_list(""), None); + } + + #[test] + fn jj_remote_list_prefix_name_does_not_match() { + // A remote named `originx` must not be mistaken for `origin`. + assert_eq!( + parse_jj_remote_list("originx https://github.com/other/cli.git"), + None, + ); + } + // ── parse_github_remote_url ───────────────────────────────────── #[test]