diff --git a/src/cli/link.rs b/src/cli/link.rs index b54be85..9e55503 100644 --- a/src/cli/link.rs +++ b/src/cli/link.rs @@ -30,6 +30,12 @@ pub enum Link { store: Store, }, + /// List what this repository keeps in the store + List { + #[command(flatten)] + store: Store, + }, + /// List untracked paths a sandbox would still see Check { /// Limit the listing to these paths diff --git a/src/commands/link.rs b/src/commands/link.rs index e7533d1..534840d 100644 --- a/src/commands/link.rs +++ b/src/commands/link.rs @@ -44,24 +44,49 @@ pub fn restore(ctx: &Ctx, paths: &[PathBuf], all: bool, store: Option<&Path>) -> let repo = Repo::discover(ctx, store)?; let report = Report::new(&repo); - let paths = match (all, paths) { - (true, []) => linked_paths(&repo, &repo.store)?, + let stored = match (all, paths) { + (true, []) => stored_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(), + (false, paths) => paths.iter().cloned().map(|p| (p, Stored::Linked)).collect(), }; - if paths.is_empty() { - note!(ctx, "nothing in the store for this repository"); + // only a linked path can be moved back; the store can hold orphans too + let linked: Vec = 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(()); } - 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); } let mut failed = 0; - for path in &paths { + for path in &linked { if let Err(e) = restore_one(ctx, &repo, path, &report) { note!(ctx, "error: {e:#}"); failed += 1; @@ -69,7 +94,7 @@ pub fn restore(ctx: &Ctx, paths: &[PathBuf], all: bool, store: Option<&Path>) -> } if failed > 0 { - return Err(anyhow!("{failed} of {} paths failed", paths.len())); + return Err(anyhow!("{failed} of {} paths failed", linked.len())); } Ok(()) } @@ -109,9 +134,20 @@ fn restore_one(ctx: &Ctx, repo: &Repo, path: &Path, report: &Report) -> Result<( 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 -// repository has linked without asking git anything -fn linked_paths(repo: &Repo, dir: &Path) -> Result> { +// repository has put there without asking git anything +fn stored_paths(repo: &Repo, dir: &Path) -> Result> { let mut found = Vec::new(); let entries = match read_dir(dir) { @@ -127,20 +163,51 @@ fn linked_paths(repo: &Repo, dir: &Path) -> Result> { .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()) - && read_link(&src).is_ok_and(|dest| dest == stored); + let state = match symlink_metadata_opt(&src)? { + 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 { - found.push(src); - } else if stored.is_dir() { - found.extend(linked_paths(repo, &stored)?); + // a linked directory is one entry; otherwise the paths inside it are + if state == Stored::Linked || !stored.is_dir() { + found.push((src, state)); + } else { + found.extend(stored_paths(repo, &stored)?); } } - found.sort(); + found.sort_by(|(a, _), (b, _)| a.cmp(b)); 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 { store: PathBuf, named: Cell, @@ -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<()> { let src = resolve(path)?; let rel = repo.relative(&src)?; diff --git a/src/main.rs b/src/main.rs index b374b37..a8422e3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -75,6 +75,9 @@ fn run(ctx: &Ctx, command: cli::Commands) -> Result { force, store, } => 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 } => { commands::link::restore(ctx, &paths, all, store.root.as_deref()).map(|()| done) }