refactor: name the modules after what they hold

This commit is contained in:
2026-09-08 12:10:41 +00:00
parent a3c08f6eaa
commit bb0a2cca44
12 changed files with 1014 additions and 989 deletions

243
src/commands/link/check.rs Normal file
View File

@@ -0,0 +1,243 @@
use fs_err::{read_dir, read_link};
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use anyhow::{Result, anyhow};
use super::store::{Repo, resolve, symlink_metadata_opt};
use crate::cmd::{Cmd, LsFiles};
use crate::ctx::Ctx;
pub fn check(
ctx: &Ctx,
paths: &[PathBuf],
porcelain: bool,
null: bool,
exit_code: bool,
store: Option<&Path>,
) -> Result<ExitCode> {
let repo = Repo::discover(ctx, store)?;
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(ctx, &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() {
return Ok(ExitCode::FAILURE);
}
Ok(ExitCode::SUCCESS)
}
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 <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;
}
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<PathBuf>,
}
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<Option<Exposed>> {
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 = read_link(&src)?;
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<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
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<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<String>> {
let mut listing = LsFiles::untracked(&repo.root).limited_to(pathspecs);
if ignored {
listing = listing.ignored();
}
Ok(listing
.capture(ctx)?
.split('\0')
.filter(|p| !p.is_empty())
.map(String::from)
.collect())
}