From cabbbde4382d79420ede5ccbc7de00b099a99a9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Jane=C5=BEi=C4=8D?= Date: Tue, 8 Sep 2026 13:48:49 +0000 Subject: [PATCH 1/9] fix: import into a cluster that has no such database yet --- src/cmd/postgres.rs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/cmd/postgres.rs b/src/cmd/postgres.rs index a39269f..4154496 100644 --- a/src/cmd/postgres.rs +++ b/src/cmd/postgres.rs @@ -21,8 +21,10 @@ pub struct DropDb<'a> { impl Cmd for DropDb<'_> { fn argv(&self) -> Argv { + // a cluster that never held this database is still a restore target Argv::new("dropdb") .flag("--username", self.username) + .arg("--if-exists") .arg(self.dbname) } } @@ -174,9 +176,22 @@ impl Cmd for PgDumpAll<'_> { #[cfg(test)] mod tests { - use super::{PgDump, Psql}; + use super::{DropDb, PgDump, Psql}; use crate::cmd::Cmd; + #[test] + fn a_missing_database_is_not_a_failed_import() { + assert_eq!( + DropDb { + username: "u", + dbname: "db", + } + .argv() + .quoted(), + "dropdb --username u --if-exists db" + ); + } + #[test] fn a_directory_dump_names_the_file_it_writes() { assert_eq!( From 61f35f5ae469d49e452d25c33d10a9f040e6fcf4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Jane=C5=BEi=C4=8D?= Date: Tue, 8 Sep 2026 14:16:08 +0000 Subject: [PATCH 2/9] feat: silence progress with --quiet --- src/cli/ahab.rs | 4 ++++ src/cmd/compose.rs | 46 +++++++++++++++++++++----------------- src/commands/django.rs | 8 +++---- src/commands/link.rs | 6 ++--- src/commands/link/store.rs | 5 ++++- src/commands/postgres.rs | 29 ++++++++++++++---------- src/ctx.rs | 1 + src/main.rs | 3 ++- src/output.rs | 10 ++++++--- 9 files changed, 67 insertions(+), 45 deletions(-) diff --git a/src/cli/ahab.rs b/src/cli/ahab.rs index e1f5feb..61a9a4a 100644 --- a/src/cli/ahab.rs +++ b/src/cli/ahab.rs @@ -16,6 +16,10 @@ pub struct Ahab { #[arg(short, long, global = true)] pub verbose: bool, + /// Print only what was asked for, not the progress along the way + #[arg(short, long, global = true, conflicts_with = "verbose")] + pub quiet: bool, + /// Print the docker commands that would run, without running them #[arg(long, global = true)] pub dry_run: bool, diff --git a/src/cmd/compose.rs b/src/cmd/compose.rs index 77f55c5..d06313f 100644 --- a/src/cmd/compose.rs +++ b/src/cmd/compose.rs @@ -1,7 +1,13 @@ use super::{Argv, Cmd}; -fn compose() -> Argv { - Argv::new("docker").arg("compose") +// compose prints its own progress, which --quiet has to ask it to stop +fn compose(quiet: bool) -> Argv { + let argv = Argv::new("docker").arg("compose"); + + match quiet { + true => argv.flag("--progress", "quiet"), + false => argv, + } } // docker compose run --rm, which runs the image's entrypoint and so fixes up the @@ -22,7 +28,7 @@ impl Run { impl Cmd for Run { fn argv(&self) -> Argv { - compose() + compose(false) .arg("run") .arg("--rm") .arg(&self.service) @@ -35,7 +41,7 @@ pub struct Config; impl Cmd for Config { fn argv(&self) -> Argv { - compose().arg("config").flag("--format", "json") + compose(false).arg("config").flag("--format", "json") } } @@ -54,39 +60,37 @@ impl Ps { impl Cmd for Ps { fn argv(&self) -> Argv { - compose().arg("ps").arg("--quiet").arg(&self.service) + compose(false).arg("ps").arg("--quiet").arg(&self.service) } } -pub struct Up; +pub struct Up { + pub quiet: bool, +} impl Cmd for Up { fn argv(&self) -> Argv { - compose().arg("up").arg("--detach") + compose(self.quiet).arg("up").arg("--detach") } } -pub struct Start { - service: String, +pub struct Start<'a> { + pub service: &'a str, + pub quiet: bool, } -impl Start { - pub fn service(service: &str) -> Self { - Self { - service: service.to_string(), - } - } -} - -impl Cmd for Start { +impl Cmd for Start<'_> { fn argv(&self) -> Argv { - compose().arg("start").arg(&self.service) + compose(self.quiet).arg("start").arg(self.service) } } -pub struct Stop; + +pub struct Stop { + pub quiet: bool, +} impl Cmd for Stop { fn argv(&self) -> Argv { - compose().arg("stop") + compose(self.quiet).arg("stop") } } diff --git a/src/commands/django.rs b/src/commands/django.rs index 704e200..997c3d4 100644 --- a/src/commands/django.rs +++ b/src/commands/django.rs @@ -24,7 +24,7 @@ pub fn make_command(ctx: &Ctx, app: &PathBuf, name: &str) -> Result<()> { return Err(anyhow!("directory {app_name} does not exist")); } - note!("found app {app_name}"); + note!(ctx, "found app {app_name}"); let management_dir = app_dir.join("management"); @@ -32,7 +32,7 @@ pub fn make_command(ctx: &Ctx, app: &PathBuf, name: &str) -> Result<()> { create_dir(ctx, &management_dir)?; touch(ctx, &management_dir.join("__init__.py"))?; - note!("created module {app_name}.management") + note!(ctx, "created module {app_name}.management") }; let commands_dir = management_dir.join("commands"); @@ -41,7 +41,7 @@ pub fn make_command(ctx: &Ctx, app: &PathBuf, name: &str) -> Result<()> { create_dir(ctx, &commands_dir)?; touch(ctx, &commands_dir.join("__init__.py"))?; - note!("created module {app_name}.management.commands") + note!(ctx, "created module {app_name}.management.commands") }; write_new( @@ -50,7 +50,7 @@ pub fn make_command(ctx: &Ctx, app: &PathBuf, name: &str) -> Result<()> { DEBUG_TEMPLATE.as_bytes(), )?; - note!("created command {app_name}.management.commands.{name}"); + note!(ctx, "created command {app_name}.management.commands.{name}"); Ok(()) } diff --git a/src/commands/link.rs b/src/commands/link.rs index d2a91df..e7533d1 100644 --- a/src/commands/link.rs +++ b/src/commands/link.rs @@ -28,7 +28,7 @@ pub fn add(ctx: &Ctx, paths: &[PathBuf], force: bool, store: Option<&Path>) -> R let mut failed = 0; for path in paths { if let Err(e) = link_one(ctx, &repo, path, force, &report) { - note!("error: {e:#}"); + note!(ctx, "error: {e:#}"); failed += 1; } } @@ -52,7 +52,7 @@ pub fn restore(ctx: &Ctx, paths: &[PathBuf], all: bool, store: Option<&Path>) -> }; if paths.is_empty() { - note!("nothing in the store for this repository"); + note!(ctx, "nothing in the store for this repository"); return Ok(()); } @@ -63,7 +63,7 @@ pub fn restore(ctx: &Ctx, paths: &[PathBuf], all: bool, store: Option<&Path>) -> let mut failed = 0; for path in &paths { if let Err(e) = restore_one(ctx, &repo, path, &report) { - note!("error: {e:#}"); + note!(ctx, "error: {e:#}"); failed += 1; } } diff --git a/src/commands/link/store.rs b/src/commands/link/store.rs index 5683c22..ef7c89b 100644 --- a/src/commands/link/store.rs +++ b/src/commands/link/store.rs @@ -86,7 +86,10 @@ fn repo_components(ctx: &Ctx, root: &Path) -> Result { if let Some(components) = components_from_remote(&url) { return Ok(components); } - note!("could not parse git remote `{url}`, falling back to the checkout name"); + note!( + ctx, + "could not parse git remote `{url}`, falling back to the checkout name" + ); } let name = root diff --git a/src/commands/postgres.rs b/src/commands/postgres.rs index 3ef52cf..8a5fe1a 100644 --- a/src/commands/postgres.rs +++ b/src/commands/postgres.rs @@ -45,11 +45,12 @@ fn restore_cluster(ctx: &Ctx, db: &Database, restore: &dyn Cmd, file: &Path) -> continue; } - note!("{line}"); + note!(ctx, "{line}"); } if existing > 0 { note!( + ctx, "left {existing} existing role{} alone", if existing == 1 { "" } else { "s" } ); @@ -66,11 +67,15 @@ pub fn import(ctx: &Ctx, file: &Path) -> Result<()> { let dump = Dump::of(file)?; let db = Database::resolve(ctx)?; - note!("stopping all containers"); - Stop.run(ctx)?; + note!(ctx, "stopping all containers"); + Stop { quiet: ctx.quiet }.run(ctx)?; - note!("starting db container"); - Start::service(&db.service).run(ctx)?; + note!(ctx, "starting db container"); + Start { + service: &db.service, + quiet: ctx.quiet, + } + .run(ctx)?; let remote = remote_dump(); @@ -121,7 +126,7 @@ pub fn import(ctx: &Ctx, file: &Path) -> Result<()> { let restore = db.restore_with(kind); // the name of what actually runs, so the message cannot drift from it let tool = restore.argv().program().to_string(); - note!("restoring database with {tool}"); + note!(ctx, "restoring database with {tool}"); when_ready( ctx, @@ -149,7 +154,7 @@ pub fn import(ctx: &Ctx, file: &Path) -> Result<()> { wait_until_ready(ctx, &db)?; if ctx.dry_run { - note!("would restore with {tool}"); + note!(ctx, "would restore with {tool}"); } else { // a directory dump was copied in whole, so pg_restore reads it from the // container; every other shape is fed in on stdin, through gunzip when it @@ -190,9 +195,9 @@ pub fn import(ctx: &Ctx, file: &Path) -> Result<()> { let _ = Rm::recursive(&remote).in_container(&db.container).run(ctx); } - note!("restarting containers"); - Stop.run(ctx)?; - Up.run(ctx)?; + note!(ctx, "restarting containers"); + Stop { quiet: ctx.quiet }.run(ctx)?; + Up { quiet: ctx.quiet }.run(ctx)?; Ok(()) } @@ -208,7 +213,7 @@ pub fn dump(ctx: &Ctx, file: &Path, format: Format, gzip: bool) -> Result<()> { return dump_directory(ctx, &db, file); } - note!("dumping to local file {}", file.to_string_lossy()); + note!(ctx, "dumping to local file {}", file.to_string_lossy()); // written beside the target and renamed once the dump succeeds, so a failure // cannot destroy the dump that is already there @@ -259,7 +264,7 @@ fn dump_directory(ctx: &Ctx, db: &Database, target: &Path) -> Result<()> { ); } - note!("dumping to local directory {}", target.display()); + note!(ctx, "dumping to local directory {}", target.display()); let remote = remote_dump(); PgDump::new(&db.user, &db.name, "d") diff --git a/src/ctx.rs b/src/ctx.rs index 38e7303..1099bc6 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -2,4 +2,5 @@ pub struct Ctx { pub verbose: bool, pub dry_run: bool, + pub quiet: bool, } diff --git a/src/main.rs b/src/main.rs index b40d113..34fa775 100644 --- a/src/main.rs +++ b/src/main.rs @@ -20,12 +20,13 @@ fn main() -> ExitCode { let ctx = Ctx { verbose: args.verbose, dry_run: args.dry_run, + quiet: args.quiet, }; // said once here rather than by each command, so every line that follows // reads as the plan it is if ctx.dry_run { - note!("dry run, nothing will be changed"); + note!(ctx, "dry run, nothing will be changed"); } match run(&ctx, args.command) { diff --git a/src/output.rs b/src/output.rs index 7ef0543..5b7e7c5 100644 --- a/src/output.rs +++ b/src/output.rs @@ -1,9 +1,13 @@ -// progress and warnings go to stderr, so what a caller pipes is only ever the -// data a command was asked for +// progress, on stderr and only when it was asked for macro_rules! note { - ($($arg:tt)*) => { eprintln!($($arg)*) }; + ($ctx:expr, $($arg:tt)*) => { + if !$ctx.quiet { + eprintln!($($arg)*) + } + }; } +// not progress: it speaks about the result, so --quiet keeps it macro_rules! warning { ($($arg:tt)*) => { eprintln!("warning: {}", format_args!($($arg)*)) }; } From 6389f3f1f582d1059637de5bb1aae4cbef3823f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Jane=C5=BEi=C4=8D?= Date: Tue, 8 Sep 2026 14:16:18 +0000 Subject: [PATCH 3/9] feat: open psql in the database container --- src/cli/postgres.rs | 7 +++++++ src/cmd/docker.rs | 12 +++++++++++ src/cmd/postgres.rs | 35 ++++++++++++++++++++++++++------- src/commands/postgres.rs | 18 ++++++++++++++--- src/commands/postgres/server.rs | 4 ++-- src/main.rs | 1 + 6 files changed, 65 insertions(+), 12 deletions(-) diff --git a/src/cli/postgres.rs b/src/cli/postgres.rs index e0a90b3..2f50505 100644 --- a/src/cli/postgres.rs +++ b/src/cli/postgres.rs @@ -7,6 +7,13 @@ pub enum Postgres { /// Import a dump, in any format ahab can produce Import { path: PathBuf }, + /// Open psql in the database container + Psql { + /// Arguments for psql itself + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + rest: Vec, + }, + /// Dump via pg_dump, or pg_dumpall for a whole cluster Dump { path: PathBuf, diff --git a/src/cmd/docker.rs b/src/cmd/docker.rs index d1fbc10..b57d9b1 100644 --- a/src/cmd/docker.rs +++ b/src/cmd/docker.rs @@ -4,6 +4,7 @@ use super::{Argv, Cmd}; pub struct Exec { container: String, interactive: bool, + tty: bool, inner: Argv, } @@ -12,10 +13,17 @@ impl Exec { Self { container: container.to_string(), interactive: false, + tty: false, inner, } } + // a terminal, for a command that is a shell rather than a pipe stage + pub fn tty(mut self, tty: bool) -> Self { + self.tty = tty; + self + } + // keep stdin open, for a command that is fed a dump pub fn interactive(mut self) -> Self { self.interactive = true; @@ -31,6 +39,10 @@ impl Cmd for Exec { argv = argv.arg("--interactive"); } + if self.tty { + argv = argv.arg("--tty"); + } + argv.arg(&self.container).args(self.inner.words()) } } diff --git a/src/cmd/postgres.rs b/src/cmd/postgres.rs index 4154496..74b9aea 100644 --- a/src/cmd/postgres.rs +++ b/src/cmd/postgres.rs @@ -82,11 +82,13 @@ impl Cmd for PgRestore<'_> { } } -// reads a plain sql dump, of one database or of a whole cluster +// reads a plain sql dump, or runs as the interactive shell it is pub struct Psql<'a> { username: &'a str, dbname: &'a str, + quiet: bool, atomic: bool, + rest: &'a [String], } impl<'a> Psql<'a> { @@ -94,33 +96,51 @@ impl<'a> Psql<'a> { Self { username, dbname, + quiet: false, atomic: false, + rest: &[], } } + // say nothing and print no result rows, for a restore whose output is noise + pub fn quiet(mut self) -> Self { + self.quiet = true; + self + } + // stop at the first error and undo the rest, which a cluster dump cannot do: // it connects to each database itself, and trips over roles already there pub fn atomic(mut self) -> Self { self.atomic = true; self } + + // whatever the caller typed, for psql's own flags + pub fn args(mut self, rest: &'a [String]) -> Self { + self.rest = rest; + self + } } impl Cmd for Psql<'_> { fn argv(&self) -> Argv { - let argv = Argv::new("psql") - .arg("--quiet") - .flag("--output", "/dev/null") + let mut argv = Argv::new("psql"); + + if self.quiet { + argv = argv.arg("--quiet").flag("--output", "/dev/null"); + } + + argv = argv .flag("--username", self.username) .flag("--dbname", self.dbname); if self.atomic { - return argv + argv = argv .flag("--variable", "ON_ERROR_STOP=1") .arg("--single-transaction"); } - argv + argv.args(self.rest) } } @@ -208,13 +228,14 @@ mod tests { fn only_a_single_database_restore_stops_at_the_first_error() { assert!( Psql::new("u", "db") + .quiet() .atomic() .argv() .quoted() .ends_with("--variable 'ON_ERROR_STOP=1' --single-transaction") ); assert_eq!( - Psql::new("u", "postgres").argv().quoted(), + Psql::new("u", "postgres").quiet().argv().quoted(), "psql --quiet --output /dev/null --username u --dbname postgres" ); } diff --git a/src/commands/postgres.rs b/src/commands/postgres.rs index 8a5fe1a..17c11a5 100644 --- a/src/commands/postgres.rs +++ b/src/commands/postgres.rs @@ -2,7 +2,7 @@ mod server; mod shape; use fs_err::File; -use std::io::{self, Write}; +use std::io::{self, IsTerminal, Write}; use std::path::Path; use std::process::Stdio; @@ -12,8 +12,8 @@ use self::server::{Database, wait_until_ready, when_ready}; use self::shape::{Dump, HEADER_LEN, Kind}; use crate::cli::Format; use crate::cmd::{ - Cmd, Cp, CreateDb, DropDb, Gunzip, Gzip, Head, PgDump, PgDumpAll, PgRestore, Rm, Start, Stop, - Up, + Cmd, Cp, CreateDb, DropDb, Gunzip, Gzip, Head, PgDump, PgDumpAll, PgRestore, Psql, Rm, Start, + Stop, Up, }; use crate::ctx::Ctx; use crate::fsops::{remove_file, rename, suffixed}; @@ -202,6 +202,18 @@ pub fn import(ctx: &Ctx, file: &Path) -> Result<()> { Ok(()) } +pub fn psql(ctx: &Ctx, rest: &[String]) -> Result<()> { + let db = Database::resolve(ctx)?; + + // a terminal only if this one has one, so `psql -c ... | cat` still works + Psql::new(&db.user, &db.name) + .args(rest) + .in_container(&db.container) + .interactive() + .tty(io::stdin().is_terminal()) + .replace(ctx) +} + pub fn dump(ctx: &Ctx, file: &Path, format: Format, gzip: bool) -> Result<()> { let db = Database::resolve(ctx)?; diff --git a/src/commands/postgres/server.rs b/src/commands/postgres/server.rs index 3d1a47d..7e840cd 100644 --- a/src/commands/postgres/server.rs +++ b/src/commands/postgres/server.rs @@ -42,8 +42,8 @@ impl Database { pub(super) fn restore_with(&self, kind: Kind) -> Box { match kind { Kind::Archive => Box::new(PgRestore::new(&self.user, &self.name)), - Kind::Sql => Box::new(Psql::new(&self.user, &self.name).atomic()), - Kind::Cluster => Box::new(Psql::new(&self.user, "postgres")), + Kind::Sql => Box::new(Psql::new(&self.user, &self.name).quiet().atomic()), + Kind::Cluster => Box::new(Psql::new(&self.user, "postgres").quiet()), } } } diff --git a/src/main.rs b/src/main.rs index 34fa775..b374b37 100644 --- a/src/main.rs +++ b/src/main.rs @@ -61,6 +61,7 @@ fn run(ctx: &Ctx, command: cli::Commands) -> Result { cli::Commands::Postgres { command } => { match command { cli::Postgres::Import { path } => commands::postgres::import(ctx, &path), + cli::Postgres::Psql { rest } => commands::postgres::psql(ctx, &rest), cli::Postgres::Dump { path, format, gzip } => { commands::postgres::dump(ctx, &path, format, gzip) } From 79ef5cca2d001ff0cbdc81490b8017fa41bfa0b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Jane=C5=BEi=C4=8D?= Date: Tue, 8 Sep 2026 14:16:38 +0000 Subject: [PATCH 4/9] feat: list what the store holds for this repository --- src/cli/link.rs | 6 +++ src/commands/link.rs | 102 +++++++++++++++++++++++++++++++++++-------- src/main.rs | 3 ++ 3 files changed, 93 insertions(+), 18 deletions(-) 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) } From 1ecf7f1ee4c7dcd669fd6522dbeece6e77ecbc35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Jane=C5=BEi=C4=8D?= Date: Tue, 8 Sep 2026 14:17:08 +0000 Subject: [PATCH 5/9] feat: show what ahab makes of this project --- src/cli/ahab.rs | 8 +++- src/cli/mod.rs | 2 +- src/commands/link.rs | 13 +++++++ src/commands/mod.rs | 1 + src/commands/status.rs | 85 ++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 5 +++ src/project.rs | 12 +++++- 7 files changed, 123 insertions(+), 3 deletions(-) create mode 100644 src/commands/status.rs diff --git a/src/cli/ahab.rs b/src/cli/ahab.rs index 61a9a4a..3cb10f4 100644 --- a/src/cli/ahab.rs +++ b/src/cli/ahab.rs @@ -1,4 +1,4 @@ -use super::{Django, Link, Postgres}; +use super::{Django, Link, Postgres, Store}; use clap::builder::styling::{AnsiColor, Effects, Styles}; use clap::{Parser, Subcommand}; @@ -44,6 +44,12 @@ pub enum Commands { command: Link, }, + /// Show what ahab makes of this project + Status { + #[command(flatten)] + store: Store, + }, + /// Print a shell completion script on stdout Completions { /// Shell to generate the script for diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 72ef580..663207e 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -7,5 +7,5 @@ mod postgres; pub use ahab::{Ahab, Commands}; pub use django::Django; -pub use link::Link; +pub use link::{Link, Store}; pub use postgres::{Format, Postgres}; diff --git a/src/commands/link.rs b/src/commands/link.rs index 534840d..5ae669f 100644 --- a/src/commands/link.rs +++ b/src/commands/link.rs @@ -182,6 +182,19 @@ fn stored_paths(repo: &Repo, dir: &Path) -> Result> { Ok(found) } +// what the store holds, without the git questions check asks +pub fn stored_summary(ctx: &Ctx, store: Option<&Path>) -> Result<(PathBuf, usize, usize)> { + let repo = Repo::discover(ctx, store)?; + let stored = stored_paths(&repo, &repo.store)?; + + let linked = stored + .iter() + .filter(|(_, state)| *state == Stored::Linked) + .count(); + + Ok((repo.store, linked, stored.len() - linked)) +} + // 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)?; diff --git a/src/commands/mod.rs b/src/commands/mod.rs index e33f8ca..80b0fbd 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -2,3 +2,4 @@ pub mod completions; pub mod django; pub mod link; pub mod postgres; +pub mod status; diff --git a/src/commands/status.rs b/src/commands/status.rs new file mode 100644 index 0000000..4c6bdc5 --- /dev/null +++ b/src/commands/status.rs @@ -0,0 +1,85 @@ +use std::path::Path; + +use anyhow::Result; + +use crate::cmd::{Cmd, Ps}; +use crate::commands::link; +use crate::ctx::Ctx; +use crate::project::Project; + +// which services ahab picked, what it would talk to, what the store holds +pub fn status(ctx: &Ctx, store: Option<&Path>) -> Result<()> { + // a role it cannot pick out is worth reporting; a project it cannot read is not + let project = match Project::resolve(ctx) { + Ok(project) => project, + Err(e) => { + stored(ctx, store); + return Err(e); + } + }; + + println!("services: {}", project.names().join(", ")); + + role(ctx, "django", project.django(), &project); + role(ctx, "postgres", project.postgres(), &project); + stored(ctx, store); + + Ok(()) +} + +// what the store holds, and where to look for what it does not +fn stored(ctx: &Ctx, store: Option<&Path>) { + match link::stored_summary(ctx, store) { + Err(e) => println!("store: {e:#}"), + Ok((store, linked, other)) => { + println!("store: {}", store.display()); + println!("\tlinked: {linked}"); + + if other > 0 { + println!("\tnot linked: {other} (see `ahab link list`)"); + } + + println!("\t`ahab link check` lists what a sandbox can still read"); + } + } +} + +// one detected service: the name, the image behind it, and whether it is up +fn role(ctx: &Ctx, role: &str, detected: Result, project: &Project) { + let service = match detected { + Ok(service) => service, + Err(e) => { + println!("{role}: {e:#}"); + return; + } + }; + + let image = project + .image(&service) + .unwrap_or("built from the project") + .to_string(); + + println!("{role}: {service} ({image})"); + + match Ps::id_of(&service).capture(ctx) { + Ok(id) if id.trim().is_empty() => println!("\tcontainer: not running"), + Ok(id) => println!("\tcontainer: {}", short(id.trim())), + Err(e) => println!("\tcontainer: {e:#}"), + } + + if role == "postgres" { + let (user, database) = project.postgres_credentials(&service); + let source = |key: &str| match project.env(&service, key) { + Some(_) => "", + None => " (default, nothing in the environment)", + }; + + println!("\tuser: {user}{}", source("POSTGRES_USER")); + println!("\tdatabase: {database}{}", source("POSTGRES_DB")); + } +} + +// a container id is 64 characters and only the first few are ever typed +fn short(id: &str) -> String { + id.chars().take(12).collect() +} diff --git a/src/main.rs b/src/main.rs index a8422e3..39a6e05 100644 --- a/src/main.rs +++ b/src/main.rs @@ -97,6 +97,11 @@ fn run(ctx: &Ctx, command: cli::Commands) -> Result { store.root.as_deref(), ), }, + cli::Commands::Status { store } => { + commands::status::status(ctx, store.root.as_deref())?; + + Ok(done) + } cli::Commands::Completions { shell } => { commands::completions::completions(shell)?; diff --git a/src/project.rs b/src/project.rs index 296b3eb..239df00 100644 --- a/src/project.rs +++ b/src/project.rs @@ -91,7 +91,7 @@ impl Project { (user, database) } - fn names(&self) -> Vec<&str> { + pub fn names(&self) -> Vec<&str> { let mut names: Vec<&str> = self .services .as_object() @@ -102,6 +102,16 @@ impl Project { names } + // the image a service runs, which a service that builds its own does not have + pub fn image(&self, service: &str) -> Option<&str> { + self.services[service].get("image")?.as_str() + } + + // whether a value was set in the project or is ahab's own fallback + pub fn env(&self, service: &str, key: &str) -> Option { + env_var(&self.services[service], key) + } + fn publishes_ports(&self, service: &str) -> bool { self.services[service] .get("ports") From 99010cb64100ac1d6fbc33ebf5e3d22d5b76ed3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Jane=C5=BEi=C4=8D?= Date: Tue, 8 Sep 2026 14:21:16 +0000 Subject: [PATCH 6/9] fix: end quietly when a reader leaves the pipe early --- src/commands/link.rs | 12 ++++++------ src/commands/link/check.rs | 15 ++++++++------- src/commands/status.rs | 27 ++++++++++++++------------- src/output.rs | 30 +++++++++++++++++++++++++++++- 4 files changed, 57 insertions(+), 27 deletions(-) diff --git a/src/commands/link.rs b/src/commands/link.rs index 5ae669f..7b1b7b6 100644 --- a/src/commands/link.rs +++ b/src/commands/link.rs @@ -12,7 +12,7 @@ use anyhow::{Result, anyhow, bail}; use self::store::{Repo, ignored, resolve, symlink_metadata_opt, tracked}; use crate::ctx::Ctx; use crate::fsops::{move_path, place_link, prune_empty, remove_file, rename, suffixed}; -use crate::output::{note, warning}; +use crate::output::{line, note, warning}; const BACKUP_SUFFIX: &str = ".ahab-bak"; @@ -200,10 +200,10 @@ 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()); + line!("store: {}", repo.store.display()); if stored.is_empty() { - println!("nothing in the store for this repository"); + line!("nothing in the store for this repository"); return Ok(()); } @@ -215,7 +215,7 @@ pub fn list(ctx: &Ctx, store: Option<&Path>) -> Result<()> { Stored::Taken => "shadowed", }; - println!("\t{:<11}{}", format!("{verb}:"), rel.display()); + line!("\t{:<11}{}", format!("{verb}:"), rel.display()); } Ok(()) @@ -237,10 +237,10 @@ impl Report { fn line(&self, verb: &str, path: &Path) { // worth naming once per run if !self.named.replace(true) { - println!("store: {}", self.store.display()); + line!("store: {}", self.store.display()); } - println!("\t{:<11}{}", format!("{verb}:"), path.display()); + line!("\t{:<11}{}", format!("{verb}:"), path.display()); } } diff --git a/src/commands/link/check.rs b/src/commands/link/check.rs index 637431d..b13f869 100644 --- a/src/commands/link/check.rs +++ b/src/commands/link/check.rs @@ -7,6 +7,7 @@ use anyhow::{Result, anyhow}; use super::store::{Repo, resolve, symlink_metadata_opt}; use crate::cmd::{Cmd, LsFiles}; use crate::ctx::Ctx; +use crate::output::{line, text}; pub fn check( ctx: &Ctx, @@ -55,17 +56,17 @@ fn print_porcelain(exposed: &[Exposed], null: bool) { for item in exposed { match &item.dest { - Some(dest) => print!("{} {} -> {}{end}", item.code(), item.name, dest.display()), - None => print!("{} {}{end}", item.code(), item.name), + Some(dest) => text!("{} {} -> {}{end}", item.code(), item.name, dest.display()), + None => text!("{} {}{end}", item.code(), item.name), } } } fn print_listing(repo: &Repo, exposed: &[Exposed]) { - println!("store: {}", repo.store.display()); + line!("store: {}", repo.store.display()); if exposed.is_empty() { - println!("nothing outside the store, a sandbox would see tracked files only"); + line!("nothing outside the store, a sandbox would see tracked files only"); return; } @@ -93,11 +94,11 @@ fn print_listing(repo: &Repo, exposed: &[Exposed]) { continue; } - println!("\n{heading}\n{hint}"); + line!("\n{heading}\n{hint}"); for item in items { match &item.dest { - Some(dest) => println!("\t{} -> {}", item.name, dest.display()), - None => println!("\t{}", item.name), + Some(dest) => line!("\t{} -> {}", item.name, dest.display()), + None => line!("\t{}", item.name), } } } diff --git a/src/commands/status.rs b/src/commands/status.rs index 4c6bdc5..0da1340 100644 --- a/src/commands/status.rs +++ b/src/commands/status.rs @@ -5,6 +5,7 @@ use anyhow::Result; use crate::cmd::{Cmd, Ps}; use crate::commands::link; use crate::ctx::Ctx; +use crate::output::line; use crate::project::Project; // which services ahab picked, what it would talk to, what the store holds @@ -18,7 +19,7 @@ pub fn status(ctx: &Ctx, store: Option<&Path>) -> Result<()> { } }; - println!("services: {}", project.names().join(", ")); + line!("services: {}", project.names().join(", ")); role(ctx, "django", project.django(), &project); role(ctx, "postgres", project.postgres(), &project); @@ -30,16 +31,16 @@ pub fn status(ctx: &Ctx, store: Option<&Path>) -> Result<()> { // what the store holds, and where to look for what it does not fn stored(ctx: &Ctx, store: Option<&Path>) { match link::stored_summary(ctx, store) { - Err(e) => println!("store: {e:#}"), + Err(e) => line!("store: {e:#}"), Ok((store, linked, other)) => { - println!("store: {}", store.display()); - println!("\tlinked: {linked}"); + line!("store: {}", store.display()); + line!("\tlinked: {linked}"); if other > 0 { - println!("\tnot linked: {other} (see `ahab link list`)"); + line!("\tnot linked: {other} (see `ahab link list`)"); } - println!("\t`ahab link check` lists what a sandbox can still read"); + line!("\t`ahab link check` lists what a sandbox can still read"); } } } @@ -49,7 +50,7 @@ fn role(ctx: &Ctx, role: &str, detected: Result, project: &Project) { let service = match detected { Ok(service) => service, Err(e) => { - println!("{role}: {e:#}"); + line!("{role}: {e:#}"); return; } }; @@ -59,12 +60,12 @@ fn role(ctx: &Ctx, role: &str, detected: Result, project: &Project) { .unwrap_or("built from the project") .to_string(); - println!("{role}: {service} ({image})"); + line!("{role}: {service} ({image})"); match Ps::id_of(&service).capture(ctx) { - Ok(id) if id.trim().is_empty() => println!("\tcontainer: not running"), - Ok(id) => println!("\tcontainer: {}", short(id.trim())), - Err(e) => println!("\tcontainer: {e:#}"), + Ok(id) if id.trim().is_empty() => line!("\tcontainer: not running"), + Ok(id) => line!("\tcontainer: {}", short(id.trim())), + Err(e) => line!("\tcontainer: {e:#}"), } if role == "postgres" { @@ -74,8 +75,8 @@ fn role(ctx: &Ctx, role: &str, detected: Result, project: &Project) { None => " (default, nothing in the environment)", }; - println!("\tuser: {user}{}", source("POSTGRES_USER")); - println!("\tdatabase: {database}{}", source("POSTGRES_DB")); + line!("\tuser: {user}{}", source("POSTGRES_USER")); + line!("\tdatabase: {database}{}", source("POSTGRES_DB")); } } diff --git a/src/output.rs b/src/output.rs index 5b7e7c5..10448f1 100644 --- a/src/output.rs +++ b/src/output.rs @@ -1,3 +1,6 @@ +use std::fmt::Arguments; +use std::io::{self, Write}; + // progress, on stderr and only when it was asked for macro_rules! note { ($ctx:expr, $($arg:tt)*) => { @@ -12,4 +15,29 @@ macro_rules! warning { ($($arg:tt)*) => { eprintln!("warning: {}", format_args!($($arg)*)) }; } -pub(crate) use {note, warning}; +// what a command was asked for, on stdout +macro_rules! line { + ($($arg:tt)*) => { $crate::output::write_line(format_args!($($arg)*)) }; +} + +// the same, for a caller that terminates its own entries +macro_rules! text { + ($($arg:tt)*) => { $crate::output::write_text(format_args!($($arg)*)) }; +} + +pub(crate) fn write_line(args: Arguments) { + finish(writeln!(io::stdout(), "{args}")); +} + +pub(crate) fn write_text(args: Arguments) { + finish(write!(io::stdout(), "{args}")); +} + +// a reader leaving early ends the pipe; println! would panic instead +fn finish(written: io::Result<()>) { + if written.is_err() { + std::process::exit(0); + } +} + +pub(crate) use {line, note, text, warning}; From b372472de64ac9eeb35cb64e9e7ce26e8395eee5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Jane=C5=BEi=C4=8D?= Date: Tue, 8 Sep 2026 14:36:38 +0000 Subject: [PATCH 7/9] feat!: drop django test, which only ever ran one of the runners --- src/cli/django.rs | 3 --- src/commands/django.rs | 4 ---- src/main.rs | 1 - 3 files changed, 8 deletions(-) diff --git a/src/cli/django.rs b/src/cli/django.rs index d9faa96..0a4c8ad 100644 --- a/src/cli/django.rs +++ b/src/cli/django.rs @@ -35,7 +35,4 @@ pub enum Django { /// Run Django's manage.py shell Shell, - - /// Run Django's manage.py test - Test, } diff --git a/src/commands/django.rs b/src/commands/django.rs index 997c3d4..0c241e2 100644 --- a/src/commands/django.rs +++ b/src/commands/django.rs @@ -82,10 +82,6 @@ pub fn shell(ctx: &Ctx) -> Result<()> { manage(ctx, &["shell".to_string()]) } -pub fn test(ctx: &Ctx) -> Result<()> { - manage(ctx, &["test".to_string()]) -} - fn service(ctx: &Ctx) -> Result { Project::resolve(ctx)?.django() } diff --git a/src/main.rs b/src/main.rs index 39a6e05..9a289c5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -53,7 +53,6 @@ fn run(ctx: &Ctx, command: cli::Commands) -> Result { cli::Django::Manage { rest } => commands::django::manage(ctx, &rest), cli::Django::Migrate { rest } => commands::django::migrate(ctx, &rest), cli::Django::Shell => commands::django::shell(ctx), - cli::Django::Test => commands::django::test(ctx), }?; Ok(done) From 23b7fde0eac9aa42fb5f4a2814ee85ee71fe0cfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Jane=C5=BEi=C4=8D?= Date: Tue, 8 Sep 2026 14:40:21 +0000 Subject: [PATCH 8/9] fix: separate the paths of a -z entry with a NUL, not an arrow --- src/commands/link/check.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/commands/link/check.rs b/src/commands/link/check.rs index b13f869..868b7d9 100644 --- a/src/commands/link/check.rs +++ b/src/commands/link/check.rs @@ -53,10 +53,19 @@ pub fn check( fn print_porcelain(exposed: &[Exposed], null: bool) { let end = if null { '\0' } else { '\n' }; + // a filename can hold an arrow but not a NUL, so -z separates the two paths + // with one the way `git status -z` does for a rename. the two character code + // stays where it is: it cannot be mistaken for part of a path + let between = if null { "\0" } else { " -> " }; for item in exposed { match &item.dest { - Some(dest) => text!("{} {} -> {}{end}", item.code(), item.name, dest.display()), + Some(dest) => text!( + "{} {}{between}{}{end}", + item.code(), + item.name, + dest.display() + ), None => text!("{} {}{end}", item.code(), item.name), } } From d7a54c70f9d5e99c3a6a1834bdc8d167aa4e6e14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Jane=C5=BEi=C4=8D?= Date: Tue, 8 Sep 2026 15:05:00 +0000 Subject: [PATCH 9/9] feat!: report findings with exit code 4, not the code for failure --- src/cli/link.rs | 2 +- src/commands/link/check.rs | 16 ++++------------ src/main.rs | 20 ++++++++++++-------- 3 files changed, 17 insertions(+), 21 deletions(-) diff --git a/src/cli/link.rs b/src/cli/link.rs index 9e55503..aa05edf 100644 --- a/src/cli/link.rs +++ b/src/cli/link.rs @@ -45,7 +45,7 @@ pub enum Link { #[arg(long)] porcelain: bool, - /// Exit with 1 when anything is outside the store, for scripts + /// Exit with 4 when anything is outside the store, for scripts #[arg(long)] exit_code: bool, diff --git a/src/commands/link/check.rs b/src/commands/link/check.rs index 868b7d9..d3c3791 100644 --- a/src/commands/link/check.rs +++ b/src/commands/link/check.rs @@ -1,6 +1,5 @@ use fs_err::{read_dir, read_link}; use std::path::{Path, PathBuf}; -use std::process::ExitCode; use anyhow::{Result, anyhow}; @@ -9,14 +8,14 @@ use crate::cmd::{Cmd, LsFiles}; use crate::ctx::Ctx; use crate::output::{line, text}; +// whether anything is outside the store, which main turns into an exit code pub fn check( ctx: &Ctx, paths: &[PathBuf], porcelain: bool, null: bool, - exit_code: bool, store: Option<&Path>, -) -> Result { +) -> Result { let repo = Repo::discover(ctx, store)?; let pathspecs = relative_pathspecs(&repo, paths)?; @@ -43,19 +42,12 @@ pub fn check( 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) + Ok(!exposed.is_empty()) } fn print_porcelain(exposed: &[Exposed], null: bool) { let end = if null { '\0' } else { '\n' }; - // a filename can hold an arrow but not a NUL, so -z separates the two paths - // with one the way `git status -z` does for a rename. the two character code - // stays where it is: it cannot be mistaken for part of a path + // a filename can hold an arrow but not a NUL, as `git status -z` also assumes let between = if null { "\0" } else { " -> " }; for item in exposed { diff --git a/src/main.rs b/src/main.rs index 9a289c5..1ab7e53 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,6 +14,9 @@ use clap::Parser; use crate::ctx::Ctx; use crate::output::note; +// 0 ran with nothing to report, 1 could not finish, 2 bad arguments, 4 found something +const FINDINGS: u8 = 4; + fn main() -> ExitCode { let args = cli::Ahab::parse(); @@ -87,14 +90,15 @@ fn run(ctx: &Ctx, command: cli::Commands) -> Result { null, exit_code, store, - } => commands::link::check( - ctx, - &paths, - porcelain, - null, - exit_code, - store.root.as_deref(), - ), + } => { + let exposed = + commands::link::check(ctx, &paths, porcelain, null, store.root.as_deref())?; + + match exit_code && exposed { + true => Ok(ExitCode::from(FINDINGS)), + false => Ok(done), + } + } }, cli::Commands::Status { store } => { commands::status::status(ctx, store.root.as_deref())?;