use fs_err::{read_dir, read_link}; use std::ffi::OsString; use std::os::unix::ffi::{OsStrExt, OsStringExt}; use std::path::{Path, PathBuf}; use anyhow::{Result, anyhow}; use super::store::{Leads, Repo, leads, resolve, symlink_metadata_opt}; use crate::cli::link as cli; use crate::cmd::{Cmd, LsFiles}; use crate::ctx::Ctx; use crate::output::{line, text, write_bytes}; // whether anything is outside the store, which main turns into an exit code pub fn check(ctx: &Ctx, args: &cli::Check) -> Result { let repo = Repo::discover(ctx, args.store.root())?; let pathspecs = relative_pathspecs(&repo, &args.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(ctx, &repo, ignored, &pathspecs)? { // --directory collapses a wholly untracked dir into `dir/` match entry.strip_suffix(b"/") { Some(dir) => exposed.extend(walk(&repo, &path_from(dir), mark)?.1), None => exposed.extend(classify(&repo, &path_from(&entry), mark)?), } } } // and says nothing about tracked paths, where a symlink out still counts for entry in list_tracked(ctx, &repo, &pathspecs)? { exposed.extend(classify_tracked(&repo, &path_from(&entry))?); } exposed.sort_by(|a, b| a.name.cmp(&b.name)); if args.porcelain || args.null { print_porcelain(&exposed, args.null); } else { print_listing(&repo, &exposed); } Ok(!exposed.is_empty()) } fn print_porcelain(exposed: &[Exposed], null: bool) { // -z is the format for scripts that must survive any filename, so its // records are written as the bytes a path actually is. a filename can hold // an arrow but not a NUL, as `git status -z` also assumes if null { for item in exposed { let mut record = item.code().into_bytes(); record.push(b' '); record.extend_from_slice(item.name.as_os_str().as_bytes()); if let Some(dest) = &item.dest { record.push(0); record.extend_from_slice(dest.as_os_str().as_bytes()); } record.push(0); write_bytes(&record); } return; } for item in exposed { match &item.dest { Some(dest) => text!( "{} {} -> {}\n", item.code(), item.name.display(), dest.display() ), None => text!("{} {}\n", item.code(), item.name.display()), } } } fn print_listing(repo: &Repo, exposed: &[Exposed]) { line!("store: {}", repo.store.display()); if exposed.is_empty() { line!("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; } line!("\n{heading}\n{hint}"); for item in items { match &item.dest { Some(dest) => line!("\t{} -> {}", item.name.display(), dest.display()), None => line!("\t{}", item.name.display()), } } } } 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 = '>'; // no git equivalent: a tracked path, which only ever appears as a symlink out const TRACKED: char = 'T'; struct Exposed { mark: char, name: PathBuf, dest: Option, } impl Exposed { fn content(mark: char, name: PathBuf) -> 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.to_path_buf(); let Some(meta) = symlink_metadata_opt(&src)? else { return Ok(None); }; if !meta.is_symlink() { return Ok(Some(Exposed::content(mark, name))); } let dest = read_link(&src)?; if dest == repo.store.join(rel) { // it names the store, but what the store holds there can be a symlink of // its own leading straight back out, which is not being held at all return Ok(match leads(&src, &repo.store) { Leads::Inside | Leads::Dangling => None, Leads::Outside(end) => Some(Exposed { mark, name, dest: Some(end), }), }); } Ok(Some(Exposed { mark, name, dest: Some(dest), })) } // git tracks symlinks, so one can lead out of the repository without ever // showing up in an untracked or ignored listing; `add` cannot externalize it // either, so all check can do is say it is there fn classify_tracked(repo: &Repo, rel: &Path) -> Result> { let src = repo.root.join(rel); let Some(meta) = symlink_metadata_opt(&src)? else { return Ok(None); }; if !meta.is_symlink() { return Ok(None); } Ok(match leads(&src, &repo.root) { Leads::Inside | Leads::Dangling => None, Leads::Outside(end) => Some(Exposed { mark: TRACKED, name: rel.to_path_buf(), dest: Some(end), }), }) } 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 read_dir(&dir)? { 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 -- unless some of // it leads out of the repository, which is a different thing to report and // carries a destination the one line would drop let all_content = exposed.iter().all(|item| item.dest.is_none()); if handled == 0 && !exposed.is_empty() && all_content { let mut name = rel.as_os_str().to_owned(); name.push("/"); return Ok((0, vec![Exposed::content(mark, PathBuf::from(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( ctx: &Ctx, repo: &Repo, ignored: bool, pathspecs: &[PathBuf], ) -> Result>> { let mut listing = LsFiles::untracked(&repo.root).limited_to(pathspecs); if ignored { listing = listing.ignored(); } Ok(split_nul(&listing.capture_bytes(ctx)?)) } fn list_tracked(ctx: &Ctx, repo: &Repo, pathspecs: &[PathBuf]) -> Result>> { let listing = LsFiles::tracked(&repo.root).limited_to(pathspecs); Ok(split_nul(&listing.capture_bytes(ctx)?)) } // every listing check reads is asked for with -z, and kept as the bytes git // wrote: a path is not obliged to be utf-8, and one that is not used to fail // the whole listing rather than the one entry fn split_nul(out: &[u8]) -> Vec> { out.split(|byte| *byte == 0) .filter(|record| !record.is_empty()) .map(<[u8]>::to_vec) .collect() } fn path_from(bytes: &[u8]) -> PathBuf { PathBuf::from(OsString::from_vec(bytes.to_vec())) }