use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use inquire::Select;
use owo_colors::OwoColorize;
use walkdir::WalkDir;
use crate::cli::ImportArgs;
use crate::commands::ShokaContext;
use crate::remote::parse_remote_url;
use crate::state::{Repo, Shelf};
pub async fn run(ctx: &ShokaContext, args: ImportArgs) -> Result<()> {
let source = match args.path {
Some(p) => p,
None => prompt_for_source()?,
};
if !source.is_dir() {
bail!("import source {} is not a directory", source.display());
}
let mut shelf = Shelf::load(&ctx.paths)?;
let mut imported = 0usize;
let mut skipped_already = 0usize;
let mut skipped_no_remote = 0usize;
let mut errors = 0usize;
println!(
"{} scanning {} for git repos…",
"import:".bold(),
source.display()
);
let mut it = WalkDir::new(&source).follow_links(false).into_iter();
while let Some(entry) = it.next() {
let entry = match entry {
Ok(e) => e,
Err(e) => {
tracing::warn!(target: "shoka", "walkdir error: {e}");
errors += 1;
continue;
}
};
if entry.file_name() != ".git" || !entry.path().is_dir() {
continue;
}
it.skip_current_dir();
let repo_root = match entry.path().parent() {
Some(p) => p,
None => continue,
};
match extract_repo(repo_root) {
Ok(Some(repo)) => {
let slug = repo.slug();
if shelf.find(&repo.host, &repo.owner, &repo.name).is_some() {
skipped_already += 1;
} else {
match shelf.add(repo) {
Ok(()) => {
println!(" {} {slug}", "+".green());
imported += 1;
}
Err(e) => {
tracing::warn!(
target: "shoka",
"failed to add {slug} to shelf: {e:#}"
);
errors += 1;
}
}
}
}
Ok(None) => {
tracing::debug!(
target: "shoka",
"no default remote at {}",
repo_root.display()
);
skipped_no_remote += 1;
}
Err(e) => {
tracing::warn!(
target: "shoka",
"failed to read {}: {e:#}",
repo_root.display()
);
errors += 1;
}
}
}
shelf.save(&ctx.paths)?;
println!();
println!(
"{} {} imported, {} on shelf total",
"import:".bold(),
imported,
shelf.len()
);
if skipped_already > 0 {
println!(" {} {} already on shelf", "↩".dimmed(), skipped_already);
}
if skipped_no_remote > 0 {
println!(
" {} {} repos with no remote (left alone)",
"↩".dimmed(),
skipped_no_remote
);
}
if errors > 0 {
println!(
" {} {} read errors (see SHOKA_LOG=warn for details)",
"!".red(),
errors
);
}
Ok(())
}
fn prompt_for_source() -> Result<PathBuf> {
let home = directories::BaseDirs::new()
.map(|b| b.home_dir().to_path_buf())
.context("could not locate home dir for default candidates")?;
let candidates: Vec<PathBuf> = ["ghq", "src", "dev", "Code", "code", "repos", "Projects"]
.into_iter()
.map(|d| home.join(d))
.filter(|p| p.is_dir())
.collect();
if candidates.is_empty() {
bail!(
"no common source dirs found under {} — pass `--path` to specify one",
home.display()
);
}
let labels: Vec<String> = candidates.iter().map(|p| p.display().to_string()).collect();
let chosen = Select::new("Pick a source dir to import from:", labels.clone())
.prompt()
.context("source dir selection cancelled")?;
let idx = labels
.iter()
.position(|l| l == &chosen)
.context("chosen label not in candidates")?;
Ok(candidates[idx].clone())
}
fn extract_repo(repo_root: &Path) -> Result<Option<Repo>> {
let repo = gix::open(repo_root)
.with_context(|| format!("opening {} as a git repo", repo_root.display()))?;
let Some(remote_result) = repo.find_default_remote(gix::remote::Direction::Fetch) else {
return Ok(None);
};
let remote = remote_result.context("resolving default remote")?;
let Some(url) = remote.url(gix::remote::Direction::Fetch) else {
return Ok(None);
};
let parts = parse_remote_url(url).with_context(|| format!("parsing remote URL `{url}`"))?;
Ok(Some(Repo::new(parts.host, parts.owner, parts.name)))
}