From 0aeecdcb5430a8ebd30b28368b7ba20829b36872 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Jane=C5=BEi=C4=8D?= Date: Mon, 7 Sep 2026 13:46:07 +0000 Subject: [PATCH 1/8] feat: remove stop_all --- src/scripts/docker.rs | 15 --------------- src/scripts/mod.rs | 1 - 2 files changed, 16 deletions(-) delete mode 100644 src/scripts/docker.rs diff --git a/src/scripts/docker.rs b/src/scripts/docker.rs deleted file mode 100644 index 6ff49e8..0000000 --- a/src/scripts/docker.rs +++ /dev/null @@ -1,15 +0,0 @@ -use anyhow::Result; - -use crate::command_builder::CommandBuilder; - -pub fn stop_all() -> Result<()> { - let running_containers = CommandBuilder::docker().args("ps -q").exec_get_stdout()?; - - if running_containers.is_empty() { - return Ok(()); - } - - CommandBuilder::docker() - .args(&format!("stop {running_containers}")) - .exec() -} diff --git a/src/scripts/mod.rs b/src/scripts/mod.rs index 4fbf51b..451a4ad 100644 --- a/src/scripts/mod.rs +++ b/src/scripts/mod.rs @@ -1,6 +1,5 @@ pub mod completions; pub mod django; -pub mod docker; pub mod docker_compose; pub mod link; pub mod postgres; From 25f7b7df9d8c63467df382eca69aba57e74a9fd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Jane=C5=BEi=C4=8D?= Date: Mon, 7 Sep 2026 13:46:58 +0000 Subject: [PATCH 2/8] feat: fail when a command fails --- src/command_builder.rs | 33 ++++++++++++++++++++++++++------- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/src/command_builder.rs b/src/command_builder.rs index b7a9d78..8e8832c 100644 --- a/src/command_builder.rs +++ b/src/command_builder.rs @@ -1,7 +1,7 @@ -use anyhow::{Context, Result}; +use anyhow::{Context, Result, bail}; use std::{ fmt::Display, - process::{Child, Command, Stdio}, + process::{Child, Command, ExitStatus, Stdio}, }; use crate::debug_eprintln; @@ -70,12 +70,18 @@ impl CommandBuilder { } pub fn exec_get_stdout(self) -> Result { - Ok(String::from_utf8(self.build()?.output()?.stdout)?) + let shown = self.to_string(); + let out = self.build()?.output()?; + + check(&shown, out.status)?; + Ok(String::from_utf8(out.stdout)?) } pub fn exec(self) -> Result<()> { - self.build()?.spawn()?.wait()?; - Ok(()) + let shown = self.to_string(); + let status = self.build()?.spawn()?.wait()?; + + check(&shown, status) } pub fn spawn(self) -> Result { @@ -83,7 +89,20 @@ impl CommandBuilder { } pub fn exec_redirect_stdout(self, stdio: Stdio) -> Result<()> { - self.build()?.stdout(stdio).spawn()?.wait()?; - Ok(()) + let shown = self.to_string(); + let status = self.build()?.stdout(stdio).spawn()?.wait()?; + + check(&shown, status) + } +} + +fn check(command: &str, status: ExitStatus) -> Result<()> { + if status.success() { + return Ok(()); + } + + match status.code() { + Some(code) => bail!("`{command}` exited with {code}"), + None => bail!("`{command}` was killed by a signal"), } } From 1bb205bb79e927bc845503ced8149363d7752fc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Jane=C5=BEi=C4=8D?= Date: Mon, 7 Sep 2026 13:49:24 +0000 Subject: [PATCH 3/8] feat: build commands from arguments instead of strings --- src/command_builder.rs | 10 +--- src/scripts/django.rs | 9 ++- src/scripts/docker_compose.rs | 14 +++-- src/scripts/postgres.rs | 105 +++++++++++++++++++--------------- 4 files changed, 75 insertions(+), 63 deletions(-) diff --git a/src/command_builder.rs b/src/command_builder.rs index 8e8832c..dddfa4e 100644 --- a/src/command_builder.rs +++ b/src/command_builder.rs @@ -14,12 +14,6 @@ impl From<&str> for Args { } } -impl From<&String> for Args { - fn from(value: &String) -> Self { - Self(Vec::from_iter(value.split_whitespace().map(String::from))) - } -} - impl From<&[String]> for Args { fn from(value: &[String]) -> Self { Self(value.to_vec()) @@ -46,8 +40,8 @@ impl CommandBuilder { Self::default().args("docker compose") } - pub fn arg(mut self, arg: impl Into) -> Self { - self.args.push(arg.into()); + pub fn arg(mut self, arg: impl AsRef) -> Self { + self.args.push(arg.as_ref().to_string()); self } diff --git a/src/scripts/django.rs b/src/scripts/django.rs index 47f339f..c302043 100644 --- a/src/scripts/django.rs +++ b/src/scripts/django.rs @@ -4,7 +4,6 @@ use std::path::{Path, PathBuf}; use anyhow::{Result, anyhow}; -use crate::command_builder::CommandBuilder; use crate::compose::Compose; use crate::scripts::docker_compose; use crate::{create_file, safe_create_file}; @@ -65,10 +64,10 @@ pub fn run(rest: &[String]) -> Result<()> { } pub fn manage(rest: &[String]) -> Result<()> { - let container = Compose::resolve()?.django()?; - let joined = rest.join(" "); - let command = format!("run --rm {container} python manage.py {joined}"); - CommandBuilder::docker_compose().args(&command).exec() + let mut args = vec!["python".to_string(), "manage.py".to_string()]; + args.extend_from_slice(rest); + + run(&args) } // shortcuts diff --git a/src/scripts/docker_compose.rs b/src/scripts/docker_compose.rs index d2212a6..763f53b 100644 --- a/src/scripts/docker_compose.rs +++ b/src/scripts/docker_compose.rs @@ -13,7 +13,7 @@ pub fn down() -> Result<()> { pub fn run(service: &str, rest: &[String]) -> Result<()> { CommandBuilder::docker_compose() .args("run --rm") - .args(service) + .arg(service) .args(rest) .exec() } @@ -21,7 +21,7 @@ pub fn run(service: &str, rest: &[String]) -> Result<()> { pub fn exec(service: &str, rest: &[String]) -> Result<()> { CommandBuilder::docker_compose() .args("exec") - .args(service) + .arg(service) .args(rest) .exec() } @@ -30,9 +30,13 @@ pub fn ps() -> Result<()> { CommandBuilder::docker_compose().args("ps").exec() } -pub fn start(containers: Option<&str>) -> Result<()> { - let args = format!("start {}", containers.unwrap_or("")); - CommandBuilder::docker_compose().args(&args).exec() +pub fn start(service: Option<&str>) -> Result<()> { + let mut command = CommandBuilder::docker_compose().args("start"); + if let Some(service) = service { + command = command.arg(service); + } + + command.exec() } pub fn stop() -> Result<()> { diff --git a/src/scripts/postgres.rs b/src/scripts/postgres.rs index b0fcbcd..6c66e73 100644 --- a/src/scripts/postgres.rs +++ b/src/scripts/postgres.rs @@ -40,7 +40,7 @@ impl Database { let container = CommandBuilder::docker_compose() .args("ps -q") - .args(&service) + .arg(&service) .exec_get_stdout()? .trim() .to_string(); @@ -173,7 +173,7 @@ fn piped(db: &Database, script: &str, input: &Path) -> Result Result Result<()> { debug_eprintln!("waiting until pg_isready"); while !CommandBuilder::docker() - .args(&format!( - "exec {} pg_isready -U {} -d {}", - db.container, db.user, db.name - )) + .args("exec") + .arg(&db.container) + .args("pg_isready -U") + .arg(&db.user) + .args("-d") + .arg(&db.name) .build()? .stdout(Stdio::null()) .spawn()? @@ -201,9 +203,13 @@ fn wait_until_ready(db: &Database) -> Result<()> { Ok(()) } -fn run_when_ready(db: &Database, command: &str) -> Result<()> { +fn when_ready(db: &Database, command: CommandBuilder) -> Result<()> { wait_until_ready(db)?; - CommandBuilder::docker().args(command).exec() + command.exec() +} + +fn in_container(db: &Database) -> CommandBuilder { + CommandBuilder::docker().args("exec").arg(&db.container) } pub fn import(file: &Path) -> Result<()> { @@ -220,9 +226,12 @@ pub fn import(file: &Path) -> Result<()> { // a directory cannot be streamed, so it is the one shape that gets copied in if matches!(dump, Dump::Directory) { - run_when_ready( + when_ready( &db, - &format!("cp -L {} {}:{remote}", file.display(), db.container), + CommandBuilder::docker() + .args("cp -L") + .arg(file.to_string_lossy()) + .arg(format!("{}:{remote}", db.container)), )?; } @@ -264,20 +273,25 @@ pub fn import(file: &Path) -> Result<()> { let tool = kind.tool(); eprintln!("restoring database with {tool}"); - run_when_ready( + + when_ready( &db, - &format!("exec {} dropdb -U {} {}", db.container, db.user, db.name), + in_container(&db) + .args("dropdb -U") + .arg(&db.user) + .arg(&db.name), )?; // a cluster dump creates the database itself, and would trip over one that // is already there if kind != Kind::Cluster { - run_when_ready( + when_ready( &db, - &format!( - "exec {} createdb -U {} -E utf8 -T template0 {}", - db.container, db.user, db.name - ), + in_container(&db) + .args("createdb -U") + .arg(&db.user) + .args("-E utf8 -T template0") + .arg(&db.name), )?; } @@ -289,11 +303,11 @@ pub fn import(file: &Path) -> Result<()> { (_, restore) => { let status = match restore { Some(script) => piped(&db, script, file)?.spawn()?.wait()?, - None => CommandBuilder::docker() - .args(&format!( - "exec {} pg_restore -U {} --dbname={} {remote}", - db.container, db.user, db.name - )) + None => in_container(&db) + .args("pg_restore -U") + .arg(&db.user) + .arg(format!("--dbname={}", db.name)) + .arg(&remote) .build()? .spawn()? .wait()?, @@ -306,9 +320,7 @@ pub fn import(file: &Path) -> Result<()> { } if matches!(dump, Dump::Directory) { - let _ = CommandBuilder::docker() - .args(&format!("exec {} rm -rf {remote}", db.container)) - .exec(); + let _ = in_container(&db).args("rm -rf").arg(&remote).exec(); } eprintln!("restarting containers"); @@ -332,19 +344,25 @@ pub fn dump(file: &PathBuf, format: Format, gzip: bool) -> Result<()> { eprintln!("dumping to local file {}", file.to_string_lossy()); let stdout = Stdio::from(File::create(file)?); - let dumping = dump_command(&db, format); - if gzip { + // the whole pipeline has to arrive as one shell argument CommandBuilder::docker() .args("exec") - .args(&db.container) + .arg(&db.container) .args("sh -c") - .arg(format!("{dumping} | gzip")) + .arg(format!("{} | gzip", dump_command(&db, format))) .exec_redirect_stdout(stdout)?; } else { - CommandBuilder::docker() - .args(&format!("exec {} {dumping}", db.container)) - .exec_redirect_stdout(stdout)?; + let dumping = match format.flag() { + Some(flag) => in_container(&db) + .args("pg_dump -U") + .arg(&db.user) + .arg(format!("--format={flag}")) + .arg(&db.name), + None => in_container(&db).args("pg_dumpall -U").arg(&db.user), + }; + + dumping.exec_redirect_stdout(stdout)?; } Ok(()) @@ -371,24 +389,21 @@ fn dump_directory(db: &Database, target: &Path) -> Result<()> { eprintln!("dumping to local directory {}", target.display()); let remote = remote_dump(); - CommandBuilder::docker() - .args(&format!( - "exec {} pg_dump -U {} --format=d -f {remote} {}", - db.container, db.user, db.name - )) + in_container(db) + .args("pg_dump -U") + .arg(&db.user) + .args("--format=d -f") + .arg(&remote) + .arg(&db.name) .exec()?; let copied = CommandBuilder::docker() - .args(&format!( - "cp {}:{remote} {}", - db.container, - target.display() - )) + .args("cp") + .arg(format!("{}:{remote}", db.container)) + .arg(target.to_string_lossy()) .exec(); - let _ = CommandBuilder::docker() - .args(&format!("exec {} rm -rf {remote}", db.container)) - .exec(); + let _ = in_container(db).args("rm -rf").arg(&remote).exec(); copied } From a489d77b0afc797d46f1edd29282a6316f7cbb5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Jane=C5=BEi=C4=8D?= Date: Mon, 7 Sep 2026 13:52:15 +0000 Subject: [PATCH 4/8] fix: write the dump to a temporary file first --- src/scripts/postgres.rs | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/src/scripts/postgres.rs b/src/scripts/postgres.rs index 6c66e73..50ba0d9 100644 --- a/src/scripts/postgres.rs +++ b/src/scripts/postgres.rs @@ -1,6 +1,6 @@ use anyhow::{Context, Result, anyhow, bail}; use std::{ - fs::File, + fs::{self, File}, io::{self, Read, Write}, path::{Path, PathBuf}, process::Stdio, @@ -157,6 +157,13 @@ fn restore_cluster(db: &Database, script: &str, file: &Path) -> Result<()> { Ok(()) } +fn suffixed(path: &Path, suffix: &str) -> PathBuf { + let mut out = path.as_os_str().to_owned(); + out.push(suffix); + + PathBuf::from(out) +} + fn read_header(path: &Path) -> Result> { let mut file = File::open(path).with_context(|| format!("opening {}", path.display()))?; let mut header = vec![0; HEADER_LEN]; @@ -343,15 +350,21 @@ pub fn dump(file: &PathBuf, format: Format, gzip: bool) -> Result<()> { eprintln!("dumping to local file {}", file.to_string_lossy()); - let stdout = Stdio::from(File::create(file)?); - if gzip { + // written beside the target and renamed once the dump succeeds, so a failure + // cannot destroy the dump that is already there + let partial = suffixed(file, ".partial"); + let stdout = Stdio::from( + File::create(&partial).with_context(|| format!("creating {}", partial.display()))?, + ); + + let dumped = if gzip { // the whole pipeline has to arrive as one shell argument CommandBuilder::docker() .args("exec") .arg(&db.container) .args("sh -c") .arg(format!("{} | gzip", dump_command(&db, format))) - .exec_redirect_stdout(stdout)?; + .exec_redirect_stdout(stdout) } else { let dumping = match format.flag() { Some(flag) => in_container(&db) @@ -362,9 +375,17 @@ pub fn dump(file: &PathBuf, format: Format, gzip: bool) -> Result<()> { None => in_container(&db).args("pg_dumpall -U").arg(&db.user), }; - dumping.exec_redirect_stdout(stdout)?; + dumping.exec_redirect_stdout(stdout) + }; + + if let Err(e) = dumped { + let _ = fs::remove_file(&partial); + return Err(e); } + fs::rename(&partial, file) + .with_context(|| format!("renaming {} to {}", partial.display(), file.display()))?; + Ok(()) } From ab93e7ef4f460f082e1e1e19576f27c987fc9d56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Jane=C5=BEi=C4=8D?= Date: Mon, 7 Sep 2026 14:09:55 +0000 Subject: [PATCH 5/8] fix: give the readiness wait a timeout --- src/scripts/postgres.rs | 50 +++++++++++++++++++++++++++-------------- 1 file changed, 33 insertions(+), 17 deletions(-) diff --git a/src/scripts/postgres.rs b/src/scripts/postgres.rs index 50ba0d9..8d94631 100644 --- a/src/scripts/postgres.rs +++ b/src/scripts/postgres.rs @@ -5,7 +5,7 @@ use std::{ path::{Path, PathBuf}, process::Stdio, thread, - time::Duration, + time::{Duration, Instant}, }; use super::docker_compose; @@ -16,6 +16,9 @@ const CUSTOM_MAGIC: &[u8] = b"PGDMP"; const TAR_MAGIC: &[u8] = b"toc.dat"; const GZIP_MAGIC: &[u8] = b"\x1f\x8b"; const CLUSTER_MARKER: &str = "PostgreSQL database cluster dump"; +const READY_TIMEOUT: Duration = Duration::from_secs(60); +const POLL_INTERVAL: Duration = Duration::from_secs(1); + // wide enough for the cluster marker, which sits a few bytes into the file const HEADER_LEN: usize = 512; @@ -191,23 +194,36 @@ fn piped(db: &Database, script: &str, input: &Path) -> Result Result<()> { debug_eprintln!("waiting until pg_isready"); - while !CommandBuilder::docker() - .args("exec") - .arg(&db.container) - .args("pg_isready -U") - .arg(&db.user) - .args("-d") - .arg(&db.name) - .build()? - .stdout(Stdio::null()) - .spawn()? - .wait()? - .success() - { - thread::sleep(Duration::from_secs(1)); - } + let deadline = Instant::now() + READY_TIMEOUT; - Ok(()) + loop { + let ready = CommandBuilder::docker() + .args("exec") + .arg(&db.container) + .args("pg_isready -U") + .arg(&db.user) + .args("-d") + .arg(&db.name) + .build()? + .stdout(Stdio::null()) + .spawn()? + .wait()? + .success(); + + if ready { + return Ok(()); + } + + if Instant::now() >= deadline { + bail!( + "{} did not accept connections within {} seconds", + db.service, + READY_TIMEOUT.as_secs() + ); + } + + thread::sleep(POLL_INTERVAL); + } } fn when_ready(db: &Database, command: CommandBuilder) -> Result<()> { From b2a0c0825666348e6cf5ee5d717c1fb1233dd90b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Jane=C5=BEi=C4=8D?= Date: Mon, 7 Sep 2026 14:12:53 +0000 Subject: [PATCH 6/8] feat: add verbose and dry run --- README.md | 10 ++++++--- src/cli/ahab.rs | 9 +++++++- src/command_builder.rs | 38 ++++++++++++++++++++++++++++---- src/main.rs | 7 +++++- src/scripts/postgres.rs | 48 ++++++++++++++++++++++++----------------- 5 files changed, 83 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 51d31af..826fc5a 100644 --- a/README.md +++ b/README.md @@ -10,10 +10,14 @@ You will need rust installed. Clone repo and run: cargo install --path . ``` -To print the underlying docker commands as they run, build with debug -assertions: +## seeing what it runs + +`-v` prints each docker command as it runs, and `--dry-run` prints the ones it +would run without running them. Both work before or after the subcommand. + ```bash -cargo install --path . --debug +ahab -v django test +ahab --dry-run postgres import ./dump ``` ## shell completion diff --git a/src/cli/ahab.rs b/src/cli/ahab.rs index 9596252..9d62d14 100644 --- a/src/cli/ahab.rs +++ b/src/cli/ahab.rs @@ -8,8 +8,15 @@ use clap_complete::Shell; pub struct Ahab { #[command(subcommand)] pub command: Commands, -} + /// Print each docker command as it runs + #[arg(short, long, global = true)] + pub verbose: bool, + + /// Print the docker commands that would run, without running them + #[arg(long, global = true)] + pub dry_run: bool, +} #[derive(Debug, Subcommand)] pub enum Commands { /// Django related subcommands diff --git a/src/command_builder.rs b/src/command_builder.rs index dddfa4e..3eaf0c9 100644 --- a/src/command_builder.rs +++ b/src/command_builder.rs @@ -2,10 +2,9 @@ use anyhow::{Context, Result, bail}; use std::{ fmt::Display, process::{Child, Command, ExitStatus, Stdio}, + sync::OnceLock, }; -use crate::debug_eprintln; - pub struct Args(Vec); impl From<&str> for Args { @@ -54,8 +53,9 @@ impl CommandBuilder { } pub fn build(self) -> Result { - debug_eprintln!("running `{self}`"); - + if options().verbose { + eprintln!("running `{self}`"); + } let (first, rest) = self.args.split_first().context("empty args")?; let mut command = Command::new(first); command.args(rest); @@ -72,6 +72,11 @@ impl CommandBuilder { } pub fn exec(self) -> Result<()> { + if options().dry_run { + eprintln!("would run `{self}`"); + return Ok(()); + } + let shown = self.to_string(); let status = self.build()?.spawn()?.wait()?; @@ -83,6 +88,11 @@ impl CommandBuilder { } pub fn exec_redirect_stdout(self, stdio: Stdio) -> Result<()> { + if options().dry_run { + eprintln!("would run `{self}`"); + return Ok(()); + } + let shown = self.to_string(); let status = self.build()?.stdout(stdio).spawn()?.wait()?; @@ -100,3 +110,23 @@ fn check(command: &str, status: ExitStatus) -> Result<()> { None => bail!("`{command}` was killed by a signal"), } } + +#[derive(Default, Clone, Copy)] +pub struct Options { + pub verbose: bool, + pub dry_run: bool, +} + +static OPTIONS: OnceLock = OnceLock::new(); + +pub fn set_options(options: Options) { + let _ = OPTIONS.set(options); +} + +fn options() -> Options { + OPTIONS.get().copied().unwrap_or_default() +} + +pub fn is_dry_run() -> bool { + options().dry_run +} diff --git a/src/main.rs b/src/main.rs index f70e2e4..db7d96f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,4 @@ -use ahab::{cli, scripts}; +use ahab::{cli, command_builder, scripts}; use anyhow::Result; use clap::Parser; @@ -6,6 +6,11 @@ use clap::Parser; fn main() -> Result<()> { let args = cli::Ahab::parse(); + command_builder::set_options(command_builder::Options { + verbose: args.verbose, + dry_run: args.dry_run, + }); + match args.command { cli::Commands::Django { command } => match command { cli::Django::Bash => scripts::django::bash(), diff --git a/src/scripts/postgres.rs b/src/scripts/postgres.rs index 8d94631..4a0dda2 100644 --- a/src/scripts/postgres.rs +++ b/src/scripts/postgres.rs @@ -10,7 +10,8 @@ use std::{ use super::docker_compose; use crate::cli::Format; -use crate::{command_builder::CommandBuilder, compose::Compose, debug_eprintln}; +use crate::command_builder; +use crate::{command_builder::CommandBuilder, compose::Compose}; const CUSTOM_MAGIC: &[u8] = b"PGDMP"; const TAR_MAGIC: &[u8] = b"toc.dat"; @@ -193,7 +194,10 @@ fn piped(db: &Database, script: &str, input: &Path) -> Result Result<()> { - debug_eprintln!("waiting until pg_isready"); + if command_builder::is_dry_run() { + return Ok(()); + } + let deadline = Instant::now() + READY_TIMEOUT; loop { @@ -319,25 +323,29 @@ pub fn import(file: &Path) -> Result<()> { } wait_until_ready(&db)?; - match (kind, restore.as_deref()) { - // psql's output is read rather than streamed here, to keep the expected - // role errors out of the way - (Kind::Cluster, Some(script)) => restore_cluster(&db, script, file)?, - (_, restore) => { - let status = match restore { - Some(script) => piped(&db, script, file)?.spawn()?.wait()?, - None => in_container(&db) - .args("pg_restore -U") - .arg(&db.user) - .arg(format!("--dbname={}", db.name)) - .arg(&remote) - .build()? - .spawn()? - .wait()?, - }; + if command_builder::is_dry_run() { + eprintln!("would restore with {tool}"); + } else { + match (kind, restore.as_deref()) { + // psql's output is read rather than streamed here, to keep the expected + // role errors out of the way + (Kind::Cluster, Some(script)) => restore_cluster(&db, script, file)?, + (_, restore) => { + let status = match restore { + Some(script) => piped(&db, script, file)?.spawn()?.wait()?, + None => in_container(&db) + .args("pg_restore -U") + .arg(&db.user) + .arg(format!("--dbname={}", db.name)) + .arg(&remote) + .build()? + .spawn()? + .wait()?, + }; - if !status.success() { - bail!("{tool} failed, the database is left empty"); + if !status.success() { + bail!("{tool} failed, the database is left empty"); + } } } } From 15bcaafa854b87346bf22bfae1e2461db5ad1e7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Jane=C5=BEi=C4=8D?= Date: Mon, 7 Sep 2026 14:13:56 +0000 Subject: [PATCH 7/8] feat: add exit-code to link check --- src/cli/link.rs | 4 ++++ src/main.rs | 3 ++- src/scripts/link.rs | 10 +++++++++- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/cli/link.rs b/src/cli/link.rs index d777a08..58d29de 100644 --- a/src/cli/link.rs +++ b/src/cli/link.rs @@ -24,6 +24,10 @@ pub enum Link { #[arg(long)] porcelain: bool, + /// Exit with 1 when anything is outside the store, for scripts + #[arg(long)] + exit_code: bool, + /// Terminate porcelain entries with NUL #[arg(short = 'z')] null: bool, diff --git a/src/main.rs b/src/main.rs index db7d96f..3a020eb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -34,7 +34,8 @@ fn main() -> Result<()> { paths, porcelain, null, - } => scripts::link::check(&paths, porcelain, null), + exit_code, + } => scripts::link::check(&paths, porcelain, null, exit_code), }, cli::Commands::Completions { shell } => scripts::completions::completions(shell), } diff --git a/src/scripts/link.rs b/src/scripts/link.rs index 92f2d79..83e2619 100644 --- a/src/scripts/link.rs +++ b/src/scripts/link.rs @@ -2,8 +2,10 @@ use std::cell::Cell; use std::env; use std::ffi::OsString; use std::fs; +use std::io::{self, Write}; use std::os::unix::fs::symlink; use std::path::{Path, PathBuf}; +use std::process; use std::process::Command; use anyhow::{Context, Result, anyhow}; @@ -62,7 +64,7 @@ fn warn(msg: impl std::fmt::Display) { } // list untracked paths not in the store, i.e. what a sandbox can still read -pub fn check(paths: &[PathBuf], porcelain: bool, null: bool) -> Result<()> { +pub fn check(paths: &[PathBuf], porcelain: bool, null: bool, exit_code: bool) -> Result<()> { let repo = Repo::discover()?; let pathspecs = relative_pathspecs(&repo, paths)?; @@ -89,6 +91,12 @@ pub fn check(paths: &[PathBuf], porcelain: bool, null: bool) -> Result<()> { print_listing(&repo, &exposed); } + // git's --exit-code convention: nothing to report is 0, anything is 1 + if exit_code && !exposed.is_empty() { + io::stdout().flush().context("writing the listing")?; + process::exit(1); + } + Ok(()) } From 43d55b10035ffd334866f5325384585a3bd3a06a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Jane=C5=BEi=C4=8D?= Date: Mon, 7 Sep 2026 14:14:07 +0000 Subject: [PATCH 8/8] style: normalise command help --- src/cli/ahab.rs | 2 +- src/cli/django.rs | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/cli/ahab.rs b/src/cli/ahab.rs index 9d62d14..095aa17 100644 --- a/src/cli/ahab.rs +++ b/src/cli/ahab.rs @@ -2,7 +2,7 @@ use super::{Django, Link, Postgres}; use clap::{Parser, Subcommand}; use clap_complete::Shell; -/// A program for interacting with various dockerized applications. +/// A program for interacting with various dockerized applications #[derive(Parser, Debug)] #[command(author, version, about, long_about=None)] pub struct Ahab { diff --git a/src/cli/django.rs b/src/cli/django.rs index 347eb45..d9faa96 100644 --- a/src/cli/django.rs +++ b/src/cli/django.rs @@ -9,19 +9,19 @@ pub enum Django { /// Start a bash session in a fresh django container Bash, - /// Prepare empty management command 'command' in app 'app'. + /// Prepare empty management command 'command' in app 'app' MakeCommand { app: PathBuf, name: String }, - /// Run Django's manage.py makemigrations. + /// Run Django's manage.py makemigrations Makemigrations, - /// Pass arguments to Django's manage.py. + /// Pass arguments to Django's manage.py Manage { #[arg(trailing_var_arg = true, allow_hyphen_values = true)] rest: Vec, }, - /// Run Django's manage.py migrate. + /// Run Django's manage.py migrate Migrate { #[arg(trailing_var_arg = true, allow_hyphen_values = true)] rest: Vec, @@ -33,9 +33,9 @@ pub enum Django { rest: Vec, }, - /// Run Django's manage.py shell. + /// Run Django's manage.py shell Shell, - /// Run Django's manage.py test. + /// Run Django's manage.py test Test, }