use anyhow::{anyhow, Result}; use clap::{Args, Subcommand}; use crate::api; use crate::cli::context::{resolve_repo, RepoFlag}; use crate::client::Client; use crate::config::hosts::Hosts; use crate::git; use crate::output; use super::editor; use super::web; #[derive(Debug, Args)] pub struct RepoCmd { #[command(subcommand)] pub command: RepoSub, } #[derive(Debug, Subcommand)] pub enum RepoSub { /// List repositories you have access to on the current host. List(ListArgs), /// View a repository. View(ViewArgs), /// Clone a repository over git. Clone(CloneArgs), /// Create a new repository. Create(CreateArgs), /// Fork a repository. Fork(ForkArgs), /// Sync a fork with its upstream default branch. Sync(SyncArgs), /// Edit a repository's description, visibility, default branch. Edit(EditArgs), /// Rename a repository (in-place). Rename(RenameArgs), /// Archive a repository (read-only). Archive(ArchiveArgs), /// Unarchive a repository. Unarchive(ArchiveArgs), /// Delete a repository. Destructive. Delete(DeleteArgs), /// List branches. Branches(BranchesArgs), /// Manage repo topics (tags). Topics(TopicsArgs), /// Migrate or mirror a repo from another git host. Mirror(MirrorArgs), /// Manually trigger a sync on a pull-mirror. MirrorSync(MirrorSyncArgs), } #[derive(Debug, Args)] pub struct ListArgs { #[arg(short = 'L', long, default_value_t = 30)] pub limit: u32, #[arg(long, default_value_t = 1)] pub page: u32, /// Search query (uses `/repos/search`). #[arg(short, long)] pub search: Option, /// Emit raw JSON instead of a table. #[arg(long)] pub json: bool, } #[derive(Debug, Args)] pub struct ViewArgs { /// `owner/name` slug. Inferred from the git remote when omitted. pub repo: Option, #[arg(long)] pub json: bool, /// Open the repo's web page. #[arg(long)] pub web: bool, } #[derive(Debug, Args)] pub struct CloneArgs { /// `owner/name` slug. pub repo: String, /// Optional destination directory. pub dir: Option, } #[derive(Debug, Args)] pub struct CreateArgs { /// `[owner/]name`. Without an owner, the authenticated user is used. pub repo: String, #[arg(short, long)] pub description: Option, #[arg(long, default_value_t = false)] pub private: bool, #[arg(long, default_value_t = false)] pub init: bool, /// Clone the new repo into the current directory. #[arg(long)] pub clone: bool, /// Open the new repo in your browser. #[arg(long)] pub web: bool, } #[derive(Debug, Args)] pub struct ForkArgs { /// Source repo `owner/name`. Inferred when omitted. pub repo: Option, /// Place the fork under this organization instead of your user account. #[arg(long)] pub org: Option, /// New repository name. #[arg(long)] pub name: Option, /// Clone the fork after creation. #[arg(long)] pub clone: bool, } #[derive(Debug, Args)] pub struct SyncArgs { pub repo: Option, /// Branch to sync. Defaults to the repo's default branch. #[arg(long)] pub branch: Option, } #[derive(Debug, Args)] pub struct EditArgs { pub repo: Option, #[arg(long)] pub description: Option, #[arg(long)] pub website: Option, #[arg(long)] pub default_branch: Option, /// Force private/public. Accepts `true` or `false`. #[arg(long)] pub private: Option, /// Open `$EDITOR` for the description. #[arg(long)] pub description_editor: bool, } #[derive(Debug, Args)] pub struct RenameArgs { pub repo: String, pub new_name: String, } #[derive(Debug, Args)] pub struct ArchiveArgs { pub repo: Option, } #[derive(Debug, Args)] pub struct DeleteArgs { pub repo: String, /// Skip the confirmation prompt. #[arg(short = 'y', long)] pub yes: bool, } #[derive(Debug, Args)] pub struct BranchesArgs { pub repo: Option, #[arg(long)] pub json: bool, } #[derive(Debug, Args)] pub struct TopicsArgs { pub repo: Option, /// Set the topic list (comma-separated). Omit to just print. #[arg(long)] pub set: Option, } #[derive(Debug, Args)] pub struct MirrorArgs { /// Source URL (e.g. `https://github.com/foo/bar.git`). pub source_url: String, /// Destination as `[owner/]name`. Defaults to the source's basename /// under your user account. #[arg(long)] pub dest: Option, /// Migrate as a pull-mirror instead of a one-shot import. #[arg(long)] pub mirror: bool, /// Make the destination repo private. #[arg(long)] pub private: bool, /// Description for the new repo. #[arg(long)] pub description: Option, /// Username for HTTP source-auth. #[arg(long)] pub auth_user: Option, /// Password / token for HTTP source-auth. #[arg(long)] pub auth_pass: Option, /// Service hint: git, github, gitea, gitlab, gogs. #[arg(long, default_value = "git")] pub service: String, /// Refresh interval for pull-mirrors (e.g. `8h`, `1d`). #[arg(long)] pub interval: Option, } #[derive(Debug, Args)] pub struct MirrorSyncArgs { pub repo: Option, } pub async fn run(cmd: RepoCmd, host: Option<&str>) -> Result<()> { match cmd.command { RepoSub::List(args) => list(args, host).await, RepoSub::View(args) => view(args, host).await, RepoSub::Clone(args) => clone(args, host).await, RepoSub::Create(args) => create(args, host).await, RepoSub::Fork(args) => fork(args, host).await, RepoSub::Sync(args) => sync(args, host).await, RepoSub::Edit(args) => edit(args, host).await, RepoSub::Rename(args) => rename(args, host).await, RepoSub::Archive(args) => set_archived(args, host, true).await, RepoSub::Unarchive(args) => set_archived(args, host, false).await, RepoSub::Delete(args) => delete(args, host).await, RepoSub::Branches(args) => branches(args, host).await, RepoSub::Topics(args) => topics(args, host).await, RepoSub::Mirror(args) => mirror(args, host).await, RepoSub::MirrorSync(args) => mirror_sync(args, host).await, } } async fn mirror(args: MirrorArgs, host: Option<&str>) -> Result<()> { let client = Client::connect(host)?; let me = api::user::current(&client).await?; let (dest_owner, dest_name) = match args.dest.as_deref() { Some(slug) => match slug.split_once('/') { Some((o, n)) => (o.to_string(), n.to_string()), None => (me.login.clone(), slug.to_string()), }, None => { let basename = std::path::Path::new(&args.source_url) .file_stem() .and_then(|s| s.to_str()) .ok_or_else(|| anyhow!("can't infer destination name from {}", args.source_url))?; (me.login.clone(), basename.to_string()) } }; let body = api::repo::Migrate { clone_addr: &args.source_url, repo_name: &dest_name, repo_owner: &dest_owner, mirror: args.mirror, private: args.private, description: args.description.as_deref(), auth_username: args.auth_user.as_deref(), auth_password: args.auth_pass.as_deref(), auth_token: None, service: &args.service, mirror_interval: args.interval.as_deref(), }; let r = api::repo::migrate(&client, &body).await?; println!( "✓ {} {}", if args.mirror { "Mirrored" } else { "Imported" }, r.full_name ); println!("{}", r.html_url); Ok(()) } async fn mirror_sync(args: MirrorSyncArgs, host: Option<&str>) -> Result<()> { let ctx = resolve_repo(args.repo.as_deref(), host)?; api::repo::mirror_sync(&ctx.client, &ctx.owner, &ctx.name).await?; println!("✓ Triggered mirror sync for {}/{}", ctx.owner, ctx.name); Ok(()) } async fn list(args: ListArgs, host: Option<&str>) -> Result<()> { let client = Client::connect(host)?; let opts = api::repo::ListOptions { limit: args.limit, page: args.page, query: args.search.as_deref(), }; let page = if args.search.is_some() { api::repo::search(&client, opts).await? } else { api::repo::list_for_user(&client, opts).await? }; if args.json { let v = serde_json::to_value(&page.items)?; return output::print_json(&v); } if page.items.is_empty() { println!("(no repositories)"); return Ok(()); } let rows: Vec> = page .items .iter() .map(|r| { let visibility = if r.private { "private" } else { "public" }; vec![ r.full_name.clone(), truncate(&r.description, 60), visibility.into(), output::dim(&output::relative_time(r.updated_at)), ] }) .collect(); print!( "{}", output::render_table(&["NAME", "DESCRIPTION", "VISIBILITY", "UPDATED"], &rows) ); Ok(()) } async fn view(args: ViewArgs, host: Option<&str>) -> Result<()> { let ctx = resolve_repo(args.repo.as_deref(), host)?; let repo = api::repo::get(&ctx.client, &ctx.owner, &ctx.name).await?; if args.web { return web::open(&repo.html_url); } if args.json { return output::print_json(&serde_json::to_value(&repo)?); } let header = output::bold(&repo.full_name); let vis = if repo.private { "private" } else { "public" }; let extra = if repo.archived { format!(" {}", output::dim("(archived)")) } else { String::new() }; println!("{header} {}{extra}", output::dim(vis)); if !repo.description.is_empty() { println!("{}", repo.description); } println!(); println!("Default branch: {}", repo.default_branch); println!("Stars: {}", repo.stars_count); println!("Forks: {}", repo.forks_count); println!("Open issues: {}", repo.open_issues_count); println!( "Updated: {}", output::relative_time(repo.updated_at) ); println!(); println!("URL: {}", repo.html_url); println!("Clone URL: {}", repo.clone_url); println!("SSH URL: {}", repo.ssh_url); Ok(()) } async fn clone(args: CloneArgs, host: Option<&str>) -> Result<()> { let (owner, name) = api::split_repo(&args.repo)?; let client = Client::connect(host)?; let repo = api::repo::get(&client, owner, name).await?; let hosts = Hosts::load()?; let hostname = hosts.resolve_host(host)?; let proto = hosts .hosts .get(hostname) .map(|h| h.git_protocol.clone()) .unwrap_or_else(|| "https".into()); let url = if proto == "ssh" { repo.ssh_url } else { repo.clone_url }; git::clone(&url, args.dir.as_deref()) } async fn create(args: CreateArgs, host: Option<&str>) -> Result<()> { let client = Client::connect(host)?; let (owner, name) = match args.repo.split_once('/') { Some((o, n)) => (Some(o.to_string()), n.to_string()), None => (None, args.repo.clone()), }; let body = api::repo::CreateRepo { name: &name, description: args.description.as_deref(), private: args.private, default_branch: None, auto_init: args.init, }; let repo = match owner { Some(o) => match api::repo::create_for_org(&client, &o, &body).await { Ok(r) => r, Err(_) => api::repo::create_for_current_user(&client, &body).await?, }, None => api::repo::create_for_current_user(&client, &body).await?, }; println!("✓ Created {}", repo.full_name); println!("{}", repo.html_url); if args.clone { let hosts = Hosts::load()?; let hostname = hosts.resolve_host(host)?; let proto = hosts .hosts .get(hostname) .map(|h| h.git_protocol.clone()) .unwrap_or_else(|| "https".into()); let url = if proto == "ssh" { repo.ssh_url.clone() } else { repo.clone_url.clone() }; git::clone(&url, None)?; } if args.web { web::open(&repo.html_url)?; } Ok(()) } async fn fork(args: ForkArgs, host: Option<&str>) -> Result<()> { let ctx = resolve_repo(args.repo.as_deref(), host)?; let r = api::repo::fork( &ctx.client, &ctx.owner, &ctx.name, args.org.as_deref(), args.name.as_deref(), ) .await?; println!("✓ Forked to {}", r.full_name); println!("{}", r.html_url); if args.clone { let hosts = Hosts::load()?; let hostname = hosts.resolve_host(host)?; let proto = hosts .hosts .get(hostname) .map(|h| h.git_protocol.clone()) .unwrap_or_else(|| "https".into()); let url = if proto == "ssh" { r.ssh_url } else { r.clone_url }; git::clone(&url, None)?; } Ok(()) } async fn sync(args: SyncArgs, host: Option<&str>) -> Result<()> { let ctx = resolve_repo(args.repo.as_deref(), host)?; let branch = match args.branch { Some(b) => b, None => { api::repo::get(&ctx.client, &ctx.owner, &ctx.name) .await? .default_branch } }; api::repo::sync_with_upstream(&ctx.client, &ctx.owner, &ctx.name, &branch).await?; println!( "✓ Synced {}/{} branch {} with upstream", ctx.owner, ctx.name, branch ); Ok(()) } async fn edit(args: EditArgs, host: Option<&str>) -> Result<()> { let ctx = resolve_repo(args.repo.as_deref(), host)?; let description = if args.description_editor { let existing = api::repo::get(&ctx.client, &ctx.owner, &ctx.name).await?; Some(editor::edit_text( "REPO_DESCRIPTION.md", &existing.description, )?) } else { args.description }; let body = api::repo::EditRepo { description: description.as_deref(), website: args.website.as_deref(), private: args.private, default_branch: args.default_branch.as_deref(), archived: None, }; let r = api::repo::edit(&ctx.client, &ctx.owner, &ctx.name, &body).await?; println!("✓ Updated {}", r.full_name); Ok(()) } async fn rename(args: RenameArgs, host: Option<&str>) -> Result<()> { let (owner, name) = api::split_repo(&args.repo)?; let client = Client::connect(host)?; api::repo::rename(&client, owner, name, &args.new_name).await?; println!("✓ Renamed {}/{} → {}/{}", owner, name, owner, args.new_name); Ok(()) } async fn set_archived(args: ArchiveArgs, host: Option<&str>, archived: bool) -> Result<()> { let ctx = resolve_repo(args.repo.as_deref(), host)?; let body = api::repo::EditRepo { archived: Some(archived), ..Default::default() }; api::repo::edit(&ctx.client, &ctx.owner, &ctx.name, &body).await?; println!( "✓ {} {}/{}", if archived { "Archived" } else { "Unarchived" }, ctx.owner, ctx.name ); Ok(()) } async fn delete(args: DeleteArgs, host: Option<&str>) -> Result<()> { let (owner, name) = api::split_repo(&args.repo)?; let client = Client::connect(host)?; if !args.yes { let prompt = format!("Delete {owner}/{name}? Type the slug to confirm"); let answer = editor::prompt_line(&prompt)?; if answer != args.repo { return Err(anyhow!("aborted: slug did not match")); } } api::repo::delete(&client, owner, name).await?; println!("✓ Deleted {owner}/{name}"); Ok(()) } async fn branches(args: BranchesArgs, host: Option<&str>) -> Result<()> { let ctx = resolve_repo(args.repo.as_deref(), host)?; let bs = api::repo::list_branches(&ctx.client, &ctx.owner, &ctx.name).await?; if args.json { return output::print_json(&serde_json::to_value(&bs)?); } if bs.is_empty() { println!("(no branches)"); return Ok(()); } let rows: Vec> = bs .iter() .map(|b| { vec![ b.name.clone(), output::dim(&b.commit.id[..7.min(b.commit.id.len())]), truncate(b.commit.message.lines().next().unwrap_or(""), 60), if b.protected { output::bold("protected") } else { String::new() }, ] }) .collect(); print!( "{}", output::render_table(&["BRANCH", "SHA", "SUBJECT", ""], &rows) ); Ok(()) } async fn topics(args: TopicsArgs, host: Option<&str>) -> Result<()> { let ctx = resolve_repo(args.repo.as_deref(), host)?; if let Some(set) = args.set { let list: Vec = set .split(',') .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .collect(); api::repo::set_topics(&ctx.client, &ctx.owner, &ctx.name, &list).await?; println!("✓ Topics set to: {}", list.join(", ")); return Ok(()); } let topics = api::repo::list_topics(&ctx.client, &ctx.owner, &ctx.name).await?; if topics.is_empty() { println!("(no topics)"); } else { for t in topics { println!("{t}"); } } Ok(()) } // Silence the unused `RepoFlag` import for now — kept for symmetry with other // subcommands and exported through `cli::context`. const _: fn() -> Option = || None; fn truncate(s: &str, max: usize) -> String { if s.chars().count() <= max { return s.to_string(); } let mut out: String = s.chars().take(max.saturating_sub(1)).collect(); out.push('…'); out }