feat: list what the store holds for this repository

This commit is contained in:
2026-09-08 14:16:38 +00:00
parent 6389f3f1f5
commit 79ef5cca2d
3 changed files with 93 additions and 18 deletions

View File

@@ -30,6 +30,12 @@ pub enum Link {
store: Store, store: Store,
}, },
/// List what this repository keeps in the store
List {
#[command(flatten)]
store: Store,
},
/// List untracked paths a sandbox would still see /// List untracked paths a sandbox would still see
Check { Check {
/// Limit the listing to these paths /// Limit the listing to these paths

View File

@@ -44,24 +44,49 @@ pub fn restore(ctx: &Ctx, paths: &[PathBuf], all: bool, store: Option<&Path>) ->
let repo = Repo::discover(ctx, store)?; let repo = Repo::discover(ctx, store)?;
let report = Report::new(&repo); let report = Report::new(&repo);
let paths = match (all, paths) { let stored = match (all, paths) {
(true, []) => linked_paths(&repo, &repo.store)?, (true, []) => stored_paths(&repo, &repo.store)?,
(true, _) => bail!("--all restores everything, so it takes no paths"), (true, _) => bail!("--all restores everything, so it takes no paths"),
(false, []) => bail!("name a path to restore, or pass --all"), (false, []) => bail!("name a path to restore, or pass --all"),
(false, paths) => paths.to_vec(), (false, paths) => paths.iter().cloned().map(|p| (p, Stored::Linked)).collect(),
}; };
if paths.is_empty() { // only a linked path can be moved back; the store can hold orphans too
note!(ctx, "nothing in the store for this repository"); let linked: Vec<PathBuf> = stored
.iter()
.filter(|(_, state)| *state == Stored::Linked)
.map(|(path, _)| path.clone())
.collect();
let skipped = stored.len() - linked.len();
if linked.is_empty() {
match skipped {
0 => note!(ctx, "nothing in the store for this repository"),
n => note!(
ctx,
"nothing to restore: {n} path{} in the store {} not linked, see `ahab link list`",
if n == 1 { "" } else { "s" },
if n == 1 { "is" } else { "are" }
),
}
return Ok(()); return Ok(());
} }
if let [path] = paths.as_slice() { if skipped > 0 {
note!(
ctx,
"leaving {skipped} path{} that the store holds but nothing links to",
if skipped == 1 { "" } else { "s" }
);
}
if let [path] = linked.as_slice() {
return restore_one(ctx, &repo, path, &report); return restore_one(ctx, &repo, path, &report);
} }
let mut failed = 0; let mut failed = 0;
for path in &paths { for path in &linked {
if let Err(e) = restore_one(ctx, &repo, path, &report) { if let Err(e) = restore_one(ctx, &repo, path, &report) {
note!(ctx, "error: {e:#}"); note!(ctx, "error: {e:#}");
failed += 1; failed += 1;
@@ -69,7 +94,7 @@ pub fn restore(ctx: &Ctx, paths: &[PathBuf], all: bool, store: Option<&Path>) ->
} }
if failed > 0 { if failed > 0 {
return Err(anyhow!("{failed} of {} paths failed", paths.len())); return Err(anyhow!("{failed} of {} paths failed", linked.len()));
} }
Ok(()) Ok(())
} }
@@ -109,9 +134,20 @@ fn restore_one(ctx: &Ctx, repo: &Repo, path: &Path, report: &Report) -> Result<(
Ok(()) Ok(())
} }
// what the repository looks like from the store's side
#[derive(PartialEq)]
enum Stored {
// the symlink is there and points at the store, which is the whole point
Linked,
// nothing is at the path the store holds it for
Missing,
// something else took the path, so the stored copy is the one nobody reads
Taken,
}
// the store mirrors the repository layout, so walking it finds every path this // the store mirrors the repository layout, so walking it finds every path this
// repository has linked without asking git anything // repository has put there without asking git anything
fn linked_paths(repo: &Repo, dir: &Path) -> Result<Vec<PathBuf>> { fn stored_paths(repo: &Repo, dir: &Path) -> Result<Vec<(PathBuf, Stored)>> {
let mut found = Vec::new(); let mut found = Vec::new();
let entries = match read_dir(dir) { let entries = match read_dir(dir) {
@@ -127,20 +163,51 @@ fn linked_paths(repo: &Repo, dir: &Path) -> Result<Vec<PathBuf>> {
.expect("walked out of the store"); .expect("walked out of the store");
let src = repo.root.join(rel); let src = repo.root.join(rel);
let linked = symlink_metadata_opt(&src)?.is_some_and(|meta| meta.is_symlink()) let state = match symlink_metadata_opt(&src)? {
&& read_link(&src).is_ok_and(|dest| dest == stored); None => Stored::Missing,
Some(meta) if !meta.is_symlink() => Stored::Taken,
Some(_) if read_link(&src).is_ok_and(|dest| dest == stored) => Stored::Linked,
Some(_) => Stored::Taken,
};
if linked { // a linked directory is one entry; otherwise the paths inside it are
found.push(src); if state == Stored::Linked || !stored.is_dir() {
} else if stored.is_dir() { found.push((src, state));
found.extend(linked_paths(repo, &stored)?); } else {
found.extend(stored_paths(repo, &stored)?);
} }
} }
found.sort(); found.sort_by(|(a, _), (b, _)| a.cmp(b));
Ok(found) Ok(found)
} }
// list what the store holds for this repository, the inverse of check
pub fn list(ctx: &Ctx, store: Option<&Path>) -> Result<()> {
let repo = Repo::discover(ctx, store)?;
let stored = stored_paths(&repo, &repo.store)?;
println!("store: {}", repo.store.display());
if stored.is_empty() {
println!("nothing in the store for this repository");
return Ok(());
}
for (path, state) in stored {
let rel = path.strip_prefix(&repo.root).unwrap_or(&path);
let verb = match state {
Stored::Linked => "linked",
Stored::Missing => "missing",
Stored::Taken => "shadowed",
};
println!("\t{:<11}{}", format!("{verb}:"), rel.display());
}
Ok(())
}
struct Report { struct Report {
store: PathBuf, store: PathBuf,
named: Cell<bool>, named: Cell<bool>,
@@ -164,7 +231,6 @@ impl Report {
} }
} }
// list untracked paths not in the store, i.e. what a sandbox can still read
fn link_one(ctx: &Ctx, repo: &Repo, path: &Path, force: bool, report: &Report) -> Result<()> { fn link_one(ctx: &Ctx, repo: &Repo, path: &Path, force: bool, report: &Report) -> Result<()> {
let src = resolve(path)?; let src = resolve(path)?;
let rel = repo.relative(&src)?; let rel = repo.relative(&src)?;

View File

@@ -75,6 +75,9 @@ fn run(ctx: &Ctx, command: cli::Commands) -> Result<ExitCode> {
force, force,
store, store,
} => commands::link::add(ctx, &paths, force, store.root.as_deref()).map(|()| done), } => commands::link::add(ctx, &paths, force, store.root.as_deref()).map(|()| done),
cli::Link::List { store } => {
commands::link::list(ctx, store.root.as_deref()).map(|()| done)
}
cli::Link::Restore { paths, all, store } => { cli::Link::Restore { paths, all, store } => {
commands::link::restore(ctx, &paths, all, store.root.as_deref()).map(|()| done) commands::link::restore(ctx, &paths, all, store.root.as_deref()).map(|()| done)
} }