use std::collections::HashSet;
use std::ffi::OsStr;
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};
const LOCAL_HOST: &str = "local";
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 updated = 0usize;
let mut skipped_already = 0usize;
let mut errors = 0usize;
let mut imported_roots: HashSet<PathBuf> = HashSet::new();
println!(
"{} scanning {} for git / jj 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;
}
};
let fname = entry.file_name();
let is_marker =
(fname == OsStr::new(".git") || fname == OsStr::new(".jj")) && entry.path().is_dir();
if !is_marker {
continue;
}
it.skip_current_dir();
let repo_root = match entry.path().parent() {
Some(p) => p.to_path_buf(),
None => continue,
};
if !imported_roots.insert(repo_root.clone()) {
continue;
}
let result = match fname {
f if f == OsStr::new(".git") => extract_git_repo(&repo_root),
_ => Ok(synthesise_local(&repo_root)),
};
match result {
Ok(repo) => {
let slug = repo.slug();
let outcome = upsert_into_shelf(&mut shelf, repo);
match outcome {
Outcome::Imported => {
println!(" {} {slug}", "+".green());
imported += 1;
}
Outcome::PathFilled => {
println!(" {} {slug}", "↻".cyan());
updated += 1;
}
Outcome::AlreadyOnShelf => {
skipped_already += 1;
}
Outcome::AddFailed(e) => {
tracing::warn!(
target: "shoka",
"failed to add {slug} to shelf: {e:#}"
);
errors += 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 updated > 0 {
println!(" {} {} path refreshed", "↻".cyan(), updated);
}
if skipped_already > 0 {
println!(" {} {} already on shelf", "↩".dimmed(), skipped_already);
}
if errors > 0 {
println!(
" {} {} read errors (see SHOKA_LOG=warn for details)",
"!".red(),
errors
);
}
Ok(())
}
enum Outcome {
Imported,
PathFilled,
AlreadyOnShelf,
AddFailed(anyhow::Error),
}
fn upsert_into_shelf(shelf: &mut Shelf, repo: Repo) -> Outcome {
if shelf
.find_by_path(&repo.host, &repo.owner, &repo.name, repo.path.as_deref())
.is_some()
{
return Outcome::AlreadyOnShelf;
}
if repo.path.is_some() {
if let Some(existing) = shelf.find_mut_by_path(&repo.host, &repo.owner, &repo.name, None) {
existing.path = repo.path;
return Outcome::PathFilled;
}
}
match shelf.add(repo) {
Ok(()) => Outcome::Imported,
Err(e) => Outcome::AddFailed(e),
}
}
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_git_repo(repo_root: &Path) -> Result<Repo> {
let repo = gix::open(repo_root)
.with_context(|| format!("opening {} as a git repo", repo_root.display()))?;
let remote = repo
.find_default_remote(gix::remote::Direction::Fetch)
.transpose()
.with_context(|| {
format!(
"reading default remote configuration for {}",
repo_root.display()
)
})?;
let maybe_url = remote.and_then(|r| r.url(gix::remote::Direction::Fetch).cloned());
let Some(url) = maybe_url else {
return Ok(synthesise_local(repo_root));
};
match parse_remote_url(&url) {
Ok(parts) => {
Ok(Repo::new(parts.host, parts.owner, parts.name).with_path(absolute(repo_root)))
}
Err(e) => {
tracing::warn!(
target: "shoka",
"could not parse remote URL `{url}` for {}: {e:#}; importing as local",
repo_root.display()
);
Ok(synthesise_local(repo_root))
}
}
}
fn absolute(p: &Path) -> PathBuf {
std::path::absolute(p).unwrap_or_else(|_| p.to_path_buf())
}
fn synthesise_local(repo_root: &Path) -> Repo {
let abs = absolute(repo_root);
let name = path_component_string(abs.file_name()).unwrap_or_else(|| abs.display().to_string());
let owner = abs
.parent()
.and_then(|p| path_component_string(p.file_name()))
.unwrap_or_else(|| "_".to_string());
Repo::new(LOCAL_HOST, owner, name).with_path(abs)
}
fn path_component_string(component: Option<&OsStr>) -> Option<String> {
let raw = component?.to_string_lossy().to_string();
if raw.is_empty() { None } else { Some(raw) }
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn upsert_self_heals_path_less_twin_even_when_path_pinned_row_is_first() {
let mut shelf = Shelf::default();
let mut pinned = Repo::new("github.com", "yukimemi", "admintask");
pinned.path = Some(PathBuf::from("/elsewhere/admintask"));
shelf.add(pinned).unwrap();
shelf
.add(Repo::new("github.com", "yukimemi", "admintask"))
.unwrap();
assert_eq!(shelf.len(), 2, "fixture must start with both rows");
let mut incoming = Repo::new("github.com", "yukimemi", "admintask");
incoming.path = Some(PathBuf::from("/here/admintask"));
let outcome = upsert_into_shelf(&mut shelf, incoming);
assert!(
matches!(outcome, Outcome::PathFilled),
"expected PathFilled, got something else"
);
assert_eq!(shelf.len(), 2, "no new row should be added");
let still_pinned = shelf
.repos
.iter()
.find(|r| r.path.as_deref() == Some(Path::new("/elsewhere/admintask")))
.expect("original pinned row preserved");
assert_eq!(still_pinned.name, "admintask");
let healed = shelf
.repos
.iter()
.find(|r| r.path.as_deref() == Some(Path::new("/here/admintask")))
.expect("path-less row healed to incoming path");
assert_eq!(healed.name, "admintask");
}
#[test]
fn synthesise_local_uses_parent_and_repo_dir_names() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path().join("alpha").join("beta");
std::fs::create_dir_all(&repo).unwrap();
let r = synthesise_local(&repo);
assert_eq!(r.host, LOCAL_HOST);
assert_eq!(r.owner, "alpha");
assert_eq!(r.name, "beta");
let canonical = std::fs::canonicalize(&repo).unwrap();
let stored = std::fs::canonicalize(r.path.as_ref().unwrap()).unwrap();
assert_eq!(stored, canonical);
}
fn init_git_with_remote(dir: &Path, url: &str) {
std::fs::create_dir_all(dir).unwrap();
gix::init(dir).expect("gix init");
let cfg = dir.join(".git").join("config");
let mut body = std::fs::read_to_string(&cfg).unwrap();
body.push_str(&format!(
"\n[remote \"origin\"]\n\turl = {url}\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n"
));
std::fs::write(&cfg, body).unwrap();
}
#[test]
fn extract_git_repo_pins_path_when_dirname_differs_from_remote_slug() {
let tmp = TempDir::new().unwrap();
let repo_root = tmp.path().join("DeviceManagement");
init_git_with_remote(&repo_root, "https://github.com/yukimemi/admintask.git");
let r = extract_git_repo(&repo_root).expect("extract");
assert_eq!(r.host, "github.com");
assert_eq!(r.owner, "yukimemi");
assert_eq!(r.name, "admintask");
let pinned = r.path.as_ref().expect("path must be pinned on import");
assert_eq!(
std::fs::canonicalize(pinned).unwrap(),
std::fs::canonicalize(&repo_root).unwrap(),
);
}
#[test]
fn extract_git_repo_pins_path_even_when_dirname_matches_slug() {
let tmp = TempDir::new().unwrap();
let repo_root = tmp.path().join("matching-name");
init_git_with_remote(&repo_root, "https://github.com/yukimemi/matching-name.git");
let r = extract_git_repo(&repo_root).expect("extract");
assert_eq!(r.name, "matching-name");
assert!(
r.path.is_some(),
"path should be pinned even in the matching-name case"
);
}
#[test]
fn synthesise_local_falls_back_when_no_parent_name() {
let empty: Option<&OsStr> = Some(OsStr::new(""));
assert!(path_component_string(empty).is_none());
let none: Option<&OsStr> = None;
assert!(path_component_string(none).is_none());
}
}