use std::path::Path;
use std::sync::atomic::AtomicBool;
use anyhow::{Context, Result, bail};
use inquire::{Select, Text};
use owo_colors::OwoColorize;
use crate::cli::CloneArgs;
use crate::commands::ShokaContext;
use crate::config::{ShokaConfig, VcsDefault};
use crate::gh;
use crate::remote::parse_clone_input;
use crate::state::{Repo, Shelf};
pub async fn run(ctx: &ShokaContext, args: CloneArgs) -> Result<()> {
let cfg = ShokaConfig::load(&ctx.paths)?.resolve(ctx.profile_override.as_deref())?;
let input = match args.url {
Some(s) => s,
None => prompt_for_input(&cfg.ui.own_owners).await?,
};
let (parts, url) = parse_clone_input(&input, &cfg.default_host, cfg.default_protocol)?;
let repo = Repo::new(parts.host, parts.owner, parts.name);
let dest = cfg.clone_path_for_one(&repo)?;
let target = cfg.resolve_target(&repo.slug());
if dest_is_occupied(&dest)? {
bail!(
"destination {} already exists and is not empty — \
refusing to clone over existing content",
dest.display()
);
}
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating parent dir {}", parent.display()))?;
}
println!("{} {} → {}", "clone:".bold(), repo.slug(), dest.display());
match target.default_vcs {
VcsDefault::Jj => {
let url_str = url.to_string();
spawn_jj_clone(&url_str, &dest).await?;
}
VcsDefault::Git | VcsDefault::Auto => {
let dest_owned = dest.clone();
let url_owned = url.clone();
tokio::task::spawn_blocking(move || gix_clone(url_owned, &dest_owned))
.await
.context("gix clone task panicked")?
.with_context(|| format!("cloning {} via gix", url))?;
}
}
let mut shelf = Shelf::load(&ctx.paths)?;
if shelf.find(&repo.host, &repo.owner, &repo.name).is_some() {
tracing::warn!(
target: "shoka",
"shelf already had {} — leaving the existing entry's metadata",
repo.slug()
);
} else {
shelf.add(repo).context("recording cloned repo on shelf")?;
shelf.save(&ctx.paths)?;
}
println!("{} done", "clone:".bold());
Ok(())
}
async fn prompt_for_input(own_owners: &[String]) -> Result<String> {
let Some(token) = gh::resolve_token().await else {
return text_fallback_prompt();
};
let client = match gh::build_client(&token) {
Ok(c) => c,
Err(e) => {
tracing::warn!(target: "shoka", "octocrab client build failed: {e:#}");
return text_fallback_prompt();
}
};
let personal_login = match gh::whoami(&client).await {
Ok(login) => login,
Err(e) => {
tracing::warn!(target: "shoka", "gh /user lookup failed: {e:#}");
return text_fallback_prompt();
}
};
let default_owner = own_owners
.first()
.cloned()
.unwrap_or_else(|| personal_login.clone());
let mut default: Option<&str> = Some(default_owner.as_str());
loop {
let mut prompt = Text::new("org or user (Enter = your own repos):");
if let Some(d) = default {
prompt = prompt.with_default(d);
}
let owner_input = prompt.prompt().context("clone owner prompt cancelled")?;
let trimmed = owner_input.trim();
let want_mine = trimmed.is_empty() || trimmed.eq_ignore_ascii_case(&personal_login);
let owner_for_query = if want_mine { None } else { Some(trimmed) };
let is_org = owner_for_query
.is_some_and(|o| own_owners.iter().any(|own| own.eq_ignore_ascii_case(o)));
match gh::list_repos(&client, owner_for_query, is_org).await {
Ok(repos) if repos.is_empty() => {
eprintln!(
"{} no repos found for {} — try another?",
"clone:".bold(),
owner_for_query.unwrap_or(default_owner.as_str())
);
default = None;
continue;
}
Ok(repos) => {
let picked = Select::new("pick a repo:", repos)
.with_page_size(15)
.prompt()
.context("clone repo select cancelled")?;
return Ok(format!("{}/{}", picked.owner, picked.name));
}
Err(e) if gh::is_not_found(&e) => {
eprintln!(
"{} no such org or user: {} — try another?",
"clone:".bold(),
owner_for_query.unwrap_or(default_owner.as_str())
);
default = None;
continue;
}
Err(e) => {
return Err(e).with_context(|| match owner_for_query {
None => "listing your own repos".to_string(),
Some(o) => format!("listing repos for {o}"),
});
}
}
}
}
fn text_fallback_prompt() -> Result<String> {
let answer = Text::new("URL or owner/name to clone:")
.with_help_message("e.g. https://github.com/foo/bar or foo/bar")
.prompt()
.context("clone input prompt cancelled")?;
if answer.trim().is_empty() {
bail!("empty clone input");
}
Ok(answer)
}
fn dest_is_occupied(dest: &Path) -> Result<bool> {
match std::fs::read_dir(dest) {
Ok(mut it) => Ok(it.next().is_some()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(e) => Err(e).with_context(|| format!("inspecting {}", dest.display())),
}
}
fn gix_clone(url: gix::Url, dest: &Path) -> Result<()> {
let interrupt = AtomicBool::new(false);
let mut prepare_fetch = gix::prepare_clone(url, dest).context("preparing clone")?;
let (mut prepare_checkout, _outcome) = prepare_fetch
.fetch_then_checkout(gix::progress::Discard, &interrupt)
.context("fetching from remote")?;
let (_repo, _outcome) = prepare_checkout
.main_worktree(gix::progress::Discard, &interrupt)
.context("checking out main worktree")?;
Ok(())
}
async fn spawn_jj_clone(url: &str, dest: &Path) -> Result<()> {
let jj = which::which("jj").context("`jj` not found on PATH (required for vcs = jj)")?;
let mut cmd = tokio::process::Command::new(&jj);
cmd.arg("git").arg("clone").arg(url).arg(dest);
#[cfg(windows)]
cmd.creation_flags(crate::silent_creation_flags());
let status = cmd
.status()
.await
.with_context(|| format!("spawning `{} git clone`", jj.display()))?;
if !status.success() {
bail!("jj git clone exited with status {status}");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dest_is_occupied_missing_dir_is_free() {
let tmp = tempfile::TempDir::new().unwrap();
let absent = tmp.path().join("not-there");
assert!(!dest_is_occupied(&absent).unwrap());
}
#[test]
fn dest_is_occupied_empty_dir_is_free() {
let tmp = tempfile::TempDir::new().unwrap();
let empty = tmp.path().join("empty");
std::fs::create_dir_all(&empty).unwrap();
assert!(!dest_is_occupied(&empty).unwrap());
}
#[test]
fn dest_is_occupied_nonempty_dir_is_blocked() {
let tmp = tempfile::TempDir::new().unwrap();
let occupied = tmp.path().join("occupied");
std::fs::create_dir_all(&occupied).unwrap();
std::fs::write(occupied.join("leftover.txt"), "hi").unwrap();
assert!(dest_is_occupied(&occupied).unwrap());
}
}