use crate::{config, crosshub, gitcmd};
use anyhow::{anyhow, Result};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
fn map_path() -> Option<PathBuf> {
config::home().ok().map(|h| h.join(".confer").join("repos.json"))
}
#[derive(Serialize, Deserialize, Default)]
struct Map {
#[serde(default)]
clones: BTreeMap<String, String>,
}
pub fn load() -> BTreeMap<String, String> {
map_path()
.and_then(|p| std::fs::read_to_string(p).ok())
.and_then(|s| serde_json::from_str::<Map>(&s).ok())
.map(|m| m.clones)
.unwrap_or_default()
}
fn write(clones: &BTreeMap<String, String>) -> Result<()> {
let p = map_path().ok_or_else(|| anyhow!("no home directory for ~/.confer/repos.json"))?;
if let Some(d) = p.parent() {
std::fs::create_dir_all(d)?;
}
std::fs::write(p, serde_json::to_string_pretty(&Map { clones: clones.clone() })?)?;
Ok(())
}
fn is_git_repo(dir: &Path) -> bool {
dir.is_dir()
&& gitcmd::output(dir, &["rev-parse", "--git-dir"])
.map(|o| o.status.success())
.unwrap_or(false)
}
pub fn set(slug: &str, path: &Path) -> Result<PathBuf> {
let abs = path
.canonicalize()
.map_err(|e| anyhow!("{}: {e}", path.display()))?;
if !is_git_repo(&abs) {
return Err(anyhow!("{} is not a git repository", abs.display()));
}
let mut clones = load();
clones.insert(slug.to_string(), abs.to_string_lossy().to_string());
write(&clones)?;
Ok(abs)
}
pub fn path(slug: &str) -> Option<PathBuf> {
let p = PathBuf::from(load().get(slug)?);
is_git_repo(&p).then_some(p)
}
pub fn resolve(slug: &str, card_root_sha: Option<&str>) -> Option<PathBuf> {
let p = path(slug)?;
match card_root_sha {
Some(want) if !crosshub::is_shallow(&p) && crosshub::root_sha(&p).as_deref() != Some(want) => {
None
}
_ => Some(p),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CaptureSource {
RefFrom,
Cwd,
Mapped,
}
#[derive(Debug, Clone)]
pub struct Capture {
pub dir: PathBuf,
pub source: CaptureSource,
}
fn toplevel(dir: &Path) -> Option<PathBuf> {
let o = gitcmd::output(dir, &["rev-parse", "--show-toplevel"]).ok()?;
if !o.status.success() {
return None;
}
let s = String::from_utf8_lossy(&o.stdout).trim().to_string();
PathBuf::from(s).canonicalize().ok()
}
fn common_dir(dir: &Path) -> Option<PathBuf> {
let o = gitcmd::output(dir, &["rev-parse", "--git-common-dir"]).ok()?;
if !o.status.success() {
return None;
}
let s = String::from_utf8_lossy(&o.stdout).trim().to_string();
let p = PathBuf::from(&s);
let abs = if p.is_absolute() { p } else { dir.join(p) };
abs.canonicalize().ok()
}
fn is_same_repo(candidate: &Path, mapped: Option<&Path>, card_root_sha: Option<&str>) -> bool {
if let Some(m) = mapped {
if let (Some(a), Some(b)) = (common_dir(candidate), common_dir(m)) {
if a == b {
return true;
}
}
}
let want = card_root_sha.map(str::to_string).or_else(|| mapped.and_then(crosshub::root_sha));
match want {
Some(want) => crosshub::root_sha(candidate).as_deref() == Some(want.as_str()),
None => false, }
}
pub fn capture_dir(slug: &str, card_root_sha: Option<&str>, ref_from: Option<&Path>) -> Option<Capture> {
let mapped_raw = path(slug); if let Some(dir) = ref_from {
if let Some(top) = toplevel(dir) {
if is_same_repo(&top, mapped_raw.as_deref(), card_root_sha) {
return Some(Capture { dir: top, source: CaptureSource::RefFrom });
}
}
}
if let Ok(cwd) = std::env::current_dir() {
if let Some(top) = toplevel(&cwd) {
if is_same_repo(&top, mapped_raw.as_deref(), card_root_sha) {
return Some(Capture { dir: top, source: CaptureSource::Cwd });
}
}
}
if let Some(dir) = resolve(slug, card_root_sha) {
return Some(Capture { dir, source: CaptureSource::Mapped });
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::Command;
fn git(dir: &Path, args: &[&str]) {
let ok = Command::new("git")
.arg("-C")
.arg(dir)
.args(["-c", "user.name=t", "-c", "user.email=t@t.local", "-c", "commit.gpgsign=false", "-c", "init.defaultBranch=main"])
.args(args)
.output()
.map(|o| o.status.success())
.unwrap_or(false);
assert!(ok, "git {args:?} failed");
}
#[test]
fn is_git_repo_detects_repo_vs_plain_dir() {
let base = std::env::temp_dir().join(format!("confer-repomap-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let repo = base.join("r");
let plain = base.join("p");
std::fs::create_dir_all(&repo).unwrap();
std::fs::create_dir_all(&plain).unwrap();
git(&repo, &["init", "-q"]);
assert!(is_git_repo(&repo));
assert!(!is_git_repo(&plain));
assert!(!is_git_repo(&base.join("does-not-exist")));
let _ = std::fs::remove_dir_all(&base);
}
fn fresh_repo(tag: &str) -> PathBuf {
let dir = std::env::temp_dir()
.join(format!("confer-repomap-{}-{tag}-{}", std::process::id(), ulid::Ulid::new()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
git(&dir, &["init", "-q"]);
std::fs::write(dir.join("f.txt"), format!("{tag}-{}\n", ulid::Ulid::new())).unwrap();
git(&dir, &["add", "-A"]);
git(&dir, &["commit", "-q", "-m", &format!("c0-{tag}")]);
dir
}
#[test]
fn is_same_repo_matches_linked_worktree_via_common_dir() {
let main = fresh_repo("wt-main");
let wt = main.parent().unwrap().join(format!("wt-{}", ulid::Ulid::new()));
git(&main, &["worktree", "add", "-q", "-b", "feature", wt.to_str().unwrap()]);
assert!(is_same_repo(&wt, Some(&main), None));
let _ = std::fs::remove_dir_all(&main);
let _ = std::fs::remove_dir_all(&wt);
}
#[test]
fn is_same_repo_matches_separate_clone_via_root_sha() {
let a = fresh_repo("root-a");
let root = crosshub::root_sha(&a).unwrap();
let b = fresh_repo("root-b");
assert!(is_same_repo(&a, None, Some(&root)));
assert!(!is_same_repo(&b, None, Some(&root)));
assert!(is_same_repo(&a, Some(&a), None));
assert!(!is_same_repo(&b, Some(&a), None));
let _ = std::fs::remove_dir_all(&a);
let _ = std::fs::remove_dir_all(&b);
}
#[test]
fn is_same_repo_false_with_nothing_to_compare() {
let a = fresh_repo("nocompare");
assert!(!is_same_repo(&a, None, None));
let _ = std::fs::remove_dir_all(&a);
}
#[test]
fn shallow_clone_root_sha_is_unverifiable_not_refused() {
let origin = fresh_repo("shallow-origin");
git(&origin, &["commit", "--allow-empty", "-q", "-m", "c1"]);
git(&origin, &["commit", "--allow-empty", "-q", "-m", "c2"]);
let shallow = origin.parent().unwrap().join(format!("shallow-{}", ulid::Ulid::new()));
let ok = Command::new("git")
.args([
"clone",
"-q",
"--depth",
"1",
&format!("file://{}", origin.display()),
shallow.to_str().unwrap(),
])
.status()
.map(|s| s.success())
.unwrap_or(false);
assert!(ok, "shallow clone failed");
assert!(crosshub::is_shallow(&shallow));
assert!(!crosshub::is_shallow(&origin));
let _ = std::fs::remove_dir_all(&origin);
let _ = std::fs::remove_dir_all(&shallow);
}
}