From 056da8b9b26a1f9b96df0400556351e451a8d197 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Jane=C5=BEi=C4=8D?= Date: Tue, 8 Sep 2026 11:26:33 +0000 Subject: [PATCH 1/6] refactor: build one binary instead of a library and a binary --- src/cli/mod.rs | 2 +- src/command_builder.rs | 6 +----- src/lib.rs | 29 ----------------------------- src/main.rs | 5 ++++- src/scripts/django.rs | 17 +++++++++++++++-- src/scripts/docker_compose.rs | 31 ------------------------------- 6 files changed, 21 insertions(+), 69 deletions(-) delete mode 100644 src/lib.rs diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 15dc00a..0e96624 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -5,5 +5,5 @@ mod postgres; pub use ahab::{Ahab, Commands, reject_unsupported_dry_run}; pub use django::Django; -pub use link::{Link, Store}; +pub use link::Link; pub use postgres::{Format, Postgres}; diff --git a/src/command_builder.rs b/src/command_builder.rs index 96fe2c4..245a6c2 100644 --- a/src/command_builder.rs +++ b/src/command_builder.rs @@ -2,7 +2,7 @@ use anyhow::{Context, Result, bail}; use std::{ fmt::Display, os::unix::process::CommandExt, - process::{Child, Command, ExitStatus, Stdio}, + process::{Command, ExitStatus, Stdio}, sync::OnceLock, }; @@ -97,10 +97,6 @@ impl CommandBuilder { Err(error).with_context(|| format!("running `{shown}`")) } - pub fn spawn(self) -> Result { - Ok(self.build()?.spawn()?) - } - pub fn exec_redirect_stdout(self, stdio: Stdio) -> Result<()> { if options().dry_run { eprintln!("would run `{self}`"); diff --git a/src/lib.rs b/src/lib.rs deleted file mode 100644 index 041d969..0000000 --- a/src/lib.rs +++ /dev/null @@ -1,29 +0,0 @@ -use std::{ - fs::{File, OpenOptions}, - path::PathBuf, -}; - -pub mod cli; -pub mod command_builder; -pub mod compose; -pub mod scripts; - -// NOTE: stolen from https://docs.rs/debug_print/latest/debug_print/ -#[macro_export] -macro_rules! debug_eprintln { - ($($arg:tt)*) => (#[cfg(debug_assertions)] eprintln!($($arg)*)); -} - -fn safe_create_file(path: PathBuf) -> Result { - OpenOptions::new().write(true).create_new(true).open(path) -} - -// truncate(false) keeps an existing file's contents, which is what callers creating -// an empty __init__.py want -fn create_file(path: PathBuf) -> Result { - OpenOptions::new() - .write(true) - .create(true) - .truncate(false) - .open(path) -} diff --git a/src/main.rs b/src/main.rs index e4945d3..26344b0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,9 @@ use std::process::ExitCode; -use ahab::{cli, command_builder, scripts}; +mod cli; +mod command_builder; +mod compose; +mod scripts; use anyhow::Result; use clap::Parser; diff --git a/src/scripts/django.rs b/src/scripts/django.rs index c302043..dc01d71 100644 --- a/src/scripts/django.rs +++ b/src/scripts/django.rs @@ -1,4 +1,4 @@ -use std::fs::create_dir; +use std::fs::{File, OpenOptions, create_dir}; use std::io::Write; use std::path::{Path, PathBuf}; @@ -6,7 +6,6 @@ use anyhow::{Result, anyhow}; use crate::compose::Compose; use crate::scripts::docker_compose; -use crate::{create_file, safe_create_file}; const DEBUG_TEMPLATE: &str = r#"from django.core.management.base import BaseCommand @@ -89,3 +88,17 @@ pub fn shell() -> Result<()> { pub fn test() -> Result<()> { manage(&["test".to_string()]) } + +fn safe_create_file(path: PathBuf) -> Result { + OpenOptions::new().write(true).create_new(true).open(path) +} + +// truncate(false) keeps an existing file's contents, which is what callers creating +// an empty __init__.py want +fn create_file(path: PathBuf) -> Result { + OpenOptions::new() + .write(true) + .create(true) + .truncate(false) + .open(path) +} diff --git a/src/scripts/docker_compose.rs b/src/scripts/docker_compose.rs index c80a1d8..73d84f2 100644 --- a/src/scripts/docker_compose.rs +++ b/src/scripts/docker_compose.rs @@ -2,14 +2,6 @@ use anyhow::Result; use crate::command_builder::CommandBuilder; -pub fn build() -> Result<()> { - CommandBuilder::docker_compose().args("build").exec() -} - -pub fn down() -> Result<()> { - CommandBuilder::docker_compose().args("down").exec() -} - pub fn run(service: &str, rest: &[String]) -> Result<()> { CommandBuilder::docker_compose() .args("run --rm") @@ -18,18 +10,6 @@ pub fn run(service: &str, rest: &[String]) -> Result<()> { .exec_replace() } -pub fn exec(service: &str, rest: &[String]) -> Result<()> { - CommandBuilder::docker_compose() - .args("exec") - .arg(service) - .args(rest) - .exec() -} - -pub fn ps() -> Result<()> { - CommandBuilder::docker_compose().args("ps").exec() -} - pub fn start(service: Option<&str>) -> Result<()> { let mut command = CommandBuilder::docker_compose().args("start"); if let Some(service) = service { @@ -46,14 +26,3 @@ pub fn stop() -> Result<()> { pub fn up() -> Result<()> { CommandBuilder::docker_compose().args("up -d").exec() } - -pub fn rebuild() -> Result<()> { - stop()?; - build()?; - up() -} - -pub fn restart() -> Result<()> { - stop()?; - up() -} From 3d06f7dcb0ea3df5f9de62c690e265c772428ed5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Jane=C5=BEi=C4=8D?= Date: Tue, 8 Sep 2026 11:29:33 +0000 Subject: [PATCH 2/6] refactor: pass invocation options down instead of reading a global --- src/command_builder.rs | 49 +++++++-------------- src/compose.rs | 5 ++- src/ctx.rs | 5 +++ src/main.rs | 31 +++++++------ src/scripts/django.rs | 31 ++++++------- src/scripts/docker_compose.rs | 17 +++---- src/scripts/postgres.rs | 83 +++++++++++++++++++---------------- 7 files changed, 109 insertions(+), 112 deletions(-) create mode 100644 src/ctx.rs diff --git a/src/command_builder.rs b/src/command_builder.rs index 245a6c2..3f63082 100644 --- a/src/command_builder.rs +++ b/src/command_builder.rs @@ -3,9 +3,10 @@ use std::{ fmt::Display, os::unix::process::CommandExt, process::{Command, ExitStatus, Stdio}, - sync::OnceLock, }; +use crate::ctx::Ctx; + pub struct Args(Vec); impl From<&str> for Args { @@ -53,8 +54,8 @@ impl CommandBuilder { self } - pub fn build(self) -> Result { - if options().verbose { + pub fn build(self, ctx: &Ctx) -> Result { + if ctx.verbose { eprintln!("running `{self}`"); } let (first, rest) = self.args.split_first().context("empty args")?; @@ -64,47 +65,47 @@ impl CommandBuilder { Ok(command) } - pub fn exec_get_stdout(self) -> Result { + pub fn exec_get_stdout(self, ctx: &Ctx) -> Result { let shown = self.to_string(); - let out = self.build()?.output()?; + let out = self.build(ctx)?.output()?; check(&shown, out.status)?; Ok(String::from_utf8(out.stdout)?) } - pub fn exec(self) -> Result<()> { - if options().dry_run { + pub fn exec(self, ctx: &Ctx) -> Result<()> { + if ctx.dry_run { eprintln!("would run `{self}`"); return Ok(()); } let shown = self.to_string(); - let status = self.build()?.spawn()?.wait()?; + let status = self.build(ctx)?.spawn()?.wait()?; check(&shown, status) } // replaces this process, so the command's exit code and signals become ours - pub fn exec_replace(self) -> Result<()> { - if options().dry_run { + pub fn exec_replace(self, ctx: &Ctx) -> Result<()> { + if ctx.dry_run { eprintln!("would run `{self}`"); return Ok(()); } let shown = self.to_string(); - let error = self.build()?.exec(); + let error = self.build(ctx)?.exec(); Err(error).with_context(|| format!("running `{shown}`")) } - pub fn exec_redirect_stdout(self, stdio: Stdio) -> Result<()> { - if options().dry_run { + pub fn exec_redirect_stdout(self, ctx: &Ctx, stdio: Stdio) -> Result<()> { + if ctx.dry_run { eprintln!("would run `{self}`"); return Ok(()); } let shown = self.to_string(); - let status = self.build()?.stdout(stdio).spawn()?.wait()?; + let status = self.build(ctx)?.stdout(stdio).spawn()?.wait()?; check(&shown, status) } @@ -120,23 +121,3 @@ 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/compose.rs b/src/compose.rs index d470913..c604f81 100644 --- a/src/compose.rs +++ b/src/compose.rs @@ -2,6 +2,7 @@ use anyhow::{Context, Result, anyhow, bail}; use serde_json::Value; use crate::command_builder::CommandBuilder; +use crate::ctx::Ctx; const POSTGRES_IMAGES: [&str; 4] = ["postg", "timescale", "pgvector", "citus"]; const DJANGO_SETTINGS_MODULE: &str = "DJANGO_SETTINGS_MODULE"; @@ -11,10 +12,10 @@ pub struct Compose { } impl Compose { - pub fn resolve() -> Result { + pub fn resolve(ctx: &Ctx) -> Result { let out = CommandBuilder::docker_compose() .args("config --format json") - .build()? + .build(ctx)? .output() .context("running docker compose config")?; diff --git a/src/ctx.rs b/src/ctx.rs new file mode 100644 index 0000000..38e7303 --- /dev/null +++ b/src/ctx.rs @@ -0,0 +1,5 @@ +// how ahab was invoked, handed to everything that runs commands or writes files +pub struct Ctx { + pub verbose: bool, + pub dry_run: bool, +} diff --git a/src/main.rs b/src/main.rs index 26344b0..7546671 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,24 +3,27 @@ use std::process::ExitCode; mod cli; mod command_builder; mod compose; +mod ctx; mod scripts; use anyhow::Result; use clap::Parser; +use crate::ctx::Ctx; + fn main() -> ExitCode { let args = cli::Ahab::parse(); - command_builder::set_options(command_builder::Options { + let ctx = Ctx { verbose: args.verbose, dry_run: args.dry_run, - }); + }; - if args.dry_run { + if ctx.dry_run { cli::reject_unsupported_dry_run(&args.command); } - match run(args.command) { + match run(&ctx, args.command) { Ok(code) => code, Err(e) => { eprintln!("Error: {e:#}"); @@ -29,31 +32,31 @@ fn main() -> ExitCode { } } -fn run(command: cli::Commands) -> Result { +fn run(ctx: &Ctx, command: cli::Commands) -> Result { let done = ExitCode::SUCCESS; match command { cli::Commands::Django { command } => { match command { - cli::Django::Bash => scripts::django::bash(), - cli::Django::Run { rest } => scripts::django::run(&rest), + cli::Django::Bash => scripts::django::bash(ctx), + cli::Django::Run { rest } => scripts::django::run(ctx, &rest), cli::Django::MakeCommand { app, name } => { scripts::django::make_command(&app, &name) } - cli::Django::Makemigrations => scripts::django::makemigrations(), - cli::Django::Manage { rest } => scripts::django::manage(&rest), - cli::Django::Migrate { rest } => scripts::django::migrate(&rest), - cli::Django::Shell => scripts::django::shell(), - cli::Django::Test => scripts::django::test(), + cli::Django::Makemigrations => scripts::django::makemigrations(ctx), + cli::Django::Manage { rest } => scripts::django::manage(ctx, &rest), + cli::Django::Migrate { rest } => scripts::django::migrate(ctx, &rest), + cli::Django::Shell => scripts::django::shell(ctx), + cli::Django::Test => scripts::django::test(ctx), }?; Ok(done) } cli::Commands::Postgres { command } => { match command { - cli::Postgres::Import { path } => scripts::postgres::import(&path), + cli::Postgres::Import { path } => scripts::postgres::import(ctx, &path), cli::Postgres::Dump { path, format, gzip } => { - scripts::postgres::dump(&path, format, gzip) + scripts::postgres::dump(ctx, &path, format, gzip) } }?; diff --git a/src/scripts/django.rs b/src/scripts/django.rs index dc01d71..915c9a5 100644 --- a/src/scripts/django.rs +++ b/src/scripts/django.rs @@ -5,6 +5,7 @@ use std::path::{Path, PathBuf}; use anyhow::{Result, anyhow}; use crate::compose::Compose; +use crate::ctx::Ctx; use crate::scripts::docker_compose; const DEBUG_TEMPLATE: &str = r#"from django.core.management.base import BaseCommand @@ -53,40 +54,40 @@ pub fn make_command(app: &PathBuf, name: &str) -> Result<()> { Ok(()) } -pub fn bash() -> Result<()> { - run(&["bash".to_string()]) +pub fn bash(ctx: &Ctx) -> Result<()> { + run(ctx, &["bash".to_string()]) } -pub fn run(rest: &[String]) -> Result<()> { - let service = Compose::resolve()?.django()?; - docker_compose::run(&service, rest) +pub fn run(ctx: &Ctx, rest: &[String]) -> Result<()> { + let service = Compose::resolve(ctx)?.django()?; + docker_compose::run(ctx, &service, rest) } -pub fn manage(rest: &[String]) -> Result<()> { +pub fn manage(ctx: &Ctx, rest: &[String]) -> Result<()> { let mut args = vec!["python".to_string(), "manage.py".to_string()]; args.extend_from_slice(rest); - run(&args) + run(ctx, &args) } // shortcuts -pub fn makemigrations() -> Result<()> { - manage(&["makemigrations".to_string()]) +pub fn makemigrations(ctx: &Ctx) -> Result<()> { + manage(ctx, &["makemigrations".to_string()]) } -pub fn migrate(rest: &[String]) -> Result<()> { +pub fn migrate(ctx: &Ctx, rest: &[String]) -> Result<()> { let mut full_rest = vec!["migrate".to_string()]; full_rest.extend_from_slice(rest); - manage(&full_rest) + manage(ctx, &full_rest) } -pub fn shell() -> Result<()> { - manage(&["shell".to_string()]) +pub fn shell(ctx: &Ctx) -> Result<()> { + manage(ctx, &["shell".to_string()]) } -pub fn test() -> Result<()> { - manage(&["test".to_string()]) +pub fn test(ctx: &Ctx) -> Result<()> { + manage(ctx, &["test".to_string()]) } fn safe_create_file(path: PathBuf) -> Result { diff --git a/src/scripts/docker_compose.rs b/src/scripts/docker_compose.rs index 73d84f2..b889798 100644 --- a/src/scripts/docker_compose.rs +++ b/src/scripts/docker_compose.rs @@ -1,28 +1,29 @@ use anyhow::Result; use crate::command_builder::CommandBuilder; +use crate::ctx::Ctx; -pub fn run(service: &str, rest: &[String]) -> Result<()> { +pub fn run(ctx: &Ctx, service: &str, rest: &[String]) -> Result<()> { CommandBuilder::docker_compose() .args("run --rm") .arg(service) .args(rest) - .exec_replace() + .exec_replace(ctx) } -pub fn start(service: Option<&str>) -> Result<()> { +pub fn start(ctx: &Ctx, service: Option<&str>) -> Result<()> { let mut command = CommandBuilder::docker_compose().args("start"); if let Some(service) = service { command = command.arg(service); } - command.exec() + command.exec(ctx) } -pub fn stop() -> Result<()> { - CommandBuilder::docker_compose().args("stop").exec() +pub fn stop(ctx: &Ctx) -> Result<()> { + CommandBuilder::docker_compose().args("stop").exec(ctx) } -pub fn up() -> Result<()> { - CommandBuilder::docker_compose().args("up -d").exec() +pub fn up(ctx: &Ctx) -> Result<()> { + CommandBuilder::docker_compose().args("up -d").exec(ctx) } diff --git a/src/scripts/postgres.rs b/src/scripts/postgres.rs index f5b4d76..e73e4e9 100644 --- a/src/scripts/postgres.rs +++ b/src/scripts/postgres.rs @@ -10,7 +10,7 @@ use std::{ use super::docker_compose; use crate::cli::Format; -use crate::command_builder; +use crate::ctx::Ctx; use crate::{command_builder::CommandBuilder, compose::Compose}; const CUSTOM_MAGIC: &[u8] = b"PGDMP"; @@ -37,15 +37,15 @@ struct Database { } impl Database { - fn resolve() -> Result { - let compose = Compose::resolve()?; + fn resolve(ctx: &Ctx) -> Result { + let compose = Compose::resolve(ctx)?; let service = compose.postgres()?; let (user, name) = compose.postgres_credentials(&service); let container = CommandBuilder::docker_compose() .args("ps -q") .arg(&service) - .exec_get_stdout()? + .exec_get_stdout(ctx)? .trim() .to_string(); @@ -132,8 +132,10 @@ fn is_existing_role_error(line: &str) -> bool { line.starts_with("ERROR:") && line.contains("role \"") && line.ends_with("already exists") } -fn restore_cluster(db: &Database, script: &str, file: &Path) -> Result<()> { - let out = piped(db, script, file)?.output().context("running psql")?; +fn restore_cluster(ctx: &Ctx, db: &Database, script: &str, file: &Path) -> Result<()> { + let out = piped(ctx, db, script, file)? + .output() + .context("running psql")?; io::stdout().write_all(&out.stdout).ok(); @@ -187,21 +189,21 @@ fn pipefail(script: &str) -> String { format!("set -o pipefail; {script}") } -fn piped(db: &Database, script: &str, input: &Path) -> Result { +fn piped(ctx: &Ctx, db: &Database, script: &str, input: &Path) -> Result { let file = File::open(input).with_context(|| format!("opening {}", input.display()))?; let mut command = CommandBuilder::docker() .args("exec -i") .arg(&db.container) .args("sh -c") .arg(script) - .build()?; + .build(ctx)?; command.stdin(Stdio::from(file)); Ok(command) } -fn wait_until_ready(db: &Database) -> Result<()> { - if command_builder::is_dry_run() { +fn wait_until_ready(ctx: &Ctx, db: &Database) -> Result<()> { + if ctx.dry_run { return Ok(()); } @@ -215,7 +217,7 @@ fn wait_until_ready(db: &Database) -> Result<()> { .arg(&db.user) .args("-d") .arg(&db.name) - .build()? + .build(ctx)? .stdout(Stdio::null()) .spawn()? .wait()? @@ -237,30 +239,31 @@ fn wait_until_ready(db: &Database) -> Result<()> { } } -fn when_ready(db: &Database, command: CommandBuilder) -> Result<()> { - wait_until_ready(db)?; - command.exec() +fn when_ready(ctx: &Ctx, db: &Database, command: CommandBuilder) -> Result<()> { + wait_until_ready(ctx, db)?; + command.exec(ctx) } fn in_container(db: &Database) -> CommandBuilder { CommandBuilder::docker().args("exec").arg(&db.container) } -pub fn import(file: &Path) -> Result<()> { +pub fn import(ctx: &Ctx, file: &Path) -> Result<()> { let dump = Dump::of(file)?; - let db = Database::resolve()?; + let db = Database::resolve(ctx)?; eprintln!("stopping all containers"); - docker_compose::stop()?; + docker_compose::stop(ctx)?; eprintln!("starting db container"); - docker_compose::start(Some(&db.service))?; + docker_compose::start(ctx, Some(&db.service))?; let remote = remote_dump(); // a directory cannot be streamed, so it is the one shape that gets copied in if matches!(dump, Dump::Directory) { when_ready( + ctx, &db, CommandBuilder::docker() .args("cp -L") @@ -277,8 +280,8 @@ pub fn import(file: &Path) -> Result<()> { (kind, Some(db.restore_with(kind))) } Dump::Gzip => { - wait_until_ready(&db)?; - let out = piped(&db, &format!("gunzip -c | head -c {HEADER_LEN}"), file)? + wait_until_ready(ctx, &db)?; + let out = piped(ctx, &db, &format!("gunzip -c | head -c {HEADER_LEN}"), file)? .output() .context("reading the compressed dump's header")?; @@ -312,6 +315,7 @@ pub fn import(file: &Path) -> Result<()> { eprintln!("restoring database with {tool}"); when_ready( + ctx, &db, in_container(&db) .args("dropdb -U") @@ -323,6 +327,7 @@ pub fn import(file: &Path) -> Result<()> { // is already there if kind != Kind::Cluster { when_ready( + ctx, &db, in_container(&db) .args("createdb -U") @@ -332,23 +337,23 @@ pub fn import(file: &Path) -> Result<()> { )?; } - wait_until_ready(&db)?; - if command_builder::is_dry_run() { + wait_until_ready(ctx, &db)?; + if ctx.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)?, + (Kind::Cluster, Some(script)) => restore_cluster(ctx, &db, script, file)?, (_, restore) => { let status = match restore { - Some(script) => piped(&db, script, file)?.spawn()?.wait()?, + Some(script) => piped(ctx, &db, script, file)?.spawn()?.wait()?, None => in_container(&db) .args("pg_restore -U") .arg(&db.user) .arg(format!("--dbname={}", db.name)) .arg(&remote) - .build()? + .build(ctx)? .spawn()? .wait()?, }; @@ -361,25 +366,25 @@ pub fn import(file: &Path) -> Result<()> { } if matches!(dump, Dump::Directory) { - let _ = in_container(&db).args("rm -rf").arg(&remote).exec(); + let _ = in_container(&db).args("rm -rf").arg(&remote).exec(ctx); } eprintln!("restarting containers"); - docker_compose::stop()?; - docker_compose::up()?; + docker_compose::stop(ctx)?; + docker_compose::up(ctx)?; Ok(()) } -pub fn dump(file: &PathBuf, format: Format, gzip: bool) -> Result<()> { - let db = Database::resolve()?; +pub fn dump(ctx: &Ctx, file: &PathBuf, format: Format, gzip: bool) -> Result<()> { + let db = Database::resolve(ctx)?; if format == Format::Directory { if gzip { bail!("a directory dump is a directory of already compressed files, not a stream"); } - return dump_directory(&db, file); + return dump_directory(ctx, &db, file); } eprintln!("dumping to local file {}", file.to_string_lossy()); @@ -388,7 +393,7 @@ pub fn dump(file: &PathBuf, format: Format, gzip: bool) -> Result<()> { // cannot destroy the dump that is already there let partial = suffixed(file, ".partial"); // a dry run produces no dump, so it must not lay a hand on the target either - let stdout = if command_builder::is_dry_run() { + let stdout = if ctx.dry_run { Stdio::null() } else { Stdio::from( @@ -403,7 +408,7 @@ pub fn dump(file: &PathBuf, format: Format, gzip: bool) -> Result<()> { .arg(&db.container) .args("sh -c") .arg(pipefail(&format!("{} | gzip", dump_command(&db, format)))) - .exec_redirect_stdout(stdout) + .exec_redirect_stdout(ctx, stdout) } else { let dumping = match format.flag() { Some(flag) => in_container(&db) @@ -414,7 +419,7 @@ 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(ctx, stdout) }; if let Err(e) = dumped { @@ -422,7 +427,7 @@ pub fn dump(file: &PathBuf, format: Format, gzip: bool) -> Result<()> { return Err(e); } - if command_builder::is_dry_run() { + if ctx.dry_run { return Ok(()); } @@ -442,7 +447,7 @@ fn dump_command(db: &Database, format: Format) -> String { // pg_dump writes a directory format dump itself rather than to stdout, so it lands // in the container and comes back with docker cp -fn dump_directory(db: &Database, target: &Path) -> Result<()> { +fn dump_directory(ctx: &Ctx, db: &Database, target: &Path) -> Result<()> { if target.exists() { bail!( "{} already exists; a directory dump will not be written over it", @@ -459,15 +464,15 @@ fn dump_directory(db: &Database, target: &Path) -> Result<()> { .args("--format=d -f") .arg(&remote) .arg(&db.name) - .exec()?; + .exec(ctx)?; let copied = CommandBuilder::docker() .args("cp") .arg(format!("{}:{remote}", db.container)) .arg(target.to_string_lossy()) - .exec(); + .exec(ctx); - let _ = in_container(db).args("rm -rf").arg(&remote).exec(); + let _ = in_container(db).args("rm -rf").arg(&remote).exec(ctx); copied } From d7626d9f5e00c7620bfd122d084378568d34d01e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Jane=C5=BEi=C4=8D?= Date: Tue, 8 Sep 2026 11:54:21 +0000 Subject: [PATCH 3/6] refactor: give every command we run a type of its own --- Cargo.lock | 7 + Cargo.toml | 1 + src/cmd/argv.rs | 206 +++++++++++++++++++++++++++++ src/cmd/compose.rs | 92 +++++++++++++ src/cmd/django.rs | 44 +++++++ src/cmd/docker.rs | 69 ++++++++++ src/cmd/mod.rs | 80 ++++++++++++ src/cmd/postgres.rs | 206 +++++++++++++++++++++++++++++ src/cmd/shell.rs | 150 +++++++++++++++++++++ src/command_builder.rs | 123 ----------------- src/compose.rs | 21 +-- src/main.rs | 2 +- src/scripts/django.rs | 22 ++-- src/scripts/docker_compose.rs | 29 ----- src/scripts/mod.rs | 1 - src/scripts/postgres.rs | 239 ++++++++++++++-------------------- 16 files changed, 968 insertions(+), 324 deletions(-) create mode 100644 src/cmd/argv.rs create mode 100644 src/cmd/compose.rs create mode 100644 src/cmd/django.rs create mode 100644 src/cmd/docker.rs create mode 100644 src/cmd/mod.rs create mode 100644 src/cmd/postgres.rs create mode 100644 src/cmd/shell.rs delete mode 100644 src/command_builder.rs delete mode 100644 src/scripts/docker_compose.rs diff --git a/Cargo.lock b/Cargo.lock index a18df62..2a63163 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10,6 +10,7 @@ dependencies = [ "clap", "clap_complete", "serde_json", + "shlex", ] [[package]] @@ -213,6 +214,12 @@ dependencies = [ "zmij", ] +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "strsim" version = "0.11.1" diff --git a/Cargo.toml b/Cargo.toml index d6e03fe..0cc0a79 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ clap = { version = "4.6.6", features = ["derive", "env"] } clap_complete = "4.6.9" anyhow = "1.0.104" serde_json = "1.0.145" +shlex = "2.0.1" [build-dependencies] clap = { version = "4.6.6", features = ["derive", "env"] } diff --git a/src/cmd/argv.rs b/src/cmd/argv.rs new file mode 100644 index 0000000..e6d58b7 --- /dev/null +++ b/src/cmd/argv.rs @@ -0,0 +1,206 @@ +use std::{ + fmt::Display, + fs::File, + os::unix::process::CommandExt, + path::Path, + process::{Command, ExitStatus, Output, Stdio}, +}; + +use anyhow::{Context, Result, anyhow}; + +use crate::ctx::Ctx; + +// a program and its arguments: the only thing in ahab that knows what argv looks like +#[derive(Default, Clone)] +pub struct Argv(Vec); + +impl Display for Argv { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.quoted()) + } +} + +impl Argv { + pub fn new(program: &str) -> Self { + Self(vec![program.to_string()]) + } + + pub fn arg(mut self, arg: impl AsRef) -> Self { + self.0.push(arg.as_ref().to_string()); + self + } + + pub fn args(mut self, args: I) -> Self + where + I: IntoIterator, + S: AsRef, + { + self.0 + .extend(args.into_iter().map(|arg| arg.as_ref().to_string())); + self + } + + // a long option and the value it takes + pub fn flag(self, name: &str, value: impl AsRef) -> Self { + self.arg(name).arg(value) + } + + pub fn program(&self) -> &str { + self.0.first().map(String::as_str).unwrap_or_default() + } + + pub fn words(&self) -> &[String] { + &self.0 + } + + // one word list for `sh -c`, quoted so a value with a space survives the shell + pub fn quoted(&self) -> String { + // a nul byte is the only thing shlex refuses, and no argv can hold one + shlex::try_join(self.0.iter().map(String::as_str)).unwrap_or_default() + } + + pub fn run(&self, ctx: &Ctx) -> Result<()> { + if self.skipped(ctx) { + return Ok(()); + } + + let status = self.command(ctx)?.spawn()?.wait()?; + + self.check(status) + } + + // reading changes nothing, so a dry run answers the question for real + pub fn capture(&self, ctx: &Ctx) -> Result { + let out = self.command(ctx)?.output()?; + + if !out.status.success() { + // output() holds stderr back, so the command's own complaint has to be + // passed on here or it is lost + let stderr = String::from_utf8_lossy(&out.stderr); + + return Err(match stderr.trim() { + "" => self.failure(out.status), + reason => anyhow!("`{self}` failed: {reason}"), + }); + } + + Ok(String::from_utf8(out.stdout)?) + } + + // replaces this process, so the command's exit code and signals become ours + pub fn replace(&self, ctx: &Ctx) -> Result<()> { + if self.skipped(ctx) { + return Ok(()); + } + + let error = self.command(ctx)?.exec(); + + Err(error).with_context(|| format!("running `{self}`")) + } + + pub fn stream_to(&self, ctx: &Ctx, out: Stdio) -> Result<()> { + if self.skipped(ctx) { + return Ok(()); + } + + let status = self.command(ctx)?.stdout(out).spawn()?.wait()?; + + self.check(status) + } + + // the status rather than an error, for callers with something better to say + pub fn status(&self, ctx: &Ctx) -> Result { + Ok(self.command(ctx)?.spawn()?.wait()?) + } + + pub fn stdin_from(&self, ctx: &Ctx, input: &Path) -> Result { + let stdin = self.opened(input)?; + + Ok(self.command(ctx)?.stdin(stdin).spawn()?.wait()?) + } + + // both streams held back, for callers that read the command's complaints + pub fn stdin_from_captured(&self, ctx: &Ctx, input: &Path) -> Result { + let stdin = self.opened(input)?; + + Ok(self.command(ctx)?.stdin(stdin).output()?) + } + + // whether it succeeded, without failure being an error + pub fn quietly_succeeds(&self, ctx: &Ctx) -> Result { + Ok(self + .command(ctx)? + .stdout(Stdio::null()) + .spawn()? + .wait()? + .success()) + } + + fn command(&self, ctx: &Ctx) -> Result { + if ctx.verbose { + eprintln!("running `{self}`"); + } + + let (program, rest) = self.0.split_first().context("empty command")?; + let mut command = Command::new(program); + command.args(rest); + + Ok(command) + } + + fn skipped(&self, ctx: &Ctx) -> bool { + if ctx.dry_run { + eprintln!("would run `{self}`"); + return true; + } + + false + } + + fn opened(&self, path: &Path) -> Result { + let file = File::open(path).with_context(|| format!("opening {}", path.display()))?; + + Ok(Stdio::from(file)) + } + + fn check(&self, status: ExitStatus) -> Result<()> { + if status.success() { + return Ok(()); + } + + Err(self.failure(status)) + } + + fn failure(&self, status: ExitStatus) -> anyhow::Error { + match status.code() { + Some(code) => anyhow!("`{self}` exited with {code}"), + None => anyhow!("`{self}` was killed by a signal"), + } + } +} + +#[cfg(test)] +mod tests { + use super::Argv; + + #[test] + fn plain_words_are_left_as_they_are() { + let argv = Argv::new("pg_dump") + .flag("--username", "myproject") + .arg("--data-only") + .arg("myproject_db"); + + assert_eq!( + argv.quoted(), + "pg_dump --username myproject --data-only myproject_db" + ); + } + + #[test] + fn anything_a_shell_would_read_as_more_than_one_word_is_quoted() { + assert_eq!(Argv::new("psql").arg("my db").quoted(), "psql 'my db'"); + assert_eq!(Argv::new("sh").arg("a | b").quoted(), "sh 'a | b'"); + assert_eq!(Argv::new("echo").arg("").quoted(), "echo ''"); + assert_eq!(Argv::new("echo").arg("it's").quoted(), r#"echo "it's""#); + } +} diff --git a/src/cmd/compose.rs b/src/cmd/compose.rs new file mode 100644 index 0000000..77f55c5 --- /dev/null +++ b/src/cmd/compose.rs @@ -0,0 +1,92 @@ +use super::{Argv, Cmd}; + +fn compose() -> Argv { + Argv::new("docker").arg("compose") +} + +// docker compose run --rm, which runs the image's entrypoint and so fixes up the +// container user before handing over +pub struct Run { + service: String, + inner: Argv, +} + +impl Run { + pub fn wrapping(service: &str, inner: Argv) -> Self { + Self { + service: service.to_string(), + inner, + } + } +} + +impl Cmd for Run { + fn argv(&self) -> Argv { + compose() + .arg("run") + .arg("--rm") + .arg(&self.service) + .args(self.inner.words()) + } +} + +// the merged project file, with every extends and env_file resolved +pub struct Config; + +impl Cmd for Config { + fn argv(&self) -> Argv { + compose().arg("config").flag("--format", "json") + } +} + +// the container id a service is running as, empty when it is not running +pub struct Ps { + service: String, +} + +impl Ps { + pub fn id_of(service: &str) -> Self { + Self { + service: service.to_string(), + } + } +} + +impl Cmd for Ps { + fn argv(&self) -> Argv { + compose().arg("ps").arg("--quiet").arg(&self.service) + } +} + +pub struct Up; + +impl Cmd for Up { + fn argv(&self) -> Argv { + compose().arg("up").arg("--detach") + } +} + +pub struct Start { + service: String, +} + +impl Start { + pub fn service(service: &str) -> Self { + Self { + service: service.to_string(), + } + } +} + +impl Cmd for Start { + fn argv(&self) -> Argv { + compose().arg("start").arg(&self.service) + } +} +pub struct Stop; + +impl Cmd for Stop { + fn argv(&self) -> Argv { + compose().arg("stop") + } +} diff --git a/src/cmd/django.rs b/src/cmd/django.rs new file mode 100644 index 0000000..dea4580 --- /dev/null +++ b/src/cmd/django.rs @@ -0,0 +1,44 @@ +use super::{Argv, Cmd}; + +// an interactive shell in the django container +pub struct Bash; + +impl Cmd for Bash { + fn argv(&self) -> Argv { + Argv::new("bash") + } +} + +// django's manage.py, with the subcommand and its arguments +pub struct Manage<'a> { + args: &'a [String], +} + +impl<'a> Manage<'a> { + pub fn new(args: &'a [String]) -> Self { + Self { args } + } +} + +impl Cmd for Manage<'_> { + fn argv(&self) -> Argv { + Argv::new("python").arg("manage.py").args(self.args) + } +} + +// whatever the caller typed, passed through as it stands +pub struct Words<'a> { + words: &'a [String], +} + +impl<'a> Words<'a> { + pub fn new(words: &'a [String]) -> Self { + Self { words } + } +} + +impl Cmd for Words<'_> { + fn argv(&self) -> Argv { + Argv::default().args(self.words) + } +} diff --git a/src/cmd/docker.rs b/src/cmd/docker.rs new file mode 100644 index 0000000..d1fbc10 --- /dev/null +++ b/src/cmd/docker.rs @@ -0,0 +1,69 @@ +use super::{Argv, Cmd}; + +// docker exec, around a command that runs inside the container +pub struct Exec { + container: String, + interactive: bool, + inner: Argv, +} + +impl Exec { + pub fn wrapping(container: &str, inner: Argv) -> Self { + Self { + container: container.to_string(), + interactive: false, + inner, + } + } + + // keep stdin open, for a command that is fed a dump + pub fn interactive(mut self) -> Self { + self.interactive = true; + self + } +} + +impl Cmd for Exec { + fn argv(&self) -> Argv { + let mut argv = Argv::new("docker").arg("exec"); + + if self.interactive { + argv = argv.arg("--interactive"); + } + + argv.arg(&self.container).args(self.inner.words()) + } +} + +// docker cp, in either direction +pub struct Cp { + from: String, + to: String, +} + +impl Cp { + pub fn into_container(local: &str, container: &str, remote: &str) -> Self { + Self { + from: local.to_string(), + to: format!("{container}:{remote}"), + } + } + + pub fn out_of_container(container: &str, remote: &str, local: &str) -> Self { + Self { + from: format!("{container}:{remote}"), + to: local.to_string(), + } + } +} + +impl Cmd for Cp { + fn argv(&self) -> Argv { + // -L has no long form; it copies what a symlink points at + Argv::new("docker") + .arg("cp") + .arg("-L") + .arg(&self.from) + .arg(&self.to) + } +} diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs new file mode 100644 index 0000000..7cabfbe --- /dev/null +++ b/src/cmd/mod.rs @@ -0,0 +1,80 @@ +mod argv; +mod compose; +mod django; +mod docker; +mod postgres; +mod shell; + +use std::{ + path::Path, + process::{ExitStatus, Output, Stdio}, +}; + +use anyhow::Result; + +pub use argv::Argv; +pub use compose::{Config, Ps, Run, Start, Stop, Up}; +pub use django::{Bash, Manage, Words}; +pub use docker::{Cp, Exec}; +pub use postgres::{CreateDb, DropDb, PgDump, PgDumpAll, PgIsReady, PgRestore, Psql}; +pub use shell::{Gunzip, Gzip, Head, Pipeline, Rm}; + +use crate::ctx::Ctx; + +// every command ahab runs, from `docker compose config` to `pg_dump`, is a type +// that renders itself here; adapters wrap one command in another and terminals +// run it, so a call site reads as one chain +pub trait Cmd { + fn argv(&self) -> Argv; + + // this command as one word list, for a shell to read + fn shell(&self) -> String { + self.argv().quoted() + } + + // adapters + fn pipe(&self, next: &dyn Cmd) -> Pipeline { + Pipeline::starting(self.shell()).pipe(next) + } + + fn in_container(&self, container: &str) -> Exec { + Exec::wrapping(container, self.argv()) + } + + fn in_service(&self, service: &str) -> Run { + Run::wrapping(service, self.argv()) + } + // terminals + fn run(&self, ctx: &Ctx) -> Result<()> { + self.argv().run(ctx) + } + + fn capture(&self, ctx: &Ctx) -> Result { + self.argv().capture(ctx) + } + + fn replace(&self, ctx: &Ctx) -> Result<()> { + self.argv().replace(ctx) + } + + fn stream_to(&self, ctx: &Ctx, out: Stdio) -> Result<()> { + self.argv().stream_to(ctx, out) + } + + // the status rather than an error, for callers with something better to say + fn status(&self, ctx: &Ctx) -> Result { + self.argv().status(ctx) + } + + fn stdin_from(&self, ctx: &Ctx, input: &Path) -> Result { + self.argv().stdin_from(ctx, input) + } + + fn stdin_from_captured(&self, ctx: &Ctx, input: &Path) -> Result { + self.argv().stdin_from_captured(ctx, input) + } + + fn quietly_succeeds(&self, ctx: &Ctx) -> Result { + self.argv().quietly_succeeds(ctx) + } +} diff --git a/src/cmd/postgres.rs b/src/cmd/postgres.rs new file mode 100644 index 0000000..a39269f --- /dev/null +++ b/src/cmd/postgres.rs @@ -0,0 +1,206 @@ +use super::{Argv, Cmd}; + +// whether the server is accepting connections yet +pub struct PgIsReady<'a> { + pub username: &'a str, + pub dbname: &'a str, +} + +impl Cmd for PgIsReady<'_> { + fn argv(&self) -> Argv { + Argv::new("pg_isready") + .flag("--username", self.username) + .flag("--dbname", self.dbname) + } +} + +pub struct DropDb<'a> { + pub username: &'a str, + pub dbname: &'a str, +} + +impl Cmd for DropDb<'_> { + fn argv(&self) -> Argv { + Argv::new("dropdb") + .flag("--username", self.username) + .arg(self.dbname) + } +} + +pub struct CreateDb<'a> { + pub username: &'a str, + pub dbname: &'a str, +} + +impl Cmd for CreateDb<'_> { + fn argv(&self) -> Argv { + // template0 rather than template1, so nothing the local cluster picked up + // ends up in a restored database + Argv::new("createdb") + .flag("--username", self.username) + .flag("--encoding", "utf8") + .flag("--template", "template0") + .arg(self.dbname) + } +} + +// reads a custom, tar or directory format dump +pub struct PgRestore<'a> { + username: &'a str, + dbname: &'a str, + from: Option<&'a str>, +} + +impl<'a> PgRestore<'a> { + pub fn new(username: &'a str, dbname: &'a str) -> Self { + Self { + username, + dbname, + from: None, + } + } + + // a path in the container, for a dump that could not be streamed in + pub fn from(mut self, path: &'a str) -> Self { + self.from = Some(path); + self + } +} + +impl Cmd for PgRestore<'_> { + fn argv(&self) -> Argv { + let argv = Argv::new("pg_restore") + .flag("--username", self.username) + .flag("--dbname", self.dbname); + + match self.from { + Some(path) => argv.arg(path), + None => argv, + } + } +} + +// reads a plain sql dump, of one database or of a whole cluster +pub struct Psql<'a> { + username: &'a str, + dbname: &'a str, + atomic: bool, +} + +impl<'a> Psql<'a> { + pub fn new(username: &'a str, dbname: &'a str) -> Self { + Self { + username, + dbname, + atomic: false, + } + } + + // 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 + } +} + +impl Cmd for Psql<'_> { + fn argv(&self) -> Argv { + let argv = Argv::new("psql") + .arg("--quiet") + .flag("--output", "/dev/null") + .flag("--username", self.username) + .flag("--dbname", self.dbname); + + if self.atomic { + return argv + .flag("--variable", "ON_ERROR_STOP=1") + .arg("--single-transaction"); + } + + argv + } +} + +pub struct PgDump<'a> { + username: &'a str, + dbname: &'a str, + format: &'a str, + to: Option<&'a str>, +} + +impl<'a> PgDump<'a> { + pub fn new(username: &'a str, dbname: &'a str, format: &'a str) -> Self { + Self { + username, + dbname, + format, + to: None, + } + } + + // a path in the container, for the directory format, which pg_dump writes + // itself rather than to stdout + pub fn to(mut self, path: &'a str) -> Self { + self.to = Some(path); + self + } +} + +impl Cmd for PgDump<'_> { + fn argv(&self) -> Argv { + let argv = Argv::new("pg_dump") + .flag("--username", self.username) + .flag("--format", self.format); + + match self.to { + Some(path) => argv.flag("--file", path), + None => argv, + } + .arg(self.dbname) + } +} + +// the whole cluster, roles and all +pub struct PgDumpAll<'a> { + pub username: &'a str, +} + +impl Cmd for PgDumpAll<'_> { + fn argv(&self) -> Argv { + Argv::new("pg_dumpall").flag("--username", self.username) + } +} + +#[cfg(test)] +mod tests { + use super::{PgDump, Psql}; + use crate::cmd::Cmd; + + #[test] + fn a_directory_dump_names_the_file_it_writes() { + assert_eq!( + PgDump::new("u", "db", "d").to("/tmp/dump").argv().quoted(), + "pg_dump --username u --format d --file /tmp/dump db" + ); + assert_eq!( + PgDump::new("u", "db", "c").argv().quoted(), + "pg_dump --username u --format c db" + ); + } + + #[test] + fn only_a_single_database_restore_stops_at_the_first_error() { + assert!( + Psql::new("u", "db") + .atomic() + .argv() + .quoted() + .ends_with("--variable 'ON_ERROR_STOP=1' --single-transaction") + ); + assert_eq!( + Psql::new("u", "postgres").argv().quoted(), + "psql --quiet --output /dev/null --username u --dbname postgres" + ); + } +} diff --git a/src/cmd/shell.rs b/src/cmd/shell.rs new file mode 100644 index 0000000..850a590 --- /dev/null +++ b/src/cmd/shell.rs @@ -0,0 +1,150 @@ +use super::{Argv, Cmd}; + +// sh -c, the only way to reach a shell feature inside a container +pub struct Sh(String); + +impl Sh { + pub fn new(script: impl Into) -> Self { + Self(script.into()) + } +} + +impl Cmd for Sh { + fn argv(&self) -> Argv { + // -c has no long form + Argv::new("sh").arg("-c").arg(&self.0) + } +} + +// commands joined by pipes, which only a shell can run +pub struct Pipeline { + stages: Vec, + pipefail: bool, +} + +impl Pipeline { + pub fn starting(first: String) -> Self { + Self { + stages: vec![first], + pipefail: true, + } + } + + pub fn pipe(mut self, next: &dyn Cmd) -> Self { + self.stages.push(next.shell()); + self + } + + // for a pipeline whose last stage closes the pipe on purpose, where the + // SIGPIPE that kills an earlier stage is the expected end and not a failure + pub fn allow_early_close(mut self) -> Self { + self.pipefail = false; + self + } + + fn script(&self) -> String { + let piped = self.stages.join(" | "); + + // a pipeline reports only its last stage's status, so a first stage that + // dies mid-stream reads as success without this + if self.pipefail { + format!("set -o pipefail; {piped}") + } else { + piped + } + } +} + +impl Cmd for Pipeline { + fn argv(&self) -> Argv { + Sh::new(self.script()).argv() + } +} + +pub struct Gzip; + +impl Cmd for Gzip { + fn argv(&self) -> Argv { + Argv::new("gzip") + } +} + +// busybox, which alpine based images ship, has no long options for these three +pub struct Gunzip; + +impl Cmd for Gunzip { + fn argv(&self) -> Argv { + Argv::new("gunzip").arg("-c") + } +} + +pub struct Head { + bytes: usize, +} + +impl Head { + pub fn bytes(bytes: usize) -> Self { + Self { bytes } + } +} + +impl Cmd for Head { + fn argv(&self) -> Argv { + Argv::new("head").arg("-c").arg(self.bytes.to_string()) + } +} + +pub struct Rm { + path: String, +} + +impl Rm { + pub fn recursive(path: &str) -> Self { + Self { + path: path.to_string(), + } + } +} + +impl Cmd for Rm { + fn argv(&self) -> Argv { + Argv::new("rm").arg("-rf").arg(&self.path) + } +} + +#[cfg(test)] +mod tests { + use super::{Gunzip, Gzip, Head}; + use crate::cmd::Cmd; + + #[test] + fn a_pipeline_reports_a_stage_that_dies_mid_stream() { + let script = Gunzip.pipe(&Gzip).shell(); + + assert_eq!(script, "sh -c 'set -o pipefail; gunzip -c | gzip'"); + } + + #[test] + fn a_pipeline_that_closes_the_pipe_on_purpose_keeps_the_default() { + let script = Gunzip.pipe(&Head::bytes(512)).allow_early_close().shell(); + + assert_eq!(script, "sh -c 'gunzip -c | head -c 512'"); + } + + #[test] + fn a_pipeline_reaches_a_container_as_one_argument() { + let argv = Gunzip.pipe(&Gzip).in_container("abc123").argv(); + + assert_eq!( + argv.words(), + [ + "docker", + "exec", + "abc123", + "sh", + "-c", + "set -o pipefail; gunzip -c | gzip" + ] + ); + } +} diff --git a/src/command_builder.rs b/src/command_builder.rs deleted file mode 100644 index 3f63082..0000000 --- a/src/command_builder.rs +++ /dev/null @@ -1,123 +0,0 @@ -use anyhow::{Context, Result, bail}; -use std::{ - fmt::Display, - os::unix::process::CommandExt, - process::{Command, ExitStatus, Stdio}, -}; - -use crate::ctx::Ctx; - -pub struct Args(Vec); - -impl From<&str> for Args { - fn from(value: &str) -> 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()) - } -} - -#[derive(Default)] -pub struct CommandBuilder { - args: Vec, -} - -impl Display for CommandBuilder { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&self.args.join(" ")) - } -} - -impl CommandBuilder { - pub fn docker() -> Self { - Self::default().args("docker") - } - - pub fn docker_compose() -> Self { - Self::default().args("docker compose") - } - - pub fn arg(mut self, arg: impl AsRef) -> Self { - self.args.push(arg.as_ref().to_string()); - self - } - - pub fn args(mut self, args: T) -> Self - where - Args: From, - { - self.args.extend(Args::from(args).0); - self - } - - pub fn build(self, ctx: &Ctx) -> Result { - if ctx.verbose { - eprintln!("running `{self}`"); - } - let (first, rest) = self.args.split_first().context("empty args")?; - let mut command = Command::new(first); - command.args(rest); - - Ok(command) - } - - pub fn exec_get_stdout(self, ctx: &Ctx) -> Result { - let shown = self.to_string(); - let out = self.build(ctx)?.output()?; - - check(&shown, out.status)?; - Ok(String::from_utf8(out.stdout)?) - } - - pub fn exec(self, ctx: &Ctx) -> Result<()> { - if ctx.dry_run { - eprintln!("would run `{self}`"); - return Ok(()); - } - - let shown = self.to_string(); - let status = self.build(ctx)?.spawn()?.wait()?; - - check(&shown, status) - } - - // replaces this process, so the command's exit code and signals become ours - pub fn exec_replace(self, ctx: &Ctx) -> Result<()> { - if ctx.dry_run { - eprintln!("would run `{self}`"); - return Ok(()); - } - - let shown = self.to_string(); - let error = self.build(ctx)?.exec(); - - Err(error).with_context(|| format!("running `{shown}`")) - } - - pub fn exec_redirect_stdout(self, ctx: &Ctx, stdio: Stdio) -> Result<()> { - if ctx.dry_run { - eprintln!("would run `{self}`"); - return Ok(()); - } - - let shown = self.to_string(); - let status = self.build(ctx)?.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"), - } -} diff --git a/src/compose.rs b/src/compose.rs index c604f81..9e89f4d 100644 --- a/src/compose.rs +++ b/src/compose.rs @@ -1,7 +1,7 @@ -use anyhow::{Context, Result, anyhow, bail}; +use anyhow::{Context, Result, anyhow}; use serde_json::Value; -use crate::command_builder::CommandBuilder; +use crate::cmd::{Cmd, Config}; use crate::ctx::Ctx; const POSTGRES_IMAGES: [&str; 4] = ["postg", "timescale", "pgvector", "citus"]; @@ -13,21 +13,8 @@ pub struct Compose { impl Compose { pub fn resolve(ctx: &Ctx) -> Result { - let out = CommandBuilder::docker_compose() - .args("config --format json") - .build(ctx)? - .output() - .context("running docker compose config")?; - - if !out.status.success() { - bail!( - "docker compose config failed: {}", - String::from_utf8_lossy(&out.stderr).trim() - ); - } - - let config: Value = - serde_json::from_slice(&out.stdout).context("parsing docker compose config")?; + let json = Config.capture(ctx)?; + let config: Value = serde_json::from_str(&json).context("parsing docker compose config")?; let services = config .get("services") .cloned() diff --git a/src/main.rs b/src/main.rs index 7546671..7d3aff6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,7 @@ use std::process::ExitCode; mod cli; -mod command_builder; +mod cmd; mod compose; mod ctx; mod scripts; diff --git a/src/scripts/django.rs b/src/scripts/django.rs index 915c9a5..0170839 100644 --- a/src/scripts/django.rs +++ b/src/scripts/django.rs @@ -4,9 +4,9 @@ use std::path::{Path, PathBuf}; use anyhow::{Result, anyhow}; +use crate::cmd::{Bash, Cmd, Manage, Words}; use crate::compose::Compose; use crate::ctx::Ctx; -use crate::scripts::docker_compose; const DEBUG_TEMPLATE: &str = r#"from django.core.management.base import BaseCommand @@ -55,19 +55,15 @@ pub fn make_command(app: &PathBuf, name: &str) -> Result<()> { } pub fn bash(ctx: &Ctx) -> Result<()> { - run(ctx, &["bash".to_string()]) + Bash.in_service(&service(ctx)?).replace(ctx) } pub fn run(ctx: &Ctx, rest: &[String]) -> Result<()> { - let service = Compose::resolve(ctx)?.django()?; - docker_compose::run(ctx, &service, rest) + Words::new(rest).in_service(&service(ctx)?).replace(ctx) } pub fn manage(ctx: &Ctx, rest: &[String]) -> Result<()> { - let mut args = vec!["python".to_string(), "manage.py".to_string()]; - args.extend_from_slice(rest); - - run(ctx, &args) + Manage::new(rest).in_service(&service(ctx)?).replace(ctx) } // shortcuts @@ -76,10 +72,10 @@ pub fn makemigrations(ctx: &Ctx) -> Result<()> { } pub fn migrate(ctx: &Ctx, rest: &[String]) -> Result<()> { - let mut full_rest = vec!["migrate".to_string()]; - full_rest.extend_from_slice(rest); + let mut args = vec!["migrate".to_string()]; + args.extend_from_slice(rest); - manage(ctx, &full_rest) + manage(ctx, &args) } pub fn shell(ctx: &Ctx) -> Result<()> { @@ -90,6 +86,10 @@ pub fn test(ctx: &Ctx) -> Result<()> { manage(ctx, &["test".to_string()]) } +fn service(ctx: &Ctx) -> Result { + Compose::resolve(ctx)?.django() +} + fn safe_create_file(path: PathBuf) -> Result { OpenOptions::new().write(true).create_new(true).open(path) } diff --git a/src/scripts/docker_compose.rs b/src/scripts/docker_compose.rs deleted file mode 100644 index b889798..0000000 --- a/src/scripts/docker_compose.rs +++ /dev/null @@ -1,29 +0,0 @@ -use anyhow::Result; - -use crate::command_builder::CommandBuilder; -use crate::ctx::Ctx; - -pub fn run(ctx: &Ctx, service: &str, rest: &[String]) -> Result<()> { - CommandBuilder::docker_compose() - .args("run --rm") - .arg(service) - .args(rest) - .exec_replace(ctx) -} - -pub fn start(ctx: &Ctx, service: Option<&str>) -> Result<()> { - let mut command = CommandBuilder::docker_compose().args("start"); - if let Some(service) = service { - command = command.arg(service); - } - - command.exec(ctx) -} - -pub fn stop(ctx: &Ctx) -> Result<()> { - CommandBuilder::docker_compose().args("stop").exec(ctx) -} - -pub fn up(ctx: &Ctx) -> Result<()> { - CommandBuilder::docker_compose().args("up -d").exec(ctx) -} diff --git a/src/scripts/mod.rs b/src/scripts/mod.rs index 451a4ad..e33f8ca 100644 --- a/src/scripts/mod.rs +++ b/src/scripts/mod.rs @@ -1,5 +1,4 @@ pub mod completions; pub mod django; -pub mod docker_compose; pub mod link; pub mod postgres; diff --git a/src/scripts/postgres.rs b/src/scripts/postgres.rs index e73e4e9..dbf7506 100644 --- a/src/scripts/postgres.rs +++ b/src/scripts/postgres.rs @@ -8,10 +8,13 @@ use std::{ time::{Duration, Instant}, }; -use super::docker_compose; use crate::cli::Format; +use crate::cmd::{ + Cmd, Cp, CreateDb, DropDb, Gunzip, Gzip, Head, PgDump, PgDumpAll, PgIsReady, PgRestore, Ps, + Psql, Rm, Start, Stop, Up, +}; +use crate::compose::Compose; use crate::ctx::Ctx; -use crate::{command_builder::CommandBuilder, compose::Compose}; const CUSTOM_MAGIC: &[u8] = b"PGDMP"; const TAR_MAGIC: &[u8] = b"toc.dat"; @@ -42,12 +45,7 @@ impl Database { let service = compose.postgres()?; let (user, name) = compose.postgres_credentials(&service); - let container = CommandBuilder::docker_compose() - .args("ps -q") - .arg(&service) - .exec_get_stdout(ctx)? - .trim() - .to_string(); + let container = Ps::id_of(&service).capture(ctx)?.trim().to_string(); if container.is_empty() { return Err(anyhow!("service {service} has no running container")); @@ -61,16 +59,12 @@ impl Database { }) } - fn restore_with(&self, kind: Kind) -> String { + // what reads this shape of dump back in + fn restore_with(&self, kind: Kind) -> Box { match kind { - Kind::Archive => format!("pg_restore -U {} --dbname={}", self.user, self.name), - Kind::Sql => format!( - "psql -q -o /dev/null -U {} -d {} -v ON_ERROR_STOP=1 --single-transaction", - self.user, self.name - ), - // a cluster dump connects to each database itself, and stopping at the - // first error would stop at a role that is already there - Kind::Cluster => format!("psql -q -o /dev/null -U {} -d postgres", self.user), + 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")), } } } @@ -132,9 +126,11 @@ fn is_existing_role_error(line: &str) -> bool { line.starts_with("ERROR:") && line.contains("role \"") && line.ends_with("already exists") } -fn restore_cluster(ctx: &Ctx, db: &Database, script: &str, file: &Path) -> Result<()> { - let out = piped(ctx, db, script, file)? - .output() +fn restore_cluster(ctx: &Ctx, db: &Database, restore: &dyn Cmd, file: &Path) -> Result<()> { + let out = restore + .in_container(&db.container) + .interactive() + .stdin_from_captured(ctx, file) .context("running psql")?; io::stdout().write_all(&out.stdout).ok(); @@ -182,26 +178,6 @@ fn read_header(path: &Path) -> Result> { Ok(header) } -// a pipeline reports the last stage's status by default, so a first stage that -// dies mid-stream looks like success; not for pipelines that close the pipe -// early on purpose, where SIGPIPE would then read as failure -fn pipefail(script: &str) -> String { - format!("set -o pipefail; {script}") -} - -fn piped(ctx: &Ctx, db: &Database, script: &str, input: &Path) -> Result { - let file = File::open(input).with_context(|| format!("opening {}", input.display()))?; - let mut command = CommandBuilder::docker() - .args("exec -i") - .arg(&db.container) - .args("sh -c") - .arg(script) - .build(ctx)?; - - command.stdin(Stdio::from(file)); - Ok(command) -} - fn wait_until_ready(ctx: &Ctx, db: &Database) -> Result<()> { if ctx.dry_run { return Ok(()); @@ -210,18 +186,12 @@ fn wait_until_ready(ctx: &Ctx, db: &Database) -> Result<()> { let deadline = Instant::now() + READY_TIMEOUT; loop { - let ready = CommandBuilder::docker() - .args("exec") - .arg(&db.container) - .args("pg_isready -U") - .arg(&db.user) - .args("-d") - .arg(&db.name) - .build(ctx)? - .stdout(Stdio::null()) - .spawn()? - .wait()? - .success(); + let ready = PgIsReady { + username: &db.user, + dbname: &db.name, + } + .in_container(&db.container) + .quietly_succeeds(ctx)?; if ready { return Ok(()); @@ -239,13 +209,9 @@ fn wait_until_ready(ctx: &Ctx, db: &Database) -> Result<()> { } } -fn when_ready(ctx: &Ctx, db: &Database, command: CommandBuilder) -> Result<()> { +fn when_ready(ctx: &Ctx, db: &Database, command: &dyn Cmd) -> Result<()> { wait_until_ready(ctx, db)?; - command.exec(ctx) -} - -fn in_container(db: &Database) -> CommandBuilder { - CommandBuilder::docker().args("exec").arg(&db.container) + command.run(ctx) } pub fn import(ctx: &Ctx, file: &Path) -> Result<()> { @@ -253,10 +219,10 @@ pub fn import(ctx: &Ctx, file: &Path) -> Result<()> { let db = Database::resolve(ctx)?; eprintln!("stopping all containers"); - docker_compose::stop(ctx)?; + Stop.run(ctx)?; eprintln!("starting db container"); - docker_compose::start(ctx, Some(&db.service))?; + Start::service(&db.service).run(ctx)?; let remote = remote_dump(); @@ -265,24 +231,22 @@ pub fn import(ctx: &Ctx, file: &Path) -> Result<()> { when_ready( ctx, &db, - CommandBuilder::docker() - .args("cp -L") - .arg(file.to_string_lossy()) - .arg(format!("{}:{remote}", db.container)), + &Cp::into_container(&file.to_string_lossy(), &db.container, &remote), )?; } - let (kind, restore) = match &dump { - Dump::Directory => (Kind::Archive, None), - Dump::Header(header) => { - let kind = Kind::of(header); - - (kind, Some(db.restore_with(kind))) - } + let kind = match &dump { + Dump::Directory => Kind::Archive, + Dump::Header(header) => Kind::of(header), Dump::Gzip => { wait_until_ready(ctx, &db)?; - let out = piped(ctx, &db, &format!("gunzip -c | head -c {HEADER_LEN}"), file)? - .output() + let out = Gunzip + .pipe(&Head::bytes(HEADER_LEN)) + // head closes the pipe once it has its bytes, which kills gunzip + .allow_early_close() + .in_container(&db.container) + .interactive() + .stdin_from_captured(ctx, file) .context("reading the compressed dump's header")?; // head exits 0 whatever gunzip did, so an empty header is the only @@ -302,12 +266,7 @@ pub fn import(ctx: &Ctx, file: &Path) -> Result<()> { ); } - let kind = Kind::of(&out.stdout); - - ( - kind, - Some(pipefail(&format!("gunzip -c | {}", db.restore_with(kind)))), - ) + Kind::of(&out.stdout) } }; @@ -317,10 +276,11 @@ pub fn import(ctx: &Ctx, file: &Path) -> Result<()> { when_ready( ctx, &db, - in_container(&db) - .args("dropdb -U") - .arg(&db.user) - .arg(&db.name), + &DropDb { + username: &db.user, + dbname: &db.name, + } + .in_container(&db.container), )?; // a cluster dump creates the database itself, and would trip over one that @@ -329,11 +289,11 @@ pub fn import(ctx: &Ctx, file: &Path) -> Result<()> { when_ready( ctx, &db, - in_container(&db) - .args("createdb -U") - .arg(&db.user) - .args("-E utf8 -T template0") - .arg(&db.name), + &CreateDb { + username: &db.user, + dbname: &db.name, + } + .in_container(&db.container), )?; } @@ -341,22 +301,34 @@ pub fn import(ctx: &Ctx, file: &Path) -> Result<()> { if ctx.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(ctx, &db, script, file)?, - (_, restore) => { - let status = match restore { - Some(script) => piped(ctx, &db, script, file)?.spawn()?.wait()?, - None => in_container(&db) - .args("pg_restore -U") - .arg(&db.user) - .arg(format!("--dbname={}", db.name)) - .arg(&remote) - .build(ctx)? - .spawn()? - .wait()?, - }; + // 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 + // arrives compressed + if matches!(dump, Dump::Directory) { + let status = PgRestore::new(&db.user, &db.name) + .from(&remote) + .in_container(&db.container) + .status(ctx)?; + + if !status.success() { + bail!("{tool} failed, the database is left empty"); + } + } else { + let restore = db.restore_with(kind); + let restore: Box = match dump { + Dump::Gzip => Box::new(Gunzip.pipe(&*restore)), + _ => restore, + }; + + if kind == Kind::Cluster { + // psql's output is read rather than streamed here, to keep the + // expected role errors out of the way + restore_cluster(ctx, &db, &*restore, file)?; + } else { + let status = restore + .in_container(&db.container) + .interactive() + .stdin_from(ctx, file)?; if !status.success() { bail!("{tool} failed, the database is left empty"); @@ -366,12 +338,12 @@ pub fn import(ctx: &Ctx, file: &Path) -> Result<()> { } if matches!(dump, Dump::Directory) { - let _ = in_container(&db).args("rm -rf").arg(&remote).exec(ctx); + let _ = Rm::recursive(&remote).in_container(&db.container).run(ctx); } eprintln!("restarting containers"); - docker_compose::stop(ctx)?; - docker_compose::up(ctx)?; + Stop.run(ctx)?; + Up.run(ctx)?; Ok(()) } @@ -401,25 +373,15 @@ pub fn dump(ctx: &Ctx, file: &PathBuf, format: Format, gzip: bool) -> Result<()> ) }; - 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(pipefail(&format!("{} | gzip", dump_command(&db, format)))) - .exec_redirect_stdout(ctx, stdout) - } else { - 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), - }; + let dumping = dump_command(&db, format); - dumping.exec_redirect_stdout(ctx, stdout) + let dumped = if gzip { + dumping + .pipe(&Gzip) + .in_container(&db.container) + .stream_to(ctx, stdout) + } else { + dumping.in_container(&db.container).stream_to(ctx, stdout) }; if let Err(e) = dumped { @@ -437,11 +399,11 @@ pub fn dump(ctx: &Ctx, file: &PathBuf, format: Format, gzip: bool) -> Result<()> Ok(()) } -// what to run inside the container, without the docker exec in front of it -fn dump_command(db: &Database, format: Format) -> String { +// pg_dump for one database, pg_dumpall for a cluster, which has no format letter +fn dump_command(db: &Database, format: Format) -> Box { match format.flag() { - Some(flag) => format!("pg_dump -U {} --format={flag} {}", db.user, db.name), - None => format!("pg_dumpall -U {}", db.user), + Some(flag) => Box::new(PgDump::new(&db.user, &db.name, flag)), + None => Box::new(PgDumpAll { username: &db.user }), } } @@ -458,21 +420,14 @@ fn dump_directory(ctx: &Ctx, db: &Database, target: &Path) -> Result<()> { eprintln!("dumping to local directory {}", target.display()); let remote = remote_dump(); - in_container(db) - .args("pg_dump -U") - .arg(&db.user) - .args("--format=d -f") - .arg(&remote) - .arg(&db.name) - .exec(ctx)?; + PgDump::new(&db.user, &db.name, "d") + .to(&remote) + .in_container(&db.container) + .run(ctx)?; - let copied = CommandBuilder::docker() - .args("cp") - .arg(format!("{}:{remote}", db.container)) - .arg(target.to_string_lossy()) - .exec(ctx); + let copied = Cp::out_of_container(&db.container, &remote, &target.to_string_lossy()).run(ctx); - let _ = in_container(db).args("rm -rf").arg(&remote).exec(ctx); + let _ = Rm::recursive(&remote).in_container(&db.container).run(ctx); copied } From bbbca755be672af5d21927c4f820840da955a97d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Jane=C5=BEi=C4=8D?= Date: Tue, 8 Sep 2026 11:57:45 +0000 Subject: [PATCH 4/6] refactor: run git through the same layer as everything else --- src/cmd/git.rs | 156 ++++++++++++++++++++++++++++++++++++++++++++ src/cmd/mod.rs | 2 + src/main.rs | 13 +++- src/scripts/link.rs | 130 +++++++++++++++--------------------- 4 files changed, 219 insertions(+), 82 deletions(-) create mode 100644 src/cmd/git.rs diff --git a/src/cmd/git.rs b/src/cmd/git.rs new file mode 100644 index 0000000..cf7d211 --- /dev/null +++ b/src/cmd/git.rs @@ -0,0 +1,156 @@ +use std::path::Path; + +use super::{Argv, Cmd}; + +// -C has no long form; it runs git as though from that directory +fn git(root: &Path) -> Argv { + Argv::new("git").arg("-C").arg(root.to_string_lossy()) +} + +// the root of the repository the working directory is in +pub struct RevParse; + +impl Cmd for RevParse { + fn argv(&self) -> Argv { + Argv::new("git").arg("rev-parse").arg("--show-toplevel") + } +} + +// the url a remote points at, if the repository has one +pub struct ConfigGet<'a> { + pub key: &'a str, +} + +impl Cmd for ConfigGet<'_> { + fn argv(&self) -> Argv { + Argv::new("git").arg("config").flag("--get", self.key) + } +} + +pub struct LsFiles<'a> { + root: &'a Path, + tracked: bool, + ignored: bool, + pathspecs: Vec, +} + +impl<'a> LsFiles<'a> { + // paths git would not restore: untracked ones, and whole directories rather + // than every file inside them + pub fn untracked(root: &'a Path) -> Self { + Self { + root, + tracked: false, + ignored: false, + pathspecs: Vec::new(), + } + } + + pub fn tracked(root: &'a Path) -> Self { + Self { + root, + tracked: true, + ignored: false, + pathspecs: Vec::new(), + } + } + + // the ignored paths instead of the merely untracked ones + pub fn ignored(mut self) -> Self { + self.ignored = true; + self + } + + pub fn limited_to>(mut self, pathspecs: &[P]) -> Self { + self.pathspecs = pathspecs + .iter() + .map(|path| path.as_ref().to_string_lossy().to_string()) + .collect(); + self + } +} + +impl Cmd for LsFiles<'_> { + fn argv(&self) -> Argv { + let mut argv = git(self.root).arg("ls-files"); + + if self.tracked { + argv = argv.arg("--cached"); + } else { + // -z has no long form; it separates the paths with NUL, which is the + // only separator a filename cannot contain + argv = argv + .arg("-z") + .arg("--others") + .arg("--exclude-standard") + .arg("--directory") + .arg("--no-empty-directory"); + } + + if self.ignored { + argv = argv.arg("--ignored"); + } + + argv.arg("--").args(&self.pathspecs) + } +} + +// whether a path is ignored, said through the exit code alone +pub struct CheckIgnore<'a> { + root: &'a Path, + path: &'a Path, +} + +impl<'a> CheckIgnore<'a> { + pub fn new(root: &'a Path, path: &'a Path) -> Self { + Self { root, path } + } +} + +impl Cmd for CheckIgnore<'_> { + fn argv(&self) -> Argv { + git(self.root) + .arg("check-ignore") + .arg("--quiet") + .arg("--") + .arg(self.path.to_string_lossy()) + } +} + +#[cfg(test)] +mod tests { + use std::path::{Path, PathBuf}; + + use super::{CheckIgnore, LsFiles}; + use crate::cmd::Cmd; + + #[test] + fn an_untracked_listing_asks_for_whole_directories() { + let argv = LsFiles::untracked(Path::new("/repo")) + .ignored() + .limited_to(&[PathBuf::from("a b")]) + .argv(); + + assert_eq!( + argv.quoted(), + "git -C /repo ls-files -z --others --exclude-standard --directory \ + --no-empty-directory --ignored -- 'a b'" + ); + } + + #[test] + fn the_tracked_listing_asks_git_only_about_the_paths_given() { + let argv = LsFiles::tracked(Path::new("/repo")) + .limited_to(&[PathBuf::from(".env")]) + .argv(); + + assert_eq!(argv.quoted(), "git -C /repo ls-files --cached -- .env"); + } + + #[test] + fn check_ignore_says_nothing_and_reports_through_its_status() { + let argv = CheckIgnore::new(Path::new("/repo"), Path::new(".env")).argv(); + + assert_eq!(argv.quoted(), "git -C /repo check-ignore --quiet -- .env"); + } +} diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index 7cabfbe..1c13afa 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -2,6 +2,7 @@ mod argv; mod compose; mod django; mod docker; +mod git; mod postgres; mod shell; @@ -16,6 +17,7 @@ pub use argv::Argv; pub use compose::{Config, Ps, Run, Start, Stop, Up}; pub use django::{Bash, Manage, Words}; pub use docker::{Cp, Exec}; +pub use git::{CheckIgnore, ConfigGet, LsFiles, RevParse}; pub use postgres::{CreateDb, DropDb, PgDump, PgDumpAll, PgIsReady, PgRestore, Psql}; pub use shell::{Gunzip, Gzip, Head, Pipeline, Rm}; diff --git a/src/main.rs b/src/main.rs index 7d3aff6..4db2ecc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -67,9 +67,9 @@ fn run(ctx: &Ctx, command: cli::Commands) -> Result { paths, force, store, - } => scripts::link::add(&paths, force, store.root.as_deref()).map(|()| done), + } => scripts::link::add(ctx, &paths, force, store.root.as_deref()).map(|()| done), cli::Link::Restore { paths, all, store } => { - scripts::link::restore(&paths, all, store.root.as_deref()).map(|()| done) + scripts::link::restore(ctx, &paths, all, store.root.as_deref()).map(|()| done) } // the one command with something to say through its exit code cli::Link::Check { @@ -78,7 +78,14 @@ fn run(ctx: &Ctx, command: cli::Commands) -> Result { null, exit_code, store, - } => scripts::link::check(&paths, porcelain, null, exit_code, store.root.as_deref()), + } => scripts::link::check( + ctx, + &paths, + porcelain, + null, + exit_code, + store.root.as_deref(), + ), }, cli::Commands::Completions { shell } => { scripts::completions::completions(shell)?; diff --git a/src/scripts/link.rs b/src/scripts/link.rs index 90c780f..f1c402c 100644 --- a/src/scripts/link.rs +++ b/src/scripts/link.rs @@ -4,26 +4,29 @@ use std::ffi::OsString; use std::fs; use std::os::unix::fs::symlink; use std::path::{Path, PathBuf}; -use std::process::Command; + use std::process::ExitCode; use anyhow::{Context, Result, anyhow, bail}; +use crate::cmd::{CheckIgnore, Cmd, ConfigGet, LsFiles, RevParse}; +use crate::ctx::Ctx; + const BACKUP_SUFFIX: &str = ".ahab-bak"; const LOCAL_NAMESPACE: &str = "_local"; // move untracked paths out of the repo and symlink them back -pub fn add(paths: &[PathBuf], force: bool, store: Option<&Path>) -> Result<()> { - let repo = Repo::discover(store)?; +pub fn add(ctx: &Ctx, paths: &[PathBuf], force: bool, store: Option<&Path>) -> Result<()> { + let repo = Repo::discover(ctx, store)?; let report = Report::new(&repo); if let [path] = paths { - return link_one(&repo, path, force, &report); + return link_one(ctx, &repo, path, force, &report); } let mut failed = 0; for path in paths { - if let Err(e) = link_one(&repo, path, force, &report) { + if let Err(e) = link_one(ctx, &repo, path, force, &report) { eprintln!("error: {e:#}"); failed += 1; } @@ -36,8 +39,8 @@ pub fn add(paths: &[PathBuf], force: bool, store: Option<&Path>) -> Result<()> { } // move paths in the store back into the repo, the inverse of add -pub fn restore(paths: &[PathBuf], all: bool, store: Option<&Path>) -> Result<()> { - let repo = Repo::discover(store)?; +pub fn restore(ctx: &Ctx, paths: &[PathBuf], all: bool, store: Option<&Path>) -> Result<()> { + let repo = Repo::discover(ctx, store)?; let report = Report::new(&repo); let paths = match (all, paths) { @@ -179,19 +182,20 @@ 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( + ctx: &Ctx, paths: &[PathBuf], porcelain: bool, null: bool, exit_code: bool, store: Option<&Path>, ) -> Result { - let repo = Repo::discover(store)?; + 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(&repo, ignored, &pathspecs)? { + 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/` @@ -392,30 +396,19 @@ fn relative_pathspecs(repo: &Repo, paths: &[PathBuf]) -> Result> { Ok(specs) } -fn list_others(repo: &Repo, ignored: bool, pathspecs: &[PathBuf]) -> Result> { - let mut cmd = Command::new("git"); - cmd.arg("-C").arg(&repo.root).args([ - "ls-files", - "-z", - "--others", - "--exclude-standard", - "--directory", - "--no-empty-directory", - ]); +fn list_others( + ctx: &Ctx, + repo: &Repo, + ignored: bool, + pathspecs: &[PathBuf], +) -> Result> { + let mut listing = LsFiles::untracked(&repo.root).limited_to(pathspecs); if ignored { - cmd.arg("--ignored"); - } - cmd.arg("--").args(pathspecs); - - let out = cmd.output().context("running git ls-files")?; - if !out.status.success() { - return Err(anyhow!( - "git ls-files failed: {}", - String::from_utf8_lossy(&out.stderr).trim() - )); + listing = listing.ignored(); } - Ok(String::from_utf8(out.stdout)? + Ok(listing + .capture(ctx)? .split('\0') .filter(|p| !p.is_empty()) .map(String::from) @@ -430,15 +423,15 @@ struct Repo { } impl Repo { - fn discover(store: Option<&Path>) -> Result { - let root = git_root()?; + fn discover(ctx: &Ctx, store: Option<&Path>) -> Result { + let root = git_root(ctx)?; let base = match store { Some(store) => store.to_path_buf(), None => store_root()?, }; Ok(Self { - store: base.join(repo_components(&root)?), + store: base.join(repo_components(ctx, &root)?), root, base, }) @@ -466,7 +459,7 @@ impl Repo { } } -fn link_one(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 rel = repo.relative(&src)?; let target = repo.store.join(&rel); @@ -479,13 +472,13 @@ fn link_one(repo: &Repo, path: &Path, force: bool, report: &Report) -> Result<() )); } - if tracked(repo, &rel)? { + if tracked(ctx, repo, &rel)? { return Err(anyhow!( "{} is tracked by git; only untracked or ignored paths can be externalized", rel.display() )); } - if !ignored(repo, &rel) { + if !ignored(ctx, repo, &rel) { warn(format!("{} is not gitignored", rel.display())); } @@ -601,17 +594,12 @@ fn resolve(path: &Path) -> Result { Ok(parent.join(name)) } -fn git_root() -> Result { - let out = Command::new("git") - .args(["rev-parse", "--show-toplevel"]) - .output() - .context("running git")?; +fn git_root(ctx: &Ctx) -> Result { + let root = RevParse + .capture(ctx) + .map_err(|_| anyhow!("not inside a git repository"))?; - if !out.status.success() { - return Err(anyhow!("not inside a git repository")); - } - - let root = String::from_utf8(out.stdout)?.trim().to_string(); + let root = root.trim().to_string(); if root.is_empty() { return Err(anyhow!("git reported an empty repository root")); } @@ -619,8 +607,8 @@ fn git_root() -> Result { fs::canonicalize(&root).with_context(|| format!("resolving {root}")) } -fn repo_components(root: &Path) -> Result { - if let Some(url) = git_origin_url() { +fn repo_components(ctx: &Ctx, root: &Path) -> Result { + if let Some(url) = git_origin_url(ctx) { if let Some(components) = components_from_remote(&url) { return Ok(components); } @@ -633,16 +621,14 @@ fn repo_components(root: &Path) -> Result { Ok(Path::new(LOCAL_NAMESPACE).join(sanitize(&name.to_string_lossy()))) } -fn git_origin_url() -> Option { - let out = Command::new("git") - .args(["config", "--get", "remote.origin.url"]) - .output() - .ok()?; - if !out.status.success() { - return None; +fn git_origin_url(ctx: &Ctx) -> Option { + let url = ConfigGet { + key: "remote.origin.url", } + .capture(ctx) + .ok()?; - let url = String::from_utf8(out.stdout).ok()?.trim().to_string(); + let url = url.trim().to_string(); (!url.is_empty()).then_some(url) } @@ -706,32 +692,18 @@ fn non_empty_var(name: &str) -> Option { env::var_os(name).filter(|v| !v.is_empty()) } -fn tracked(repo: &Repo, rel: &Path) -> Result { - let out = Command::new("git") - .arg("-C") - .arg(&repo.root) - .args(["ls-files", "--cached", "--"]) - .arg(rel) - .output() - .context("running git ls-files")?; +fn tracked(ctx: &Ctx, repo: &Repo, rel: &Path) -> Result { + let listed = LsFiles::tracked(&repo.root) + .limited_to(&[rel]) + .capture(ctx)?; - if !out.status.success() { - return Err(anyhow!( - "git ls-files failed: {}", - String::from_utf8_lossy(&out.stderr).trim() - )); - } - Ok(!out.stdout.is_empty()) + Ok(!listed.is_empty()) } -fn ignored(repo: &Repo, rel: &Path) -> bool { - Command::new("git") - .arg("-C") - .arg(&repo.root) - .args(["check-ignore", "-q", "--"]) - .arg(rel) - .status() - .is_ok_and(|s| s.success()) +fn ignored(ctx: &Ctx, repo: &Repo, rel: &Path) -> bool { + CheckIgnore::new(&repo.root, rel) + .quietly_succeeds(ctx) + .unwrap_or(false) } fn symlink_metadata_opt(path: &Path) -> Result> { From a3c08f6eaa407f0daf0d8bdef4850001397b700d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Jane=C5=BEi=C4=8D?= Date: Tue, 8 Sep 2026 12:04:51 +0000 Subject: [PATCH 5/6] refactor: put every filesystem write behind one module --- Cargo.lock | 16 ++++ Cargo.toml | 1 + src/cli/ahab.rs | 56 +------------- src/cli/mod.rs | 4 +- src/fsops.rs | 165 ++++++++++++++++++++++++++++++++++++++++ src/main.rs | 9 ++- src/output.rs | 11 +++ src/scripts/django.rs | 43 ++++------- src/scripts/link.rs | 150 ++++++------------------------------ src/scripts/postgres.rs | 42 ++++------ 10 files changed, 261 insertions(+), 236 deletions(-) create mode 100644 src/fsops.rs create mode 100644 src/output.rs diff --git a/Cargo.lock b/Cargo.lock index 2a63163..9c143d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9,6 +9,7 @@ dependencies = [ "anyhow", "clap", "clap_complete", + "fs-err", "serde_json", "shlex", ] @@ -69,6 +70,12 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + [[package]] name = "clap" version = "4.6.6" @@ -124,6 +131,15 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "fs-err" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b91aa448ca50d7e79433bdf3ee8d99215430d2ec02ade5aefab2a073a1822e8a" +dependencies = [ + "autocfg", +] + [[package]] name = "heck" version = "0.5.0" diff --git a/Cargo.toml b/Cargo.toml index 0cc0a79..a574fca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ clap = { version = "4.6.6", features = ["derive", "env"] } clap_complete = "4.6.9" anyhow = "1.0.104" serde_json = "1.0.145" +fs-err = "3.3.1" shlex = "2.0.1" [build-dependencies] diff --git a/src/cli/ahab.rs b/src/cli/ahab.rs index 38d443a..e1f5feb 100644 --- a/src/cli/ahab.rs +++ b/src/cli/ahab.rs @@ -1,7 +1,7 @@ use super::{Django, Link, Postgres}; use clap::builder::styling::{AnsiColor, Effects, Styles}; -use clap::error::ErrorKind; -use clap::{CommandFactory, Parser, Subcommand}; + +use clap::{Parser, Subcommand}; use clap_complete::Shell; /// A program for interacting with various dockerized applications @@ -56,63 +56,13 @@ fn help_styles() -> Styles { .placeholder(AnsiColor::Cyan.on_default()) } -/// Exit with a usage error when `--dry-run` would be a lie -pub fn reject_unsupported_dry_run(command: &Commands) { - if let Some(name) = writes_locally(command) { - Ahab::command() - .error( - ErrorKind::ArgumentConflict, - format!("--dry-run is not supported by `{name}`, which writes to the working tree"), - ) - .exit() - } -} - -// TODO:(@janezicmatej) honour --dry-run in these commands instead of refusing it -fn writes_locally(command: &Commands) -> Option<&'static str> { - match command { - Commands::Link { command } => match command { - Link::Add { .. } => Some("link add"), - Link::Restore { .. } => Some("link restore"), - Link::Check { .. } => None, - }, - Commands::Django { command } => match command { - Django::MakeCommand { .. } => Some("django make-command"), - _ => None, - }, - Commands::Postgres { .. } | Commands::Completions { .. } => None, - } -} - #[cfg(test)] mod tests { use super::Ahab; - use clap::{CommandFactory, Parser}; + use clap::CommandFactory; #[test] fn the_command_is_well_formed() { Ahab::command().debug_assert(); } - - #[test] - fn dry_run_is_refused_only_where_it_cannot_be_honoured() { - let command = |args: &[&str]| Ahab::try_parse_from(args).unwrap().command; - - assert_eq!( - super::writes_locally(&command(&["ahab", "link", "add", ".env"])), - Some("link add") - ); - assert_eq!( - super::writes_locally(&command(&["ahab", "django", "make-command", "app", "name"])), - Some("django make-command") - ); - assert_eq!( - super::writes_locally(&command(&["ahab", "link", "check"])), - None - ); - assert_eq!( - super::writes_locally(&command(&["ahab", "postgres", "dump", "out.sql"])), - None - ); - } } diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 0e96624..72ef580 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,9 +1,11 @@ +// build.rs reaches these definitions with include!, so nothing here may refer to +// the rest of the crate: keep this tree to clap definitions only mod ahab; mod django; mod link; mod postgres; -pub use ahab::{Ahab, Commands, reject_unsupported_dry_run}; +pub use ahab::{Ahab, Commands}; pub use django::Django; pub use link::Link; pub use postgres::{Format, Postgres}; diff --git a/src/fsops.rs b/src/fsops.rs new file mode 100644 index 0000000..9edbd98 --- /dev/null +++ b/src/fsops.rs @@ -0,0 +1,165 @@ +use std::io::Write; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +// std::fs with the path and the operation already in the error +use fs_err as fs; +use fs_err::os::unix::fs::symlink; + +use crate::ctx::Ctx; + +pub fn suffixed(path: &Path, suffix: &str) -> PathBuf { + let mut out = path.as_os_str().to_owned(); + out.push(suffix); + + PathBuf::from(out) +} + +// every write to the working tree goes through this module, so a dry run is held +// back in one place rather than at each call site +pub fn move_path(ctx: &Ctx, src: &Path, target: &Path) -> Result<()> { + if ctx.dry_run { + return Ok(()); + } + + ensure_parent(ctx, target)?; + + // rename cannot cross filesystems, and the store often is another one + match fs::rename(src, target) { + Ok(()) => Ok(()), + Err(rename_err) => match copy_recursive(src, target) { + Ok(()) => remove_recursive(ctx, src), + Err(copy_err) => Err(copy_err).with_context(|| format!("after {rename_err}")), + }, + } +} + +pub fn rename(ctx: &Ctx, src: &Path, target: &Path) -> Result<()> { + if ctx.dry_run { + return Ok(()); + } + + Ok(fs::rename(src, target)?) +} + +pub fn place_link(ctx: &Ctx, link_path: &Path, target: &Path) -> Result<()> { + if ctx.dry_run { + return Ok(()); + } + + // symlink under a temp name and rename over the path: the rename is atomic + let tmp = suffixed(link_path, ".ahab-tmp"); + let _ = fs::remove_file(&tmp); + + symlink(target, &tmp)?; + Ok(fs::rename(&tmp, link_path)?) +} + +pub fn remove_file(ctx: &Ctx, path: &Path) -> Result<()> { + if ctx.dry_run { + return Ok(()); + } + + Ok(fs::remove_file(path)?) +} + +// whatever is there, file, directory or symlink +fn remove_recursive(ctx: &Ctx, path: &Path) -> Result<()> { + if ctx.dry_run { + return Ok(()); + } + + if fs::symlink_metadata(path)?.is_dir() { + fs::remove_dir_all(path)?; + } else { + fs::remove_file(path)?; + } + + Ok(()) +} + +fn ensure_parent(ctx: &Ctx, target: &Path) -> Result<()> { + if ctx.dry_run { + return Ok(()); + } + + match target.parent() { + Some(parent) if !parent.as_os_str().is_empty() => Ok(fs::create_dir_all(parent)?), + _ => Ok(()), + } +} + +pub fn create_dir(ctx: &Ctx, path: &Path) -> Result<()> { + if ctx.dry_run { + return Ok(()); + } + + Ok(fs::create_dir(path)?) +} + +// created if it is missing, left as it is otherwise, which is what a caller +// making an empty __init__.py wants +pub fn touch(ctx: &Ctx, path: &Path) -> Result<()> { + if ctx.dry_run { + return Ok(()); + } + + fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(false) + .open(path)?; + + Ok(()) +} + +// fails rather than writing over a file that is already there +pub fn write_new(ctx: &Ctx, path: &Path, contents: &[u8]) -> Result<()> { + if ctx.dry_run { + return Ok(()); + } + + let mut file = fs::File::create_new(path)?; + file.write_all(contents)?; + + Ok(()) +} + +// a restored path can leave the store holding nothing but empty directories +pub fn prune_empty(ctx: &Ctx, dir: Option<&Path>, stop: &Path) { + if ctx.dry_run { + return; + } + + let mut dir = dir; + + while let Some(path) = dir { + if path == stop || !path.starts_with(stop) || fs::remove_dir(path).is_err() { + return; + } + + dir = path.parent(); + } +} + +fn copy_recursive(src: &Path, target: &Path) -> Result<()> { + let meta = fs::symlink_metadata(src)?; + + if meta.is_dir() { + fs::create_dir_all(target)?; + for entry in fs::read_dir(src)? { + let entry = entry?; + copy_recursive(&entry.path(), &target.join(entry.file_name()))?; + } + + return Ok(()); + } + + if meta.is_symlink() { + return Ok(symlink(fs::read_link(src)?, target)?); + } + + fs::copy(src, target)?; + + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index 4db2ecc..b6e8934 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,12 +4,15 @@ mod cli; mod cmd; mod compose; mod ctx; +mod fsops; +mod output; mod scripts; use anyhow::Result; use clap::Parser; use crate::ctx::Ctx; +use crate::output::note; fn main() -> ExitCode { let args = cli::Ahab::parse(); @@ -19,8 +22,10 @@ fn main() -> ExitCode { dry_run: args.dry_run, }; + // said once here rather than by each command, so every line that follows + // reads as the plan it is if ctx.dry_run { - cli::reject_unsupported_dry_run(&args.command); + note!("dry run, nothing will be changed"); } match run(&ctx, args.command) { @@ -41,7 +46,7 @@ fn run(ctx: &Ctx, command: cli::Commands) -> Result { cli::Django::Bash => scripts::django::bash(ctx), cli::Django::Run { rest } => scripts::django::run(ctx, &rest), cli::Django::MakeCommand { app, name } => { - scripts::django::make_command(&app, &name) + scripts::django::make_command(ctx, &app, &name) } cli::Django::Makemigrations => scripts::django::makemigrations(ctx), cli::Django::Manage { rest } => scripts::django::manage(ctx, &rest), diff --git a/src/output.rs b/src/output.rs new file mode 100644 index 0000000..7ef0543 --- /dev/null +++ b/src/output.rs @@ -0,0 +1,11 @@ +// progress and warnings go to stderr, so what a caller pipes is only ever the +// data a command was asked for +macro_rules! note { + ($($arg:tt)*) => { eprintln!($($arg)*) }; +} + +macro_rules! warning { + ($($arg:tt)*) => { eprintln!("warning: {}", format_args!($($arg)*)) }; +} + +pub(crate) use {note, warning}; diff --git a/src/scripts/django.rs b/src/scripts/django.rs index 0170839..488e56c 100644 --- a/src/scripts/django.rs +++ b/src/scripts/django.rs @@ -1,5 +1,3 @@ -use std::fs::{File, OpenOptions, create_dir}; -use std::io::Write; use std::path::{Path, PathBuf}; use anyhow::{Result, anyhow}; @@ -7,6 +5,8 @@ use anyhow::{Result, anyhow}; use crate::cmd::{Bash, Cmd, Manage, Words}; use crate::compose::Compose; use crate::ctx::Ctx; +use crate::fsops::{create_dir, touch, write_new}; +use crate::output::note; const DEBUG_TEMPLATE: &str = r#"from django.core.management.base import BaseCommand @@ -16,7 +16,7 @@ class Command(BaseCommand): "#; -pub fn make_command(app: &PathBuf, name: &str) -> Result<()> { +pub fn make_command(ctx: &Ctx, app: &PathBuf, name: &str) -> Result<()> { let app_name = app.to_string_lossy(); let app_dir = Path::new(&app); @@ -25,32 +25,35 @@ pub fn make_command(app: &PathBuf, name: &str) -> Result<()> { return Err(anyhow!("directory {app_name} does not exist")); } - eprintln!("found app {app_name}"); + note!("found app {app_name}"); let management_dir = app_dir.join("management"); let not_management_exists = !management_dir.exists(); if not_management_exists { - create_dir(&management_dir)?; - create_file(management_dir.join("__init__.py"))?; + create_dir(ctx, &management_dir)?; + touch(ctx, &management_dir.join("__init__.py"))?; - eprintln!("created module {app_name}.management") + note!("created module {app_name}.management") }; let commands_dir = management_dir.join("commands"); let not_commands_exists = !commands_dir.exists(); if not_commands_exists { - create_dir(&commands_dir)?; - create_file(commands_dir.join("__init__.py"))?; + create_dir(ctx, &commands_dir)?; + touch(ctx, &commands_dir.join("__init__.py"))?; - eprintln!("created module {app_name}.management.commands") + note!("created module {app_name}.management.commands") }; - let mut file = safe_create_file(commands_dir.join(format!("{name}.py")))?; - file.write_all(DEBUG_TEMPLATE.as_bytes())?; + write_new( + ctx, + &commands_dir.join(format!("{name}.py")), + DEBUG_TEMPLATE.as_bytes(), + )?; - eprintln!("created command {app_name}.management.commands.{name}"); + note!("created command {app_name}.management.commands.{name}"); Ok(()) } @@ -89,17 +92,3 @@ pub fn test(ctx: &Ctx) -> Result<()> { fn service(ctx: &Ctx) -> Result { Compose::resolve(ctx)?.django() } - -fn safe_create_file(path: PathBuf) -> Result { - OpenOptions::new().write(true).create_new(true).open(path) -} - -// truncate(false) keeps an existing file's contents, which is what callers creating -// an empty __init__.py want -fn create_file(path: PathBuf) -> Result { - OpenOptions::new() - .write(true) - .create(true) - .truncate(false) - .open(path) -} diff --git a/src/scripts/link.rs b/src/scripts/link.rs index f1c402c..1ca306b 100644 --- a/src/scripts/link.rs +++ b/src/scripts/link.rs @@ -2,7 +2,6 @@ use std::cell::Cell; use std::env; use std::ffi::OsString; use std::fs; -use std::os::unix::fs::symlink; use std::path::{Path, PathBuf}; use std::process::ExitCode; @@ -11,6 +10,8 @@ use anyhow::{Context, Result, anyhow, bail}; use crate::cmd::{CheckIgnore, Cmd, ConfigGet, LsFiles, RevParse}; use crate::ctx::Ctx; +use crate::fsops::{move_path, place_link, prune_empty, remove_file, rename, suffixed}; +use crate::output::{note, warning}; const BACKUP_SUFFIX: &str = ".ahab-bak"; const LOCAL_NAMESPACE: &str = "_local"; @@ -27,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) { - eprintln!("error: {e:#}"); + note!("error: {e:#}"); failed += 1; } } @@ -51,18 +52,18 @@ pub fn restore(ctx: &Ctx, paths: &[PathBuf], all: bool, store: Option<&Path>) -> }; if paths.is_empty() { - eprintln!("nothing in the store for this repository"); + note!("nothing in the store for this repository"); return Ok(()); } if let [path] = paths.as_slice() { - return restore_one(&repo, path, &report); + return restore_one(ctx, &repo, path, &report); } let mut failed = 0; for path in &paths { - if let Err(e) = restore_one(&repo, path, &report) { - eprintln!("error: {e:#}"); + if let Err(e) = restore_one(ctx, &repo, path, &report) { + note!("error: {e:#}"); failed += 1; } } @@ -73,7 +74,7 @@ pub fn restore(ctx: &Ctx, paths: &[PathBuf], all: bool, store: Option<&Path>) -> Ok(()) } -fn restore_one(repo: &Repo, path: &Path, report: &Report) -> Result<()> { +fn restore_one(ctx: &Ctx, repo: &Repo, path: &Path, report: &Report) -> Result<()> { let src = resolve(path)?; let rel = repo.relative(&src)?; let stored = repo.store.join(&rel); @@ -100,9 +101,9 @@ fn restore_one(repo: &Repo, path: &Path, report: &Report) -> Result<()> { bail!("{} is missing from the store", rel.display()); } - fs::remove_file(&src).with_context(|| format!("removing {}", src.display()))?; - move_path(&stored, &src)?; - prune_empty(stored.parent(), &repo.base); + remove_file(ctx, &src)?; + move_path(ctx, &stored, &src)?; + prune_empty(ctx, stored.parent(), &repo.base); report.line("restored", &rel); Ok(()) @@ -140,19 +141,6 @@ fn linked_paths(repo: &Repo, dir: &Path) -> Result> { Ok(found) } -// a restored path can leave the store holding nothing but empty directories -fn prune_empty(dir: Option<&Path>, stop: &Path) { - let mut dir = dir; - - while let Some(path) = dir { - if path == stop || !path.starts_with(stop) || fs::remove_dir(path).is_err() { - return; - } - - dir = path.parent(); - } -} - struct Report { store: PathBuf, named: Cell, @@ -176,10 +164,6 @@ impl Report { } } -fn warn(msg: impl std::fmt::Display) { - eprintln!("warning: {msg}"); -} - // list untracked paths not in the store, i.e. what a sandbox can still read pub fn check( ctx: &Ctx, @@ -479,7 +463,7 @@ fn link_one(ctx: &Ctx, repo: &Repo, path: &Path, force: bool, report: &Report) - )); } if !ignored(ctx, repo, &rel) { - warn(format!("{} is not gitignored", rel.display())); + warning!("{} is not gitignored", rel.display()); } let src_meta = symlink_metadata_opt(&src)?; @@ -505,14 +489,14 @@ fn link_one(ctx: &Ctx, repo: &Repo, path: &Path, force: bool, report: &Report) - // nothing in the store to adopt, so the symlink itself moves out if !target_taken { if !src.exists() { - warn(format!( + warning!( "{} is a broken symlink to {}", rel.display(), dest.display() - )); + ); } - move_path(&src, &target)?; - place_link(&src, &target)?; + move_path(ctx, &src, &target)?; + place_link(ctx, &src, &target)?; report.line("moved", &rel); return Ok(()); } @@ -521,7 +505,7 @@ fn link_one(ctx: &Ctx, repo: &Repo, path: &Path, force: bool, report: &Report) - return Err(needs_force(&target)); } - place_link(&src, &target)?; + place_link(ctx, &src, &target)?; report.line("repointed", &rel); Ok(()) } @@ -539,18 +523,17 @@ fn link_one(ctx: &Ctx, repo: &Repo, path: &Path, force: bool, report: &Report) - )); } - fs::rename(&src, &backup) - .with_context(|| format!("renaming {} to {}", src.display(), backup.display()))?; + rename(ctx, &src, &backup)?; report.line("saved", &suffixed(&rel, BACKUP_SUFFIX)); - place_link(&src, &target)?; + place_link(ctx, &src, &target)?; report.line("linked", &rel); Ok(()) } Some(_) => { - move_path(&src, &target)?; - place_link(&src, &target)?; + move_path(ctx, &src, &target)?; + place_link(ctx, &src, &target)?; report.line("moved", &rel); Ok(()) } @@ -559,7 +542,7 @@ fn link_one(ctx: &Ctx, repo: &Repo, path: &Path, force: bool, report: &Report) - if !force { return Err(needs_force(&target)); } - place_link(&src, &target)?; + place_link(ctx, &src, &target)?; report.line("linked", &rel); Ok(()) } @@ -612,7 +595,7 @@ fn repo_components(ctx: &Ctx, root: &Path) -> Result { if let Some(components) = components_from_remote(&url) { return Ok(components); } - eprintln!("could not parse git remote `{url}`, falling back to the checkout name"); + note!("could not parse git remote `{url}`, falling back to the checkout name"); } let name = root @@ -715,93 +698,6 @@ fn symlink_metadata_opt(path: &Path) -> Result> { } } -fn move_path(src: &Path, target: &Path) -> Result<()> { - ensure_parent(target)?; - - // rename cannot cross filesystems, and the store often is another one - match fs::rename(src, target) { - Ok(()) => Ok(()), - Err(rename_err) => match copy_recursive(src, target) { - Ok(()) => remove_recursive(src), - Err(copy_err) => Err(copy_err).with_context(|| { - format!( - "moving {} to {} (rename failed: {rename_err})", - src.display(), - target.display() - ) - }), - }, - } -} - -fn copy_recursive(src: &Path, target: &Path) -> Result<()> { - let meta = - fs::symlink_metadata(src).with_context(|| format!("inspecting {}", src.display()))?; - - if meta.is_dir() { - fs::create_dir_all(target) - .with_context(|| format!("creating directory {}", target.display()))?; - for entry in - fs::read_dir(src).with_context(|| format!("reading directory {}", src.display()))? - { - let entry = entry?; - copy_recursive(&entry.path(), &target.join(entry.file_name()))?; - } - return Ok(()); - } - - if meta.is_symlink() { - let dest = fs::read_link(src)?; - return symlink(&dest, target) - .with_context(|| format!("creating symlink {}", target.display())); - } - - fs::copy(src, target) - .with_context(|| format!("copying {} to {}", src.display(), target.display()))?; - Ok(()) -} - -fn remove_recursive(path: &Path) -> Result<()> { - let meta = - fs::symlink_metadata(path).with_context(|| format!("inspecting {}", path.display()))?; - - if meta.is_dir() { - fs::remove_dir_all(path) - } else { - fs::remove_file(path) - } - .with_context(|| format!("removing {}", path.display())) -} - -fn ensure_parent(target: &Path) -> Result<()> { - if let Some(parent) = target.parent() { - if !parent.as_os_str().is_empty() { - fs::create_dir_all(parent) - .with_context(|| format!("creating directory {}", parent.display()))?; - } - } - Ok(()) -} - -fn place_link(link_path: &Path, target: &Path) -> Result<()> { - // symlink under a temp name and rename over the path: the rename is atomic - let tmp = suffixed(link_path, ".ahab-tmp"); - let _ = fs::remove_file(&tmp); - - symlink(target, &tmp) - .with_context(|| format!("creating symlink {} -> {}", tmp.display(), target.display()))?; - fs::rename(&tmp, link_path) - .with_context(|| format!("replacing {} with a symlink", link_path.display()))?; - - Ok(()) -} - -fn suffixed(path: &Path, suffix: &str) -> PathBuf { - let mut out = path.as_os_str().to_owned(); - out.push(suffix); - PathBuf::from(out) -} - #[cfg(test)] mod tests { use super::{components_from_remote, sanitize}; diff --git a/src/scripts/postgres.rs b/src/scripts/postgres.rs index dbf7506..7e5bde4 100644 --- a/src/scripts/postgres.rs +++ b/src/scripts/postgres.rs @@ -1,8 +1,8 @@ use anyhow::{Context, Result, anyhow, bail}; use std::{ - fs::{self, File}, + fs::File, io::{self, Read, Write}, - path::{Path, PathBuf}, + path::Path, process::Stdio, thread, time::{Duration, Instant}, @@ -15,6 +15,8 @@ use crate::cmd::{ }; use crate::compose::Compose; use crate::ctx::Ctx; +use crate::fsops::{remove_file, rename, suffixed}; +use crate::output::note; const CUSTOM_MAGIC: &[u8] = b"PGDMP"; const TAR_MAGIC: &[u8] = b"toc.dat"; @@ -142,11 +144,11 @@ fn restore_cluster(ctx: &Ctx, db: &Database, restore: &dyn Cmd, file: &Path) -> continue; } - eprintln!("{line}"); + note!("{line}"); } if existing > 0 { - eprintln!( + note!( "left {existing} existing role{} alone", if existing == 1 { "" } else { "s" } ); @@ -159,13 +161,6 @@ fn restore_cluster(ctx: &Ctx, db: &Database, restore: &dyn Cmd, file: &Path) -> 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]; @@ -218,10 +213,10 @@ pub fn import(ctx: &Ctx, file: &Path) -> Result<()> { let dump = Dump::of(file)?; let db = Database::resolve(ctx)?; - eprintln!("stopping all containers"); + note!("stopping all containers"); Stop.run(ctx)?; - eprintln!("starting db container"); + note!("starting db container"); Start::service(&db.service).run(ctx)?; let remote = remote_dump(); @@ -271,7 +266,7 @@ pub fn import(ctx: &Ctx, file: &Path) -> Result<()> { }; let tool = kind.tool(); - eprintln!("restoring database with {tool}"); + note!("restoring database with {tool}"); when_ready( ctx, @@ -299,7 +294,7 @@ pub fn import(ctx: &Ctx, file: &Path) -> Result<()> { wait_until_ready(ctx, &db)?; if ctx.dry_run { - eprintln!("would restore with {tool}"); + note!("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 @@ -341,14 +336,14 @@ pub fn import(ctx: &Ctx, file: &Path) -> Result<()> { let _ = Rm::recursive(&remote).in_container(&db.container).run(ctx); } - eprintln!("restarting containers"); + note!("restarting containers"); Stop.run(ctx)?; Up.run(ctx)?; Ok(()) } -pub fn dump(ctx: &Ctx, file: &PathBuf, format: Format, gzip: bool) -> Result<()> { +pub fn dump(ctx: &Ctx, file: &Path, format: Format, gzip: bool) -> Result<()> { let db = Database::resolve(ctx)?; if format == Format::Directory { @@ -359,7 +354,7 @@ pub fn dump(ctx: &Ctx, file: &PathBuf, format: Format, gzip: bool) -> Result<()> return dump_directory(ctx, &db, file); } - eprintln!("dumping to local file {}", file.to_string_lossy()); + note!("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 @@ -385,16 +380,11 @@ pub fn dump(ctx: &Ctx, file: &PathBuf, format: Format, gzip: bool) -> Result<()> }; if let Err(e) = dumped { - let _ = fs::remove_file(&partial); + let _ = remove_file(ctx, &partial); return Err(e); } - if ctx.dry_run { - return Ok(()); - } - - fs::rename(&partial, file) - .with_context(|| format!("renaming {} to {}", partial.display(), file.display()))?; + rename(ctx, &partial, file)?; Ok(()) } @@ -417,7 +407,7 @@ fn dump_directory(ctx: &Ctx, db: &Database, target: &Path) -> Result<()> { ); } - eprintln!("dumping to local directory {}", target.display()); + note!("dumping to local directory {}", target.display()); let remote = remote_dump(); PgDump::new(&db.user, &db.name, "d") From bb0a2cca44af186fcdf83764a01ddb8ac524ee5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Jane=C5=BEi=C4=8D?= Date: Tue, 8 Sep 2026 12:10:41 +0000 Subject: [PATCH 6/6] refactor: name the modules after what they hold --- src/{scripts => commands}/completions.rs | 0 src/{scripts => commands}/django.rs | 13 +- src/commands/link.rs | 284 +++++++++ src/commands/link/check.rs | 243 ++++++++ src/commands/link/store.rs | 254 ++++++++ src/{scripts => commands}/mod.rs | 0 src/{scripts => commands}/postgres.rs | 215 +------ src/commands/postgres/server.rs | 85 +++ src/commands/postgres/shape.rs | 104 +++ src/main.rs | 32 +- src/{compose.rs => project.rs} | 10 +- src/scripts/link.rs | 763 ----------------------- 12 files changed, 1014 insertions(+), 989 deletions(-) rename src/{scripts => commands}/completions.rs (100%) rename src/{scripts => commands}/django.rs (87%) create mode 100644 src/commands/link.rs create mode 100644 src/commands/link/check.rs create mode 100644 src/commands/link/store.rs rename src/{scripts => commands}/mod.rs (100%) rename src/{scripts => commands}/postgres.rs (60%) create mode 100644 src/commands/postgres/server.rs create mode 100644 src/commands/postgres/shape.rs rename src/{compose.rs => project.rs} (97%) delete mode 100644 src/scripts/link.rs diff --git a/src/scripts/completions.rs b/src/commands/completions.rs similarity index 100% rename from src/scripts/completions.rs rename to src/commands/completions.rs diff --git a/src/scripts/django.rs b/src/commands/django.rs similarity index 87% rename from src/scripts/django.rs rename to src/commands/django.rs index 488e56c..704e200 100644 --- a/src/scripts/django.rs +++ b/src/commands/django.rs @@ -3,10 +3,10 @@ use std::path::{Path, PathBuf}; use anyhow::{Result, anyhow}; use crate::cmd::{Bash, Cmd, Manage, Words}; -use crate::compose::Compose; use crate::ctx::Ctx; use crate::fsops::{create_dir, touch, write_new}; use crate::output::note; +use crate::project::Project; const DEBUG_TEMPLATE: &str = r#"from django.core.management.base import BaseCommand @@ -20,8 +20,7 @@ pub fn make_command(ctx: &Ctx, app: &PathBuf, name: &str) -> Result<()> { let app_name = app.to_string_lossy(); let app_dir = Path::new(&app); - let not_app_exists = !app_dir.is_dir(); - if not_app_exists { + if !app_dir.is_dir() { return Err(anyhow!("directory {app_name} does not exist")); } @@ -29,8 +28,7 @@ pub fn make_command(ctx: &Ctx, app: &PathBuf, name: &str) -> Result<()> { let management_dir = app_dir.join("management"); - let not_management_exists = !management_dir.exists(); - if not_management_exists { + if !management_dir.exists() { create_dir(ctx, &management_dir)?; touch(ctx, &management_dir.join("__init__.py"))?; @@ -39,8 +37,7 @@ pub fn make_command(ctx: &Ctx, app: &PathBuf, name: &str) -> Result<()> { let commands_dir = management_dir.join("commands"); - let not_commands_exists = !commands_dir.exists(); - if not_commands_exists { + if !commands_dir.exists() { create_dir(ctx, &commands_dir)?; touch(ctx, &commands_dir.join("__init__.py"))?; @@ -90,5 +87,5 @@ pub fn test(ctx: &Ctx) -> Result<()> { } fn service(ctx: &Ctx) -> Result { - Compose::resolve(ctx)?.django() + Project::resolve(ctx)?.django() } diff --git a/src/commands/link.rs b/src/commands/link.rs new file mode 100644 index 0000000..d2a91df --- /dev/null +++ b/src/commands/link.rs @@ -0,0 +1,284 @@ +mod check; +mod store; + +pub use check::check; + +use fs_err::{read_dir, read_link}; +use std::cell::Cell; +use std::path::{Path, PathBuf}; + +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}; + +const BACKUP_SUFFIX: &str = ".ahab-bak"; + +// move untracked paths out of the repo and symlink them back +pub fn add(ctx: &Ctx, paths: &[PathBuf], force: bool, store: Option<&Path>) -> Result<()> { + let repo = Repo::discover(ctx, store)?; + let report = Report::new(&repo); + + if let [path] = paths { + return link_one(ctx, &repo, path, force, &report); + } + + let mut failed = 0; + for path in paths { + if let Err(e) = link_one(ctx, &repo, path, force, &report) { + note!("error: {e:#}"); + failed += 1; + } + } + + if failed > 0 { + return Err(anyhow!("{failed} of {} paths failed", paths.len())); + } + Ok(()) +} + +// move paths in the store back into the repo, the inverse of add +pub fn restore(ctx: &Ctx, paths: &[PathBuf], all: bool, store: Option<&Path>) -> Result<()> { + let repo = Repo::discover(ctx, store)?; + let report = Report::new(&repo); + + let paths = match (all, paths) { + (true, []) => linked_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(), + }; + + if paths.is_empty() { + note!("nothing in the store for this repository"); + return Ok(()); + } + + if let [path] = paths.as_slice() { + return restore_one(ctx, &repo, path, &report); + } + + let mut failed = 0; + for path in &paths { + if let Err(e) = restore_one(ctx, &repo, path, &report) { + note!("error: {e:#}"); + failed += 1; + } + } + + if failed > 0 { + return Err(anyhow!("{failed} of {} paths failed", paths.len())); + } + Ok(()) +} + +fn restore_one(ctx: &Ctx, repo: &Repo, path: &Path, report: &Report) -> Result<()> { + let src = resolve(path)?; + let rel = repo.relative(&src)?; + let stored = repo.store.join(&rel); + + let Some(meta) = symlink_metadata_opt(&src)? else { + bail!("{} does not exist", rel.display()); + }; + if !meta.is_symlink() { + bail!( + "{} is not a symlink, so it is not in the store", + rel.display() + ); + } + + let dest = read_link(&src)?; + if dest != stored { + bail!( + "{} points at {}, which is not where the store keeps it", + rel.display(), + dest.display() + ); + } + if symlink_metadata_opt(&stored)?.is_none() { + bail!("{} is missing from the store", rel.display()); + } + + remove_file(ctx, &src)?; + move_path(ctx, &stored, &src)?; + prune_empty(ctx, stored.parent(), &repo.base); + + report.line("restored", &rel); + Ok(()) +} + +// 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> { + let mut found = Vec::new(); + + let entries = match read_dir(dir) { + Ok(entries) => entries, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(found), + Err(e) => return Err(e.into()), + }; + + for entry in entries { + let stored = entry?.path(); + let rel = stored + .strip_prefix(&repo.store) + .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); + + if linked { + found.push(src); + } else if stored.is_dir() { + found.extend(linked_paths(repo, &stored)?); + } + } + + found.sort(); + Ok(found) +} + +struct Report { + store: PathBuf, + named: Cell, +} + +impl Report { + fn new(repo: &Repo) -> Self { + Self { + store: repo.store.clone(), + named: Cell::new(false), + } + } + + fn line(&self, verb: &str, path: &Path) { + // worth naming once per run + if !self.named.replace(true) { + println!("store: {}", self.store.display()); + } + + println!("\t{:<11}{}", format!("{verb}:"), path.display()); + } +} + +// 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)?; + let target = repo.store.join(&rel); + + // a target inside the repo would be readable from the sandbox anyway + if target.starts_with(&repo.root) { + return Err(anyhow!( + "target {} is inside the repository; point AHAB_LINK_ROOT elsewhere", + target.display() + )); + } + + if tracked(ctx, repo, &rel)? { + return Err(anyhow!( + "{} is tracked by git; only untracked or ignored paths can be externalized", + rel.display() + )); + } + if !ignored(ctx, repo, &rel) { + warning!("{} is not gitignored", rel.display()); + } + + let src_meta = symlink_metadata_opt(&src)?; + let target_taken = symlink_metadata_opt(&target)?.is_some(); + + match src_meta { + Some(meta) if meta.is_symlink() => { + let dest = read_link(&src)?; + + if dest == target { + if !target_taken { + return Err(anyhow!( + "{} already points at {}, but nothing is there", + rel.display(), + target.display() + )); + } + report.line("unchanged", &rel); + return Ok(()); + } + + // nothing in the store to adopt, so the symlink itself moves out + if !target_taken { + if !src.exists() { + warning!( + "{} is a broken symlink to {}", + rel.display(), + dest.display() + ); + } + move_path(ctx, &src, &target)?; + place_link(ctx, &src, &target)?; + report.line("moved", &rel); + return Ok(()); + } + + if !force { + return Err(needs_force(&target)); + } + + place_link(ctx, &src, &target)?; + report.line("repointed", &rel); + Ok(()) + } + + Some(_) if target_taken => { + if !force { + return Err(needs_force(&target)); + } + + let backup = suffixed(&src, BACKUP_SUFFIX); + if symlink_metadata_opt(&backup)?.is_some() { + return Err(anyhow!( + "{} already exists; remove it before re-linking", + backup.display() + )); + } + + rename(ctx, &src, &backup)?; + report.line("saved", &suffixed(&rel, BACKUP_SUFFIX)); + + place_link(ctx, &src, &target)?; + report.line("linked", &rel); + Ok(()) + } + + Some(_) => { + move_path(ctx, &src, &target)?; + place_link(ctx, &src, &target)?; + report.line("moved", &rel); + Ok(()) + } + + None if target_taken => { + if !force { + return Err(needs_force(&target)); + } + place_link(ctx, &src, &target)?; + report.line("linked", &rel); + Ok(()) + } + + None => Err(anyhow!( + "{} does not exist and the store has no {}", + rel.display(), + target.display() + )), + } +} + +fn needs_force(target: &Path) -> anyhow::Error { + anyhow!( + "{} already exists; pass --force to link to it", + target.display() + ) +} diff --git a/src/commands/link/check.rs b/src/commands/link/check.rs new file mode 100644 index 0000000..637431d --- /dev/null +++ b/src/commands/link/check.rs @@ -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 { + 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 ...\" to move them into the store)", + Section::Content(UNTRACKED), + ), + ( + "Ignored paths a sandbox can read:", + " (use \"ahab link add ...\" 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, +} + +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> { + 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)> { + 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> { + 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> { + 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()) +} diff --git a/src/commands/link/store.rs b/src/commands/link/store.rs new file mode 100644 index 0000000..5683c22 --- /dev/null +++ b/src/commands/link/store.rs @@ -0,0 +1,254 @@ +use fs_err as fs; +use std::env; +use std::ffi::OsString; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, anyhow}; + +use crate::cmd::{CheckIgnore, Cmd, ConfigGet, LsFiles, RevParse}; +use crate::ctx::Ctx; +use crate::output::note; + +// a checkout with no remote to name it after +const LOCAL_NAMESPACE: &str = "_local"; + +pub(super) struct Repo { + pub(super) root: PathBuf, + pub(super) store: PathBuf, + // the configured store root, above the per-repository directories + pub(super) base: PathBuf, +} + +impl Repo { + pub(super) fn discover(ctx: &Ctx, store: Option<&Path>) -> Result { + let root = git_root(ctx)?; + let base = match store { + Some(store) => store.to_path_buf(), + None => store_root()?, + }; + + Ok(Self { + store: base.join(repo_components(ctx, &root)?), + root, + base, + }) + } + pub(super) fn relative(&self, src: &Path) -> Result { + let rel = src.strip_prefix(&self.root).map_err(|_| { + anyhow!( + "{} is outside the repository {}", + src.display(), + self.root.display() + ) + })?; + + // empty means the whole repo + if rel.as_os_str().is_empty() { + return Err(anyhow!( + "refusing to externalize the repository root itself" + )); + } + if rel.starts_with(".git") { + return Err(anyhow!("refusing to externalize anything under .git")); + } + + Ok(rel.to_path_buf()) + } +} + +pub(super) fn resolve(path: &Path) -> Result { + let abs = std::path::absolute(path) + .with_context(|| format!("resolving absolute path of {}", path.display()))?; + + // the parent must exist so symlinked components resolve like git's toplevel + let Some(name) = abs.file_name().map(OsString::from) else { + return Ok(fs::canonicalize(&abs)?); + }; + let parent = fs::canonicalize(abs.parent().unwrap_or(Path::new("/")))?; + Ok(parent.join(name)) +} + +fn git_root(ctx: &Ctx) -> Result { + let root = RevParse + .capture(ctx) + .map_err(|_| anyhow!("not inside a git repository"))?; + + let root = root.trim().to_string(); + if root.is_empty() { + return Err(anyhow!("git reported an empty repository root")); + } + + Ok(fs::canonicalize(&root)?) +} + +fn repo_components(ctx: &Ctx, root: &Path) -> Result { + if let Some(url) = git_origin_url(ctx) { + if let Some(components) = components_from_remote(&url) { + return Ok(components); + } + note!("could not parse git remote `{url}`, falling back to the checkout name"); + } + + let name = root + .file_name() + .ok_or_else(|| anyhow!("cannot derive a store path for {}", root.display()))?; + Ok(Path::new(LOCAL_NAMESPACE).join(sanitize(&name.to_string_lossy()))) +} + +fn git_origin_url(ctx: &Ctx) -> Option { + let url = ConfigGet { + key: "remote.origin.url", + } + .capture(ctx) + .ok()?; + + let url = url.trim().to_string(); + (!url.is_empty()).then_some(url) +} + +fn components_from_remote(url: &str) -> Option { + let url = url.trim(); + let url = url.strip_suffix(".git").unwrap_or(url); + + // `scheme://[user@]host[:port]/path`, or scp-like `[user@]host:path` + let (authority, path) = match url.split_once("://") { + Some((_, after)) => after.split_once('/')?, + None => url.split_once(':')?, + }; + + let host = authority.rsplit_once('@').map_or(authority, |(_, h)| h); + let host = host.split_once(':').map_or(host, |(h, _)| h); + // a local remote has no host to key on + if host.is_empty() { + return None; + } + + let mut components = PathBuf::from(sanitize(&host.to_lowercase())); + let mut depth = 0; + for part in path.split('/').filter(|p| !p.is_empty()) { + components.push(sanitize(part)); + depth += 1; + } + (depth > 0).then_some(components) +} + +fn sanitize(s: &str) -> String { + let out: String = s + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || matches!(c, '-' | '.' | '_') { + c + } else { + '_' + } + }) + .collect(); + + // `.` and `..` are legal characters but not legal components + if out.chars().all(|c| c == '.') { + return "_".repeat(out.len()); + } + out +} + +fn store_root() -> Result { + if let Some(xdg) = non_empty_var("XDG_DATA_HOME") { + return Ok(PathBuf::from(xdg).join("ahab")); + } + + let home = non_empty_var("HOME") + .filter(|v| !v.is_empty()) + .ok_or_else(|| anyhow!("neither XDG_DATA_HOME nor HOME is set"))?; + + Ok(PathBuf::from(home).join(".local/share/ahab")) +} +fn non_empty_var(name: &str) -> Option { + env::var_os(name).filter(|v| !v.is_empty()) +} + +pub(super) fn tracked(ctx: &Ctx, repo: &Repo, rel: &Path) -> Result { + let listed = LsFiles::tracked(&repo.root) + .limited_to(&[rel]) + .capture(ctx)?; + + Ok(!listed.is_empty()) +} + +pub(super) fn ignored(ctx: &Ctx, repo: &Repo, rel: &Path) -> bool { + CheckIgnore::new(&repo.root, rel) + .quietly_succeeds(ctx) + .unwrap_or(false) +} + +pub(super) fn symlink_metadata_opt(path: &Path) -> Result> { + // symlink_metadata does not follow the link, so a symlink shows as one + match fs::symlink_metadata(path) { + Ok(meta) => Ok(Some(meta)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e.into()), + } +} + +#[cfg(test)] +mod tests { + use super::{components_from_remote, sanitize}; + use std::path::PathBuf; + + #[test] + fn parses_every_spelling_of_a_remote() { + let cases = [ + ( + "git@git.aflabs.org:urnik/afurnik.git", + "git.aflabs.org/urnik/afurnik", + ), + ( + "https://git.aflabs.org/urnik/afurnik.git", + "git.aflabs.org/urnik/afurnik", + ), + ( + "https://git.aflabs.org/urnik/afurnik", + "git.aflabs.org/urnik/afurnik", + ), + ( + "https://git.aflabs.org/urnik/afurnik/", + "git.aflabs.org/urnik/afurnik", + ), + ( + "ssh://git@git.aflabs.org:22/urnik/afurnik.git", + "git.aflabs.org/urnik/afurnik", + ), + ( + "git@GIT.Aflabs.org:urnik/AFurnik.git", + "git.aflabs.org/urnik/AFurnik", + ), + ( + "git@git.aflabs.org:urnik/internal/afurnik.git", + "git.aflabs.org/urnik/internal/afurnik", + ), + ]; + + for (url, want) in cases { + assert_eq!( + components_from_remote(url), + Some(PathBuf::from(want)), + "url: {url}" + ); + } + } + + #[test] + fn rejects_remotes_without_a_host() { + assert_eq!(components_from_remote("not-a-url"), None); + assert_eq!(components_from_remote("/srv/git/afurnik.git"), None); + assert_eq!(components_from_remote("file:///srv/git/afurnik.git"), None); + assert_eq!(components_from_remote("https://git.aflabs.org/"), None); + } + + #[test] + fn sanitize_never_yields_a_traversal() { + assert_eq!(sanitize(".."), "__"); + assert_eq!(sanitize("."), "_"); + assert_eq!(sanitize("a/b"), "a_b"); + assert_eq!(sanitize(".env"), ".env"); + } +} diff --git a/src/scripts/mod.rs b/src/commands/mod.rs similarity index 100% rename from src/scripts/mod.rs rename to src/commands/mod.rs diff --git a/src/scripts/postgres.rs b/src/commands/postgres.rs similarity index 60% rename from src/scripts/postgres.rs rename to src/commands/postgres.rs index 7e5bde4..3ef52cf 100644 --- a/src/scripts/postgres.rs +++ b/src/commands/postgres.rs @@ -1,129 +1,30 @@ -use anyhow::{Context, Result, anyhow, bail}; -use std::{ - fs::File, - io::{self, Read, Write}, - path::Path, - process::Stdio, - thread, - time::{Duration, Instant}, -}; +mod server; +mod shape; +use fs_err::File; +use std::io::{self, Write}; +use std::path::Path; +use std::process::Stdio; + +use anyhow::{Context, Result, bail}; + +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, PgIsReady, PgRestore, Ps, - Psql, Rm, Start, Stop, Up, + Cmd, Cp, CreateDb, DropDb, Gunzip, Gzip, Head, PgDump, PgDumpAll, PgRestore, Rm, Start, Stop, + Up, }; -use crate::compose::Compose; use crate::ctx::Ctx; use crate::fsops::{remove_file, rename, suffixed}; use crate::output::note; -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; - // unique per run: docker cp will not copy a directory over an existing path, and // quietly leaves whatever was there for pg_restore to read instead fn remote_dump() -> String { format!("/tmp/ahab-dump-{}", std::process::id()) } -struct Database { - service: String, - container: String, - user: String, - name: String, -} - -impl Database { - fn resolve(ctx: &Ctx) -> Result { - let compose = Compose::resolve(ctx)?; - let service = compose.postgres()?; - let (user, name) = compose.postgres_credentials(&service); - - let container = Ps::id_of(&service).capture(ctx)?.trim().to_string(); - - if container.is_empty() { - return Err(anyhow!("service {service} has no running container")); - } - - Ok(Self { - service, - container, - user, - name, - }) - } - - // what reads this shape of dump back in - 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")), - } - } -} - -enum Dump { - Directory, - Gzip, - Header(Vec), -} - -impl Dump { - fn of(path: &Path) -> Result { - if path.is_dir() { - return Ok(Self::Directory); - } - - let header = read_header(path)?; - if header.starts_with(GZIP_MAGIC) { - return Ok(Self::Gzip); - } - - Ok(Self::Header(header)) - } -} - -// how the dump has to be fed back in: pg_restore for an archive, psql into the -// database for a single database dump, psql into postgres for a whole cluster -#[derive(Clone, Copy, PartialEq)] -enum Kind { - Archive, - Sql, - Cluster, -} - -impl Kind { - fn of(header: &[u8]) -> Self { - if header.starts_with(CUSTOM_MAGIC) || header.starts_with(TAR_MAGIC) { - return Self::Archive; - } - - if String::from_utf8_lossy(header).contains(CLUSTER_MARKER) { - return Self::Cluster; - } - - Self::Sql - } - - fn tool(&self) -> &'static str { - match self { - Self::Archive => "pg_restore", - Self::Sql | Self::Cluster => "psql", - } - } -} - -// pg_dumpall recreates roles the cluster already has, so this is the expected -// shape of a working restore rather than a problem fn is_existing_role_error(line: &str) -> bool { line.starts_with("ERROR:") && line.contains("role \"") && line.ends_with("already exists") } @@ -161,54 +62,6 @@ fn restore_cluster(ctx: &Ctx, db: &Database, restore: &dyn Cmd, file: &Path) -> Ok(()) } -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]; - - let read = file - .read(&mut header) - .with_context(|| format!("reading {}", path.display()))?; - header.truncate(read); - - Ok(header) -} - -fn wait_until_ready(ctx: &Ctx, db: &Database) -> Result<()> { - if ctx.dry_run { - return Ok(()); - } - - let deadline = Instant::now() + READY_TIMEOUT; - - loop { - let ready = PgIsReady { - username: &db.user, - dbname: &db.name, - } - .in_container(&db.container) - .quietly_succeeds(ctx)?; - - 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(ctx: &Ctx, db: &Database, command: &dyn Cmd) -> Result<()> { - wait_until_ready(ctx, db)?; - command.run(ctx) -} - pub fn import(ctx: &Ctx, file: &Path) -> Result<()> { let dump = Dump::of(file)?; let db = Database::resolve(ctx)?; @@ -265,7 +118,9 @@ pub fn import(ctx: &Ctx, file: &Path) -> Result<()> { } }; - let tool = kind.tool(); + 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}"); when_ready( @@ -309,7 +164,6 @@ pub fn import(ctx: &Ctx, file: &Path) -> Result<()> { bail!("{tool} failed, the database is left empty"); } } else { - let restore = db.restore_with(kind); let restore: Box = match dump { Dump::Gzip => Box::new(Gunzip.pipe(&*restore)), _ => restore, @@ -363,9 +217,7 @@ pub fn dump(ctx: &Ctx, file: &Path, format: Format, gzip: bool) -> Result<()> { let stdout = if ctx.dry_run { Stdio::null() } else { - Stdio::from( - File::create(&partial).with_context(|| format!("creating {}", partial.display()))?, - ) + Stdio::from(std::fs::File::from(File::create(&partial)?)) }; let dumping = dump_command(&db, format); @@ -424,7 +276,7 @@ fn dump_directory(ctx: &Ctx, db: &Database, target: &Path) -> Result<()> { #[cfg(test)] mod tests { - use super::{GZIP_MAGIC, Kind, is_existing_role_error}; + use super::is_existing_role_error; #[test] fn only_the_existing_role_complaint_is_expected() { @@ -441,35 +293,4 @@ mod tests { assert!(!is_existing_role_error(line), "{line}"); } } - - #[test] - fn an_archive_is_recognised_by_its_magic() { - assert!(matches!(Kind::of(b"PGDMP\x01\x0f"), Kind::Archive)); - assert!(matches!(Kind::of(b"toc.dat\x00\x00"), Kind::Archive)); - } - - #[test] - fn a_cluster_dump_is_recognised_by_its_header() { - let header = b"--\n-- PostgreSQL database cluster dump\n--\n\n\\restrict abc\n"; - assert!(matches!(Kind::of(header), Kind::Cluster)); - } - - #[test] - fn anything_else_is_a_single_database_dump() { - for header in [ - &b"--\n-- PostgreSQL database dump\n"[..], - &b"BEGIN;"[..], - &b""[..], - &b"PGD"[..], - &b"toc.da"[..], - ] { - assert!(matches!(Kind::of(header), Kind::Sql), "{header:?}"); - } - } - - #[test] - fn gzip_is_recognised_by_its_magic() { - assert!(b"\x1f\x8b\x08\x00rest".starts_with(GZIP_MAGIC)); - assert!(!b"PGDMP".starts_with(GZIP_MAGIC)); - } } diff --git a/src/commands/postgres/server.rs b/src/commands/postgres/server.rs new file mode 100644 index 0000000..3d1a47d --- /dev/null +++ b/src/commands/postgres/server.rs @@ -0,0 +1,85 @@ +use std::thread; +use std::time::{Duration, Instant}; + +use anyhow::{Result, anyhow, bail}; + +use super::shape::Kind; +use crate::cmd::{Cmd, PgIsReady, PgRestore, Ps, Psql}; +use crate::ctx::Ctx; +use crate::project::Project; + +const READY_TIMEOUT: Duration = Duration::from_secs(60); +const POLL_INTERVAL: Duration = Duration::from_secs(1); + +pub(super) struct Database { + pub(super) service: String, + pub(super) container: String, + pub(super) user: String, + pub(super) name: String, +} + +impl Database { + pub(super) fn resolve(ctx: &Ctx) -> Result { + let compose = Project::resolve(ctx)?; + let service = compose.postgres()?; + let (user, name) = compose.postgres_credentials(&service); + + let container = Ps::id_of(&service).capture(ctx)?.trim().to_string(); + + if container.is_empty() { + return Err(anyhow!("service {service} has no running container")); + } + + Ok(Self { + service, + container, + user, + name, + }) + } + + // what reads this shape of dump back in + 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")), + } + } +} + +pub(super) fn wait_until_ready(ctx: &Ctx, db: &Database) -> Result<()> { + if ctx.dry_run { + return Ok(()); + } + + let deadline = Instant::now() + READY_TIMEOUT; + + loop { + let ready = PgIsReady { + username: &db.user, + dbname: &db.name, + } + .in_container(&db.container) + .quietly_succeeds(ctx)?; + + 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); + } +} + +pub(super) fn when_ready(ctx: &Ctx, db: &Database, command: &dyn Cmd) -> Result<()> { + wait_until_ready(ctx, db)?; + command.run(ctx) +} diff --git a/src/commands/postgres/shape.rs b/src/commands/postgres/shape.rs new file mode 100644 index 0000000..7508fc4 --- /dev/null +++ b/src/commands/postgres/shape.rs @@ -0,0 +1,104 @@ +use fs_err::File; +use std::io::Read; +use std::path::Path; + +use anyhow::Result; + +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"; +// wide enough for the cluster marker, which sits a few bytes into the file +pub(super) const HEADER_LEN: usize = 512; + +pub(super) enum Dump { + Directory, + Gzip, + Header(Vec), +} + +impl Dump { + pub(super) fn of(path: &Path) -> Result { + if path.is_dir() { + return Ok(Self::Directory); + } + + let header = read_header(path)?; + if header.starts_with(GZIP_MAGIC) { + return Ok(Self::Gzip); + } + + Ok(Self::Header(header)) + } +} + +// how the dump has to be fed back in: pg_restore for an archive, psql into the +// database for a single database dump, psql into postgres for a whole cluster +#[derive(Clone, Copy, PartialEq)] +pub(super) enum Kind { + Archive, + Sql, + Cluster, +} + +impl Kind { + pub(super) fn of(header: &[u8]) -> Self { + if header.starts_with(CUSTOM_MAGIC) || header.starts_with(TAR_MAGIC) { + return Self::Archive; + } + + if String::from_utf8_lossy(header).contains(CLUSTER_MARKER) { + return Self::Cluster; + } + + Self::Sql + } +} + +// pg_dumpall recreates roles the cluster already has, so this is the expected + +fn read_header(path: &Path) -> Result> { + let mut file = File::open(path)?; + let mut header = vec![0; HEADER_LEN]; + + let read = file.read(&mut header)?; + header.truncate(read); + + Ok(header) +} + +#[cfg(test)] +mod tests { + use super::{GZIP_MAGIC, Kind}; + + #[test] + fn an_archive_is_recognised_by_its_magic() { + assert!(matches!(Kind::of(b"PGDMP\x01\x0f"), Kind::Archive)); + assert!(matches!(Kind::of(b"toc.dat\x00\x00"), Kind::Archive)); + } + + #[test] + fn a_cluster_dump_is_recognised_by_its_header() { + let header = b"--\n-- PostgreSQL database cluster dump\n--\n\n\\restrict abc\n"; + assert!(matches!(Kind::of(header), Kind::Cluster)); + } + + #[test] + fn anything_else_is_a_single_database_dump() { + for header in [ + &b"--\n-- PostgreSQL database dump\n"[..], + &b"BEGIN;"[..], + &b""[..], + &b"PGD"[..], + &b"toc.da"[..], + ] { + assert!(matches!(Kind::of(header), Kind::Sql), "{header:?}"); + } + } + + #[test] + fn gzip_is_recognised_by_its_magic() { + assert!(b"\x1f\x8b\x08\x00rest".starts_with(GZIP_MAGIC)); + assert!(!b"PGDMP".starts_with(GZIP_MAGIC)); + } +} diff --git a/src/main.rs b/src/main.rs index b6e8934..b40d113 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,11 +2,11 @@ use std::process::ExitCode; mod cli; mod cmd; -mod compose; +mod commands; mod ctx; mod fsops; mod output; -mod scripts; +mod project; use anyhow::Result; use clap::Parser; @@ -43,25 +43,25 @@ fn run(ctx: &Ctx, command: cli::Commands) -> Result { match command { cli::Commands::Django { command } => { match command { - cli::Django::Bash => scripts::django::bash(ctx), - cli::Django::Run { rest } => scripts::django::run(ctx, &rest), + cli::Django::Bash => commands::django::bash(ctx), + cli::Django::Run { rest } => commands::django::run(ctx, &rest), cli::Django::MakeCommand { app, name } => { - scripts::django::make_command(ctx, &app, &name) + commands::django::make_command(ctx, &app, &name) } - cli::Django::Makemigrations => scripts::django::makemigrations(ctx), - cli::Django::Manage { rest } => scripts::django::manage(ctx, &rest), - cli::Django::Migrate { rest } => scripts::django::migrate(ctx, &rest), - cli::Django::Shell => scripts::django::shell(ctx), - cli::Django::Test => scripts::django::test(ctx), + cli::Django::Makemigrations => commands::django::makemigrations(ctx), + 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) } cli::Commands::Postgres { command } => { match command { - cli::Postgres::Import { path } => scripts::postgres::import(ctx, &path), + cli::Postgres::Import { path } => commands::postgres::import(ctx, &path), cli::Postgres::Dump { path, format, gzip } => { - scripts::postgres::dump(ctx, &path, format, gzip) + commands::postgres::dump(ctx, &path, format, gzip) } }?; @@ -72,9 +72,9 @@ fn run(ctx: &Ctx, command: cli::Commands) -> Result { paths, force, store, - } => scripts::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::Restore { paths, all, store } => { - scripts::link::restore(ctx, &paths, all, store.root.as_deref()).map(|()| done) + commands::link::restore(ctx, &paths, all, store.root.as_deref()).map(|()| done) } // the one command with something to say through its exit code cli::Link::Check { @@ -83,7 +83,7 @@ fn run(ctx: &Ctx, command: cli::Commands) -> Result { null, exit_code, store, - } => scripts::link::check( + } => commands::link::check( ctx, &paths, porcelain, @@ -93,7 +93,7 @@ fn run(ctx: &Ctx, command: cli::Commands) -> Result { ), }, cli::Commands::Completions { shell } => { - scripts::completions::completions(shell)?; + commands::completions::completions(shell)?; Ok(done) } diff --git a/src/compose.rs b/src/project.rs similarity index 97% rename from src/compose.rs rename to src/project.rs index 9e89f4d..296b3eb 100644 --- a/src/compose.rs +++ b/src/project.rs @@ -7,11 +7,11 @@ use crate::ctx::Ctx; const POSTGRES_IMAGES: [&str; 4] = ["postg", "timescale", "pgvector", "citus"]; const DJANGO_SETTINGS_MODULE: &str = "DJANGO_SETTINGS_MODULE"; -pub struct Compose { +pub struct Project { services: Value, } -impl Compose { +impl Project { pub fn resolve(ctx: &Ctx) -> Result { let json = Config.capture(ctx)?; let config: Value = serde_json::from_str(&json).context("parsing docker compose config")?; @@ -125,11 +125,11 @@ fn is_postgres_image(image: &str) -> bool { #[cfg(test)] mod tests { - use super::{Compose, is_postgres_image}; + use super::{Project, is_postgres_image}; use serde_json::json; - fn compose(services: serde_json::Value) -> Compose { - Compose { services } + fn compose(services: serde_json::Value) -> Project { + Project { services } } #[test] diff --git a/src/scripts/link.rs b/src/scripts/link.rs deleted file mode 100644 index 1ca306b..0000000 --- a/src/scripts/link.rs +++ /dev/null @@ -1,763 +0,0 @@ -use std::cell::Cell; -use std::env; -use std::ffi::OsString; -use std::fs; -use std::path::{Path, PathBuf}; - -use std::process::ExitCode; - -use anyhow::{Context, Result, anyhow, bail}; - -use crate::cmd::{CheckIgnore, Cmd, ConfigGet, LsFiles, RevParse}; -use crate::ctx::Ctx; -use crate::fsops::{move_path, place_link, prune_empty, remove_file, rename, suffixed}; -use crate::output::{note, warning}; - -const BACKUP_SUFFIX: &str = ".ahab-bak"; -const LOCAL_NAMESPACE: &str = "_local"; - -// move untracked paths out of the repo and symlink them back -pub fn add(ctx: &Ctx, paths: &[PathBuf], force: bool, store: Option<&Path>) -> Result<()> { - let repo = Repo::discover(ctx, store)?; - let report = Report::new(&repo); - - if let [path] = paths { - return link_one(ctx, &repo, path, force, &report); - } - - let mut failed = 0; - for path in paths { - if let Err(e) = link_one(ctx, &repo, path, force, &report) { - note!("error: {e:#}"); - failed += 1; - } - } - - if failed > 0 { - return Err(anyhow!("{failed} of {} paths failed", paths.len())); - } - Ok(()) -} - -// move paths in the store back into the repo, the inverse of add -pub fn restore(ctx: &Ctx, paths: &[PathBuf], all: bool, store: Option<&Path>) -> Result<()> { - let repo = Repo::discover(ctx, store)?; - let report = Report::new(&repo); - - let paths = match (all, paths) { - (true, []) => linked_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(), - }; - - if paths.is_empty() { - note!("nothing in the store for this repository"); - return Ok(()); - } - - if let [path] = paths.as_slice() { - return restore_one(ctx, &repo, path, &report); - } - - let mut failed = 0; - for path in &paths { - if let Err(e) = restore_one(ctx, &repo, path, &report) { - note!("error: {e:#}"); - failed += 1; - } - } - - if failed > 0 { - return Err(anyhow!("{failed} of {} paths failed", paths.len())); - } - Ok(()) -} - -fn restore_one(ctx: &Ctx, repo: &Repo, path: &Path, report: &Report) -> Result<()> { - let src = resolve(path)?; - let rel = repo.relative(&src)?; - let stored = repo.store.join(&rel); - - let Some(meta) = symlink_metadata_opt(&src)? else { - bail!("{} does not exist", rel.display()); - }; - if !meta.is_symlink() { - bail!( - "{} is not a symlink, so it is not in the store", - rel.display() - ); - } - - let dest = fs::read_link(&src).with_context(|| format!("reading {}", src.display()))?; - if dest != stored { - bail!( - "{} points at {}, which is not where the store keeps it", - rel.display(), - dest.display() - ); - } - if symlink_metadata_opt(&stored)?.is_none() { - bail!("{} is missing from the store", rel.display()); - } - - remove_file(ctx, &src)?; - move_path(ctx, &stored, &src)?; - prune_empty(ctx, stored.parent(), &repo.base); - - report.line("restored", &rel); - Ok(()) -} - -// 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> { - let mut found = Vec::new(); - - let entries = match fs::read_dir(dir) { - Ok(entries) => entries, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(found), - Err(e) => return Err(e).with_context(|| format!("reading {}", dir.display())), - }; - - for entry in entries { - let stored = entry?.path(); - let rel = stored - .strip_prefix(&repo.store) - .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()) - && fs::read_link(&src).is_ok_and(|dest| dest == stored); - - if linked { - found.push(src); - } else if stored.is_dir() { - found.extend(linked_paths(repo, &stored)?); - } - } - - found.sort(); - Ok(found) -} - -struct Report { - store: PathBuf, - named: Cell, -} - -impl Report { - fn new(repo: &Repo) -> Self { - Self { - store: repo.store.clone(), - named: Cell::new(false), - } - } - - fn line(&self, verb: &str, path: &Path) { - // worth naming once per run - if !self.named.replace(true) { - println!("store: {}", self.store.display()); - } - - println!("\t{:<11}{}", format!("{verb}:"), path.display()); - } -} - -// list untracked paths not in the store, i.e. what a sandbox can still read -pub fn check( - ctx: &Ctx, - paths: &[PathBuf], - porcelain: bool, - null: bool, - exit_code: bool, - store: Option<&Path>, -) -> Result { - 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 ...\" to move them into the store)", - Section::Content(UNTRACKED), - ), - ( - "Ignored paths a sandbox can read:", - " (use \"ahab link add ...\" 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, -} - -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> { - 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 = fs::read_link(&src).with_context(|| format!("reading symlink {}", src.display()))?; - 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)> { - let dir = repo.root.join(rel); - let mut handled = 0; - let mut exposed = Vec::new(); - - for entry in fs::read_dir(&dir).with_context(|| format!("reading {}", dir.display()))? { - 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> { - 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> { - 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()) -} - -struct Repo { - root: PathBuf, - store: PathBuf, - // the configured store root, above the per-repository directories - base: PathBuf, -} - -impl Repo { - fn discover(ctx: &Ctx, store: Option<&Path>) -> Result { - let root = git_root(ctx)?; - let base = match store { - Some(store) => store.to_path_buf(), - None => store_root()?, - }; - - Ok(Self { - store: base.join(repo_components(ctx, &root)?), - root, - base, - }) - } - fn relative(&self, src: &Path) -> Result { - let rel = src.strip_prefix(&self.root).map_err(|_| { - anyhow!( - "{} is outside the repository {}", - src.display(), - self.root.display() - ) - })?; - - // empty means the whole repo - if rel.as_os_str().is_empty() { - return Err(anyhow!( - "refusing to externalize the repository root itself" - )); - } - if rel.starts_with(".git") { - return Err(anyhow!("refusing to externalize anything under .git")); - } - - Ok(rel.to_path_buf()) - } -} - -fn link_one(ctx: &Ctx, repo: &Repo, path: &Path, force: bool, report: &Report) -> Result<()> { - let src = resolve(path)?; - let rel = repo.relative(&src)?; - let target = repo.store.join(&rel); - - // a target inside the repo would be readable from the sandbox anyway - if target.starts_with(&repo.root) { - return Err(anyhow!( - "target {} is inside the repository; point AHAB_LINK_ROOT elsewhere", - target.display() - )); - } - - if tracked(ctx, repo, &rel)? { - return Err(anyhow!( - "{} is tracked by git; only untracked or ignored paths can be externalized", - rel.display() - )); - } - if !ignored(ctx, repo, &rel) { - warning!("{} is not gitignored", rel.display()); - } - - let src_meta = symlink_metadata_opt(&src)?; - let target_taken = symlink_metadata_opt(&target)?.is_some(); - - match src_meta { - Some(meta) if meta.is_symlink() => { - let dest = fs::read_link(&src) - .with_context(|| format!("reading symlink {}", src.display()))?; - - if dest == target { - if !target_taken { - return Err(anyhow!( - "{} already points at {}, but nothing is there", - rel.display(), - target.display() - )); - } - report.line("unchanged", &rel); - return Ok(()); - } - - // nothing in the store to adopt, so the symlink itself moves out - if !target_taken { - if !src.exists() { - warning!( - "{} is a broken symlink to {}", - rel.display(), - dest.display() - ); - } - move_path(ctx, &src, &target)?; - place_link(ctx, &src, &target)?; - report.line("moved", &rel); - return Ok(()); - } - - if !force { - return Err(needs_force(&target)); - } - - place_link(ctx, &src, &target)?; - report.line("repointed", &rel); - Ok(()) - } - - Some(_) if target_taken => { - if !force { - return Err(needs_force(&target)); - } - - let backup = suffixed(&src, BACKUP_SUFFIX); - if symlink_metadata_opt(&backup)?.is_some() { - return Err(anyhow!( - "{} already exists; remove it before re-linking", - backup.display() - )); - } - - rename(ctx, &src, &backup)?; - report.line("saved", &suffixed(&rel, BACKUP_SUFFIX)); - - place_link(ctx, &src, &target)?; - report.line("linked", &rel); - Ok(()) - } - - Some(_) => { - move_path(ctx, &src, &target)?; - place_link(ctx, &src, &target)?; - report.line("moved", &rel); - Ok(()) - } - - None if target_taken => { - if !force { - return Err(needs_force(&target)); - } - place_link(ctx, &src, &target)?; - report.line("linked", &rel); - Ok(()) - } - - None => Err(anyhow!( - "{} does not exist and the store has no {}", - rel.display(), - target.display() - )), - } -} - -fn needs_force(target: &Path) -> anyhow::Error { - anyhow!( - "{} already exists; pass --force to link to it", - target.display() - ) -} - -fn resolve(path: &Path) -> Result { - let abs = std::path::absolute(path) - .with_context(|| format!("resolving absolute path of {}", path.display()))?; - - // the parent must exist so symlinked components resolve like git's toplevel - let Some(name) = abs.file_name().map(OsString::from) else { - return fs::canonicalize(&abs).with_context(|| format!("resolving {}", abs.display())); - }; - let parent = abs.parent().unwrap_or(Path::new("/")); - - let parent = fs::canonicalize(parent) - .with_context(|| format!("resolving directory {}", parent.display()))?; - Ok(parent.join(name)) -} - -fn git_root(ctx: &Ctx) -> Result { - let root = RevParse - .capture(ctx) - .map_err(|_| anyhow!("not inside a git repository"))?; - - let root = root.trim().to_string(); - if root.is_empty() { - return Err(anyhow!("git reported an empty repository root")); - } - - fs::canonicalize(&root).with_context(|| format!("resolving {root}")) -} - -fn repo_components(ctx: &Ctx, root: &Path) -> Result { - if let Some(url) = git_origin_url(ctx) { - if let Some(components) = components_from_remote(&url) { - return Ok(components); - } - note!("could not parse git remote `{url}`, falling back to the checkout name"); - } - - let name = root - .file_name() - .ok_or_else(|| anyhow!("cannot derive a store path for {}", root.display()))?; - Ok(Path::new(LOCAL_NAMESPACE).join(sanitize(&name.to_string_lossy()))) -} - -fn git_origin_url(ctx: &Ctx) -> Option { - let url = ConfigGet { - key: "remote.origin.url", - } - .capture(ctx) - .ok()?; - - let url = url.trim().to_string(); - (!url.is_empty()).then_some(url) -} - -fn components_from_remote(url: &str) -> Option { - let url = url.trim(); - let url = url.strip_suffix(".git").unwrap_or(url); - - // `scheme://[user@]host[:port]/path`, or scp-like `[user@]host:path` - let (authority, path) = match url.split_once("://") { - Some((_, after)) => after.split_once('/')?, - None => url.split_once(':')?, - }; - - let host = authority.rsplit_once('@').map_or(authority, |(_, h)| h); - let host = host.split_once(':').map_or(host, |(h, _)| h); - // a local remote has no host to key on - if host.is_empty() { - return None; - } - - let mut components = PathBuf::from(sanitize(&host.to_lowercase())); - let mut depth = 0; - for part in path.split('/').filter(|p| !p.is_empty()) { - components.push(sanitize(part)); - depth += 1; - } - (depth > 0).then_some(components) -} - -fn sanitize(s: &str) -> String { - let out: String = s - .chars() - .map(|c| { - if c.is_ascii_alphanumeric() || matches!(c, '-' | '.' | '_') { - c - } else { - '_' - } - }) - .collect(); - - // `.` and `..` are legal characters but not legal components - if out.chars().all(|c| c == '.') { - return "_".repeat(out.len()); - } - out -} - -fn store_root() -> Result { - if let Some(xdg) = non_empty_var("XDG_DATA_HOME") { - return Ok(PathBuf::from(xdg).join("ahab")); - } - - let home = non_empty_var("HOME") - .filter(|v| !v.is_empty()) - .ok_or_else(|| anyhow!("neither XDG_DATA_HOME nor HOME is set"))?; - - Ok(PathBuf::from(home).join(".local/share/ahab")) -} -fn non_empty_var(name: &str) -> Option { - env::var_os(name).filter(|v| !v.is_empty()) -} - -fn tracked(ctx: &Ctx, repo: &Repo, rel: &Path) -> Result { - let listed = LsFiles::tracked(&repo.root) - .limited_to(&[rel]) - .capture(ctx)?; - - Ok(!listed.is_empty()) -} - -fn ignored(ctx: &Ctx, repo: &Repo, rel: &Path) -> bool { - CheckIgnore::new(&repo.root, rel) - .quietly_succeeds(ctx) - .unwrap_or(false) -} - -fn symlink_metadata_opt(path: &Path) -> Result> { - // symlink_metadata does not follow the link, so a symlink shows as one - match fs::symlink_metadata(path) { - Ok(meta) => Ok(Some(meta)), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), - Err(e) => Err(e).with_context(|| format!("inspecting {}", path.display())), - } -} - -#[cfg(test)] -mod tests { - use super::{components_from_remote, sanitize}; - use std::path::PathBuf; - - #[test] - fn parses_every_spelling_of_a_remote() { - let cases = [ - ( - "git@git.aflabs.org:urnik/afurnik.git", - "git.aflabs.org/urnik/afurnik", - ), - ( - "https://git.aflabs.org/urnik/afurnik.git", - "git.aflabs.org/urnik/afurnik", - ), - ( - "https://git.aflabs.org/urnik/afurnik", - "git.aflabs.org/urnik/afurnik", - ), - ( - "https://git.aflabs.org/urnik/afurnik/", - "git.aflabs.org/urnik/afurnik", - ), - ( - "ssh://git@git.aflabs.org:22/urnik/afurnik.git", - "git.aflabs.org/urnik/afurnik", - ), - ( - "git@GIT.Aflabs.org:urnik/AFurnik.git", - "git.aflabs.org/urnik/AFurnik", - ), - ( - "git@git.aflabs.org:urnik/internal/afurnik.git", - "git.aflabs.org/urnik/internal/afurnik", - ), - ]; - - for (url, want) in cases { - assert_eq!( - components_from_remote(url), - Some(PathBuf::from(want)), - "url: {url}" - ); - } - } - - #[test] - fn rejects_remotes_without_a_host() { - assert_eq!(components_from_remote("not-a-url"), None); - assert_eq!(components_from_remote("/srv/git/afurnik.git"), None); - assert_eq!(components_from_remote("file:///srv/git/afurnik.git"), None); - assert_eq!(components_from_remote("https://git.aflabs.org/"), None); - } - - #[test] - fn sanitize_never_yields_a_traversal() { - assert_eq!(sanitize(".."), "__"); - assert_eq!(sanitize("."), "_"); - assert_eq!(sanitize("a/b"), "a_b"); - assert_eq!(sanitize(".env"), ".env"); - } -}