use std::cell::Cell; use std::env; use std::ffi::OsString; use std::fs; use std::io::{self, Write}; use std::os::unix::fs::symlink; use std::path::{Path, PathBuf}; use std::process; use std::process::Command; use anyhow::{Context, Result, anyhow, bail}; const BACKUP_SUFFIX: &str = ".ahab-bak"; const LOCAL_NAMESPACE: &str = "_local"; // move untracked paths out of the repo and symlink them back pub fn add(paths: &[PathBuf], force: bool) -> Result<()> { let repo = Repo::discover()?; let report = Report::new(&repo); if let [path] = paths { return link_one(&repo, path, force, &report); } let mut failed = 0; for path in paths { if let Err(e) = link_one(&repo, path, force, &report) { eprintln!("error: {e:#}"); failed += 1; } } if failed > 0 { return Err(anyhow!("{failed} of {} paths failed", paths.len())); } Ok(()) } // move paths in the store back into the repo, the inverse of add pub fn restore(paths: &[PathBuf], all: bool) -> Result<()> { let repo = Repo::discover()?; let report = Report::new(&repo); let paths = match (all, paths) { (true, []) => linked_paths(&repo, &repo.store)?, (true, _) => bail!("--all restores everything, so it takes no paths"), (false, []) => bail!("name a path to restore, or pass --all"), (false, paths) => paths.to_vec(), }; if paths.is_empty() { eprintln!("nothing in the store for this repository"); return Ok(()); } if let [path] = paths.as_slice() { return restore_one(&repo, path, &report); } let mut failed = 0; for path in &paths { if let Err(e) = restore_one(&repo, path, &report) { eprintln!("error: {e:#}"); failed += 1; } } if failed > 0 { return Err(anyhow!("{failed} of {} paths failed", paths.len())); } Ok(()) } fn restore_one(repo: &Repo, path: &Path, report: &Report) -> Result<()> { let src = resolve(path)?; let rel = repo.relative(&src)?; let stored = repo.store.join(&rel); let Some(meta) = symlink_metadata_opt(&src)? else { bail!("{} does not exist", rel.display()); }; if !meta.is_symlink() { bail!( "{} is not a symlink, so it is not in the store", rel.display() ); } let dest = fs::read_link(&src).with_context(|| format!("reading {}", src.display()))?; if dest != stored { bail!( "{} points at {}, which is not where the store keeps it", rel.display(), dest.display() ); } if symlink_metadata_opt(&stored)?.is_none() { bail!("{} is missing from the store", rel.display()); } fs::remove_file(&src).with_context(|| format!("removing {}", src.display()))?; move_path(&stored, &src)?; prune_empty(stored.parent(), &store_root()?); report.line("restored", &rel); Ok(()) } // the store mirrors the repository layout, so walking it finds every path this // repository has linked without asking git anything fn linked_paths(repo: &Repo, dir: &Path) -> Result> { let mut found = Vec::new(); let entries = match fs::read_dir(dir) { Ok(entries) => entries, Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(found), Err(e) => return Err(e).with_context(|| format!("reading {}", dir.display())), }; for entry in entries { let stored = entry?.path(); let rel = stored .strip_prefix(&repo.store) .expect("walked out of the store"); let src = repo.root.join(rel); let linked = symlink_metadata_opt(&src)?.is_some_and(|meta| meta.is_symlink()) && fs::read_link(&src).is_ok_and(|dest| dest == stored); if linked { found.push(src); } else if stored.is_dir() { found.extend(linked_paths(repo, &stored)?); } } found.sort(); Ok(found) } // a restored path can leave the store holding nothing but empty directories fn prune_empty(dir: Option<&Path>, stop: &Path) { let mut dir = dir; while let Some(path) = dir { if path == stop || !path.starts_with(stop) || fs::remove_dir(path).is_err() { return; } dir = path.parent(); } } struct Report { store: PathBuf, named: Cell, } impl Report { fn new(repo: &Repo) -> Self { Self { store: repo.store.clone(), named: Cell::new(false), } } fn line(&self, verb: &str, path: &Path) { // worth naming once per run if !self.named.replace(true) { println!("store: {}", self.store.display()); } println!("\t{:<11}{}", format!("{verb}:"), path.display()); } } fn warn(msg: impl std::fmt::Display) { eprintln!("warning: {msg}"); } // list untracked paths not in the store, i.e. what a sandbox can still read pub fn check(paths: &[PathBuf], porcelain: bool, null: bool, exit_code: bool) -> Result<()> { let repo = Repo::discover()?; let pathspecs = relative_pathspecs(&repo, paths)?; let mut exposed = Vec::new(); // git lists untracked and ignored separately for (mark, ignored) in [(UNTRACKED, false), (IGNORED, true)] { for entry in list_others(&repo, ignored, &pathspecs)? { let rel = PathBuf::from(entry.trim_end_matches('/')); // --directory collapses a wholly untracked dir into `dir/` if entry.ends_with('/') { exposed.extend(walk(&repo, &rel, mark)?.1); } else { exposed.extend(classify(&repo, &rel, mark)?); } } } exposed.sort_by(|a, b| a.name.cmp(&b.name)); if porcelain || null { print_porcelain(&exposed, null); } else { print_listing(&repo, &exposed); } // git's --exit-code convention: nothing to report is 0, anything is 1 if exit_code && !exposed.is_empty() { io::stdout().flush().context("writing the listing")?; process::exit(1); } Ok(()) } fn print_porcelain(exposed: &[Exposed], null: bool) { let end = if null { '\0' } else { '\n' }; for item in exposed { match &item.dest { Some(dest) => print!("{} {} -> {}{end}", item.code(), item.name, dest.display()), None => print!("{} {}{end}", item.code(), item.name), } } } fn print_listing(repo: &Repo, exposed: &[Exposed]) { println!("store: {}", repo.store.display()); if exposed.is_empty() { println!("nothing outside the store, a sandbox would see tracked files only"); return; } let sections = [ ( "Untracked paths a sandbox can read:", " (use \"ahab link add ...\" to move them into the store)", Section::Content(UNTRACKED), ), ( "Ignored paths a sandbox can read:", " (use \"ahab link add ...\" to move them into the store)", Section::Content(IGNORED), ), ( "Symlinks leading outside the store:", " (their contents are not in the repository either way)", Section::Elsewhere, ), ]; for (heading, hint, section) in sections { let mut items = exposed.iter().filter(|item| section.holds(item)).peekable(); if items.peek().is_none() { continue; } println!("\n{heading}\n{hint}"); for item in items { match &item.dest { Some(dest) => println!("\t{} -> {}", item.name, dest.display()), None => println!("\t{}", item.name), } } } } enum Section { Content(char), Elsewhere, } impl Section { fn holds(&self, item: &Exposed) -> bool { match self { Self::Content(mark) => item.dest.is_none() && item.mark == *mark, Self::Elsewhere => item.dest.is_some(), } } } // status codes as `git status --porcelain` spells them const UNTRACKED: char = '?'; const IGNORED: char = '!'; const ELSEWHERE: char = '>'; struct Exposed { mark: char, name: String, dest: Option, } impl Exposed { fn content(mark: char, name: String) -> Self { Self { mark, name, dest: None, } } fn code(&self) -> String { let second = if self.dest.is_some() { ELSEWHERE } else { self.mark }; format!("{}{second}", self.mark) } } fn classify(repo: &Repo, rel: &Path, mark: char) -> Result> { let src = repo.root.join(rel); let name = rel.display().to_string(); let Some(meta) = symlink_metadata_opt(&src)? else { return Ok(None); }; if !meta.is_symlink() { return Ok(Some(Exposed::content(mark, name))); } let dest = fs::read_link(&src).with_context(|| format!("reading symlink {}", src.display()))?; if dest == repo.store.join(rel) { return Ok(None); } Ok(Some(Exposed { mark, name, dest: Some(dest), })) } fn walk(repo: &Repo, rel: &Path, mark: char) -> Result<(usize, Vec)> { let dir = repo.root.join(rel); let mut handled = 0; let mut exposed = Vec::new(); for entry in fs::read_dir(&dir).with_context(|| format!("reading {}", dir.display()))? { let entry = entry?; let child = rel.join(entry.file_name()); if entry.file_type()?.is_dir() { let (below, inside) = walk(repo, &child, mark)?; handled += below; exposed.extend(inside); continue; } match classify(repo, &child, mark)? { Some(item) => exposed.push(item), None => handled += 1, } } // nothing below is in the store, so collapse to one line if handled == 0 && !exposed.is_empty() { let name = format!("{}/", rel.display()); return Ok((0, vec![Exposed::content(mark, name)])); } Ok((handled, exposed)) } fn relative_pathspecs(repo: &Repo, paths: &[PathBuf]) -> Result> { let mut specs = Vec::with_capacity(paths.len()); for path in paths { let abs = resolve(path)?; let rel = abs.strip_prefix(&repo.root).map_err(|_| { anyhow!( "{} is outside the repository {}", abs.display(), repo.root.display() ) })?; if rel.as_os_str().is_empty() { return Ok(Vec::new()); } specs.push(rel.to_path_buf()); } Ok(specs) } fn list_others(repo: &Repo, ignored: bool, pathspecs: &[PathBuf]) -> Result> { let mut cmd = Command::new("git"); cmd.arg("-C").arg(&repo.root).args([ "ls-files", "-z", "--others", "--exclude-standard", "--directory", "--no-empty-directory", ]); if ignored { cmd.arg("--ignored"); } cmd.arg("--").args(pathspecs); let out = cmd.output().context("running git ls-files")?; if !out.status.success() { return Err(anyhow!( "git ls-files failed: {}", String::from_utf8_lossy(&out.stderr).trim() )); } Ok(String::from_utf8(out.stdout)? .split('\0') .filter(|p| !p.is_empty()) .map(String::from) .collect()) } struct Repo { root: PathBuf, store: PathBuf, } impl Repo { fn discover() -> Result { let root = git_root()?; let store = store_root()?.join(repo_components(&root)?); Ok(Self { root, store }) } fn relative(&self, src: &Path) -> Result { let rel = src.strip_prefix(&self.root).map_err(|_| { anyhow!( "{} is outside the repository {}", src.display(), self.root.display() ) })?; // empty means the whole repo if rel.as_os_str().is_empty() { return Err(anyhow!( "refusing to externalize the repository root itself" )); } if rel.starts_with(".git") { return Err(anyhow!("refusing to externalize anything under .git")); } Ok(rel.to_path_buf()) } } fn link_one(repo: &Repo, path: &Path, force: bool, report: &Report) -> Result<()> { let src = resolve(path)?; let rel = repo.relative(&src)?; let target = repo.store.join(&rel); // a target inside the repo would be readable from the sandbox anyway if target.starts_with(&repo.root) { return Err(anyhow!( "target {} is inside the repository; point AHAB_LINK_ROOT elsewhere", target.display() )); } if tracked(repo, &rel)? { return Err(anyhow!( "{} is tracked by git; only untracked or ignored paths can be externalized", rel.display() )); } if !ignored(repo, &rel) { warn(format!("{} is not gitignored", rel.display())); } let src_meta = symlink_metadata_opt(&src)?; let target_taken = symlink_metadata_opt(&target)?.is_some(); match src_meta { Some(meta) if meta.is_symlink() => { let dest = fs::read_link(&src) .with_context(|| format!("reading symlink {}", src.display()))?; if dest == target { if !target_taken { return Err(anyhow!( "{} already points at {}, but nothing is there", rel.display(), target.display() )); } report.line("unchanged", &rel); return Ok(()); } // nothing in the store to adopt, so the symlink itself moves out if !target_taken { if !src.exists() { warn(format!( "{} is a broken symlink to {}", rel.display(), dest.display() )); } move_path(&src, &target)?; place_link(&src, &target)?; report.line("moved", &rel); return Ok(()); } if !force { return Err(needs_force(&target)); } place_link(&src, &target)?; report.line("repointed", &rel); Ok(()) } Some(_) if target_taken => { if !force { return Err(needs_force(&target)); } let backup = suffixed(&src, BACKUP_SUFFIX); if symlink_metadata_opt(&backup)?.is_some() { return Err(anyhow!( "{} already exists; remove it before re-linking", backup.display() )); } fs::rename(&src, &backup) .with_context(|| format!("renaming {} to {}", src.display(), backup.display()))?; report.line("saved", &suffixed(&rel, BACKUP_SUFFIX)); place_link(&src, &target)?; report.line("linked", &rel); Ok(()) } Some(_) => { move_path(&src, &target)?; place_link(&src, &target)?; report.line("moved", &rel); Ok(()) } None if target_taken => { if !force { return Err(needs_force(&target)); } place_link(&src, &target)?; report.line("linked", &rel); Ok(()) } None => Err(anyhow!( "{} does not exist and the store has no {}", rel.display(), target.display() )), } } fn needs_force(target: &Path) -> anyhow::Error { anyhow!( "{} already exists; pass --force to link to it", target.display() ) } fn resolve(path: &Path) -> Result { let abs = std::path::absolute(path) .with_context(|| format!("resolving absolute path of {}", path.display()))?; // the parent must exist so symlinked components resolve like git's toplevel let Some(name) = abs.file_name().map(OsString::from) else { return fs::canonicalize(&abs).with_context(|| format!("resolving {}", abs.display())); }; let parent = abs.parent().unwrap_or(Path::new("/")); let parent = fs::canonicalize(parent) .with_context(|| format!("resolving directory {}", parent.display()))?; Ok(parent.join(name)) } fn git_root() -> Result { let out = Command::new("git") .args(["rev-parse", "--show-toplevel"]) .output() .context("running git")?; if !out.status.success() { return Err(anyhow!("not inside a git repository")); } let root = String::from_utf8(out.stdout)?.trim().to_string(); if root.is_empty() { return Err(anyhow!("git reported an empty repository root")); } fs::canonicalize(&root).with_context(|| format!("resolving {root}")) } fn repo_components(root: &Path) -> Result { if let Some(url) = git_origin_url() { if let Some(components) = components_from_remote(&url) { return Ok(components); } eprintln!("could not parse git remote `{url}`, falling back to the checkout name"); } let name = root .file_name() .ok_or_else(|| anyhow!("cannot derive a store path for {}", root.display()))?; Ok(Path::new(LOCAL_NAMESPACE).join(sanitize(&name.to_string_lossy()))) } fn git_origin_url() -> Option { let out = Command::new("git") .args(["config", "--get", "remote.origin.url"]) .output() .ok()?; if !out.status.success() { return None; } let url = String::from_utf8(out.stdout).ok()?.trim().to_string(); (!url.is_empty()).then_some(url) } fn components_from_remote(url: &str) -> Option { let url = url.trim(); let url = url.strip_suffix(".git").unwrap_or(url); // `scheme://[user@]host[:port]/path`, or scp-like `[user@]host:path` let (authority, path) = match url.split_once("://") { Some((_, after)) => after.split_once('/')?, None => url.split_once(':')?, }; let host = authority.rsplit_once('@').map_or(authority, |(_, h)| h); let host = host.split_once(':').map_or(host, |(h, _)| h); // a local remote has no host to key on if host.is_empty() { return None; } let mut components = PathBuf::from(sanitize(&host.to_lowercase())); let mut depth = 0; for part in path.split('/').filter(|p| !p.is_empty()) { components.push(sanitize(part)); depth += 1; } (depth > 0).then_some(components) } fn sanitize(s: &str) -> String { let out: String = s .chars() .map(|c| { if c.is_ascii_alphanumeric() || matches!(c, '-' | '.' | '_') { c } else { '_' } }) .collect(); // `.` and `..` are legal characters but not legal components if out.chars().all(|c| c == '.') { return "_".repeat(out.len()); } out } fn store_root() -> Result { if let Some(root) = non_empty_var("AHAB_LINK_ROOT") { return Ok(PathBuf::from(root)); } if let Some(xdg) = non_empty_var("XDG_DATA_HOME") { return Ok(PathBuf::from(xdg).join("ahab")); } let home = non_empty_var("HOME") .ok_or_else(|| anyhow!("none of AHAB_LINK_ROOT, XDG_DATA_HOME or HOME is set"))?; Ok(PathBuf::from(home).join(".local/share/ahab")) } fn non_empty_var(name: &str) -> Option { env::var_os(name).filter(|v| !v.is_empty()) } fn tracked(repo: &Repo, rel: &Path) -> Result { let out = Command::new("git") .arg("-C") .arg(&repo.root) .args(["ls-files", "--cached", "--"]) .arg(rel) .output() .context("running git ls-files")?; if !out.status.success() { return Err(anyhow!( "git ls-files failed: {}", String::from_utf8_lossy(&out.stderr).trim() )); } Ok(!out.stdout.is_empty()) } fn ignored(repo: &Repo, rel: &Path) -> bool { Command::new("git") .arg("-C") .arg(&repo.root) .args(["check-ignore", "-q", "--"]) .arg(rel) .status() .is_ok_and(|s| s.success()) } fn symlink_metadata_opt(path: &Path) -> Result> { // symlink_metadata does not follow the link, so a symlink shows as one match fs::symlink_metadata(path) { Ok(meta) => Ok(Some(meta)), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), Err(e) => Err(e).with_context(|| format!("inspecting {}", path.display())), } } fn move_path(src: &Path, target: &Path) -> Result<()> { ensure_parent(target)?; // rename cannot cross filesystems, and the store often is another one match fs::rename(src, target) { Ok(()) => Ok(()), Err(rename_err) => match copy_recursive(src, target) { Ok(()) => remove_recursive(src), Err(copy_err) => Err(copy_err).with_context(|| { format!( "moving {} to {} (rename failed: {rename_err})", src.display(), target.display() ) }), }, } } fn copy_recursive(src: &Path, target: &Path) -> Result<()> { let meta = fs::symlink_metadata(src).with_context(|| format!("inspecting {}", src.display()))?; if meta.is_dir() { fs::create_dir_all(target) .with_context(|| format!("creating directory {}", target.display()))?; for entry in fs::read_dir(src).with_context(|| format!("reading directory {}", src.display()))? { let entry = entry?; copy_recursive(&entry.path(), &target.join(entry.file_name()))?; } return Ok(()); } if meta.is_symlink() { let dest = fs::read_link(src)?; return symlink(&dest, target) .with_context(|| format!("creating symlink {}", target.display())); } fs::copy(src, target) .with_context(|| format!("copying {} to {}", src.display(), target.display()))?; Ok(()) } fn remove_recursive(path: &Path) -> Result<()> { let meta = fs::symlink_metadata(path).with_context(|| format!("inspecting {}", path.display()))?; if meta.is_dir() { fs::remove_dir_all(path) } else { fs::remove_file(path) } .with_context(|| format!("removing {}", path.display())) } fn ensure_parent(target: &Path) -> Result<()> { if let Some(parent) = target.parent() { if !parent.as_os_str().is_empty() { fs::create_dir_all(parent) .with_context(|| format!("creating directory {}", parent.display()))?; } } Ok(()) } fn place_link(link_path: &Path, target: &Path) -> Result<()> { // symlink under a temp name and rename over the path: the rename is atomic let tmp = suffixed(link_path, ".ahab-tmp"); let _ = fs::remove_file(&tmp); symlink(target, &tmp) .with_context(|| format!("creating symlink {} -> {}", tmp.display(), target.display()))?; fs::rename(&tmp, link_path) .with_context(|| format!("replacing {} with a symlink", link_path.display()))?; Ok(()) } fn suffixed(path: &Path, suffix: &str) -> PathBuf { let mut out = path.as_os_str().to_owned(); out.push(suffix); PathBuf::from(out) } #[cfg(test)] mod tests { use super::{components_from_remote, sanitize}; use std::path::PathBuf; #[test] fn parses_every_spelling_of_a_remote() { let cases = [ ( "git@git.aflabs.org:urnik/afurnik.git", "git.aflabs.org/urnik/afurnik", ), ( "https://git.aflabs.org/urnik/afurnik.git", "git.aflabs.org/urnik/afurnik", ), ( "https://git.aflabs.org/urnik/afurnik", "git.aflabs.org/urnik/afurnik", ), ( "https://git.aflabs.org/urnik/afurnik/", "git.aflabs.org/urnik/afurnik", ), ( "ssh://git@git.aflabs.org:22/urnik/afurnik.git", "git.aflabs.org/urnik/afurnik", ), ( "git@GIT.Aflabs.org:urnik/AFurnik.git", "git.aflabs.org/urnik/AFurnik", ), ( "git@git.aflabs.org:urnik/internal/afurnik.git", "git.aflabs.org/urnik/internal/afurnik", ), ]; for (url, want) in cases { assert_eq!( components_from_remote(url), Some(PathBuf::from(want)), "url: {url}" ); } } #[test] fn rejects_remotes_without_a_host() { assert_eq!(components_from_remote("not-a-url"), None); assert_eq!(components_from_remote("/srv/git/afurnik.git"), None); assert_eq!(components_from_remote("file:///srv/git/afurnik.git"), None); assert_eq!(components_from_remote("https://git.aflabs.org/"), None); } #[test] fn sanitize_never_yields_a_traversal() { assert_eq!(sanitize(".."), "__"); assert_eq!(sanitize("."), "_"); assert_eq!(sanitize("a/b"), "a_b"); assert_eq!(sanitize(".env"), ".env"); } }