317 lines
9.1 KiB
Rust
317 lines
9.1 KiB
Rust
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<bool> {
|
|
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 <path>...\" to move them into the store)",
|
|
Section::Content(UNTRACKED),
|
|
),
|
|
(
|
|
"Ignored paths a sandbox can read:",
|
|
" (use \"ahab link add <path>...\" 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<PathBuf>,
|
|
}
|
|
|
|
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<Option<Exposed>> {
|
|
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<Option<Exposed>> {
|
|
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<Exposed>)> {
|
|
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<Vec<PathBuf>> {
|
|
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<Vec<Vec<u8>>> {
|
|
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<Vec<Vec<u8>>> {
|
|
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<Vec<u8>> {
|
|
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()))
|
|
}
|