merge: structural code changes

This commit is contained in:
2026-09-09 15:41:44 +02:00
22 changed files with 1077 additions and 399 deletions

View File

@@ -2,64 +2,80 @@ use std::path::PathBuf;
use clap::{Args, Subcommand};
// each variant carries its own arguments as a struct rather than as fields
// spread across the enum. the command that implements it takes that struct, so
// main does not unpack what clap has already parsed and hand it on as a row of
// booleans nothing but position tells apart
#[derive(Subcommand, Debug)]
pub enum Link {
/// Move untracked paths into the store and symlink them back
Add {
Add(Add),
/// Move paths in the store back into the repository
Restore(Restore),
/// List what this repository keeps in the store
List(List),
/// List untracked paths a sandbox would still see
Check(Check),
}
#[derive(Args, Debug)]
pub struct Add {
/// Untracked or ignored paths inside the repository
#[arg(required = true)]
paths: Vec<PathBuf>,
pub paths: Vec<PathBuf>,
/// Link to a store path that already exists
#[arg(long)]
force: bool,
pub force: bool,
#[command(flatten)]
store: Store,
},
pub store: Store,
}
/// Move paths in the store back into the repository
Restore {
#[derive(Args, Debug)]
pub struct Restore {
// said here rather than checked at runtime, so a mistake in the
// arguments is reported as one, with the usage and the exit code clap
// gives every other argument error
#[arg(required_unless_present = "all", conflicts_with = "all")]
paths: Vec<PathBuf>,
pub paths: Vec<PathBuf>,
/// Restore every path this repository has in the store
#[arg(long)]
all: bool,
pub all: bool,
#[command(flatten)]
store: Store,
},
pub store: Store,
}
/// List what this repository keeps in the store
List {
#[derive(Args, Debug)]
pub struct List {
#[command(flatten)]
store: Store,
},
pub store: Store,
}
/// List untracked paths a sandbox would still see
Check {
#[derive(Args, Debug)]
pub struct Check {
/// Limit the listing to these paths
paths: Vec<PathBuf>,
pub paths: Vec<PathBuf>,
/// Print `<code> <path>` for scripts, as `git status --porcelain` does
#[arg(long)]
porcelain: bool,
pub porcelain: bool,
/// Exit with 4 when anything is outside the store, for scripts
#[arg(long)]
exit_code: bool,
pub exit_code: bool,
/// Terminate porcelain entries with NUL
#[arg(short = 'z')]
null: bool,
pub null: bool,
#[command(flatten)]
store: Store,
},
pub store: Store,
}
#[derive(Args, Debug)]
@@ -70,3 +86,9 @@ pub struct Store {
#[arg(long = "store", env = "AHAB_LINK_ROOT", value_name = "DIR")]
pub root: Option<PathBuf>,
}
impl Store {
pub fn root(&self) -> Option<&std::path::Path> {
self.root.as_deref()
}
}

View File

@@ -1,9 +1,9 @@
// 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 mod ahab;
pub mod django;
pub mod link;
pub mod postgres;
pub use ahab::{Ahab, Commands};
pub use django::Django;

View File

@@ -1,6 +1,6 @@
use std::path::PathBuf;
use clap::{Subcommand, ValueEnum};
use clap::{Args, Subcommand, ValueEnum};
#[derive(Subcommand, Debug)]
pub enum Postgres {
@@ -15,17 +15,20 @@ pub enum Postgres {
},
/// Dump via pg_dump, or pg_dumpall for a whole cluster
Dump {
path: PathBuf,
Dump(Dump),
}
#[derive(Args, Debug)]
pub struct Dump {
pub path: PathBuf,
/// Dump format
#[arg(short = 'F', long, value_enum, default_value_t = Format::Custom)]
format: Format,
pub format: Format,
/// Compress the dump with gzip
#[arg(short = 'z', long)]
gzip: bool,
},
pub gzip: bool,
}
#[derive(ValueEnum, Clone, Copy, Debug, Default, PartialEq)]
@@ -51,16 +54,3 @@ pub enum Format {
#[value(alias = "all")]
Cluster,
}
impl Format {
// pg_dump's -F letter, which a cluster dump does not have: that is pg_dumpall
pub fn flag(&self) -> Option<&'static str> {
match self {
Self::Custom => Some("c"),
Self::Plain => Some("p"),
Self::Tar => Some("t"),
Self::Directory => Some("d"),
Self::Cluster => None,
}
}
}

View File

@@ -65,9 +65,8 @@ impl Argv {
&self.0
}
// one word list for `sh -c`, quoted so a value with a space survives the
// shell. lossy: this is for reading, and for scripts built only out of the
// ascii and compose-derived words that reach a pipeline
// for reading: the -v echo, the dry run's plan, and the command named in an
// error. lossy, because a message about a path is still worth printing
pub fn quoted(&self) -> String {
let words: Vec<String> = self
.0
@@ -82,6 +81,28 @@ impl Argv {
shlex::try_join(words.iter().map(String::as_str)).unwrap_or_else(|_| words.join(" "))
}
// one word list for a shell to read, which is a string and so cannot carry
// a word that is not text. only ascii and compose-derived words reach a
// pipeline today; this is what keeps it that way, rather than a comment
// saying so while to_string_lossy quietly rewrites a path
pub fn for_shell(&self) -> Result<String> {
let mut words = Vec::with_capacity(self.0.len());
for word in &self.0 {
let word = word.to_str().ok_or_else(|| {
anyhow!(
"`{}` cannot go in a shell pipeline: {:?} is not text",
self.quoted(),
word
)
})?;
words.push(word);
}
shlex::try_join(words).map_err(|e| anyhow!("quoting `{}`: {e}", self.quoted()))
}
pub fn run(&self, ctx: &Ctx) -> Result<()> {
if self.skipped(ctx) {
return Ok(());

View File

@@ -1,4 +1,5 @@
use super::{Argv, Cmd};
use crate::ctx::Ctx;
// compose prints its own progress, which --quiet has to ask it to stop
fn compose(quiet: bool) -> Argv {
@@ -10,6 +11,49 @@ fn compose(quiet: bool) -> Argv {
}
}
// the compose commands, each already knowing whether -q asked compose to stop
// narrating. the flag was a field every construction site set by hand, and
// three of them did not, so `ahab -q` still had compose talking over the output
pub struct Compose<'a> {
ctx: &'a Ctx,
}
impl<'a> Compose<'a> {
pub(crate) fn new(ctx: &'a Ctx) -> Self {
Self { ctx }
}
pub fn up(&self) -> Up {
Up {
quiet: self.ctx.quiet,
}
}
pub fn stop(&self) -> Stop {
Stop {
quiet: self.ctx.quiet,
}
}
pub fn start(&self, service: &'a str) -> Start<'a> {
Start {
service,
quiet: self.ctx.quiet,
}
}
// reading the project says nothing, so there is no progress to quieten
pub fn config(&self) -> Config {
Config
}
pub fn ps(&self, service: &str) -> Ps {
Ps {
service: service.to_string(),
}
}
}
// docker compose run --rm, which runs the image's entrypoint and so fixes up the
// container user before handing over
pub struct Run {
@@ -19,20 +63,15 @@ pub struct Run {
}
impl Run {
pub fn wrapping(service: &str, inner: Argv) -> Self {
// only reached through Cmd::in_service, which takes the context, so the
// progress flag cannot be left off by forgetting a builder call
pub(super) fn wrapping(ctx: &Ctx, service: &str, inner: Argv) -> Self {
Self {
service: service.to_string(),
inner,
quiet: false,
quiet: ctx.quiet,
}
}
// compose narrates the container it creates before handing over, which is
// progress along the way rather than what was asked for
pub fn quiet(mut self, quiet: bool) -> Self {
self.quiet = quiet;
self
}
}
impl Cmd for Run {
@@ -59,14 +98,6 @@ 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(false).arg("ps").arg("--quiet").arg(&self.service)
@@ -74,7 +105,7 @@ impl Cmd for Ps {
}
pub struct Up {
pub quiet: bool,
quiet: bool,
}
impl Cmd for Up {
@@ -84,8 +115,8 @@ impl Cmd for Up {
}
pub struct Start<'a> {
pub service: &'a str,
pub quiet: bool,
service: &'a str,
quiet: bool,
}
impl Cmd for Start<'_> {
@@ -95,7 +126,7 @@ impl Cmd for Start<'_> {
}
pub struct Stop {
pub quiet: bool,
quiet: bool,
}
impl Cmd for Stop {

View File

@@ -14,7 +14,7 @@ use std::{
use anyhow::Result;
pub use argv::Argv;
pub use compose::{Config, Ps, Run, Start, Stop, Up};
pub use compose::{Compose, Run};
pub use django::{Bash, Manage, Words};
pub use docker::{Cp, Exec};
pub use git::{CheckIgnore, ConfigGet, LsFiles, RevParse};
@@ -30,22 +30,25 @@ 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()
fn shell(&self) -> Result<String> {
self.argv().for_shell()
}
// adapters
fn pipe(&self, next: &dyn Cmd) -> Pipeline {
Pipeline::starting(self.shell()).pipe(next)
fn pipe(&self, next: &dyn Cmd) -> Result<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())
// the context comes in here because compose narrates what it is doing, and
// whether it should is the caller's -q rather than this command's business
fn in_service(&self, ctx: &Ctx, service: &str) -> Run {
Run::wrapping(ctx, service, self.argv())
}
// terminals
fn run(&self, ctx: &Ctx) -> Result<()> {
self.argv().run(ctx)

View File

@@ -1,4 +1,5 @@
use super::{Argv, Cmd};
use crate::cli::Format;
// whether the server is accepting connections yet
pub struct PgIsReady<'a> {
@@ -156,13 +157,25 @@ pub struct PgDump<'a> {
}
impl<'a> PgDump<'a> {
pub fn new(username: &'a str, dbname: &'a str, format: &'a str) -> Self {
Self {
// pg_dump's -F letter, which belongs with the rest of what this module
// knows about pg_dump's argv rather than with the parser that reads the
// user's `-F custom`. a cluster has no letter: that is pg_dumpall, so
// there is no PgDump to build for it
pub fn of(username: &'a str, dbname: &'a str, format: Format) -> Option<Self> {
let format = match format {
Format::Custom => "c",
Format::Plain => "p",
Format::Tar => "t",
Format::Directory => "d",
Format::Cluster => return None,
};
Some(Self {
username,
dbname,
format,
to: None,
}
})
}
// a path in the container, for the directory format, which pg_dump writes
@@ -201,7 +214,7 @@ impl Cmd for PgDumpAll<'_> {
#[cfg(test)]
mod tests {
use super::{DropDb, PgDump, Psql};
use super::{DropDb, Format, PgDump, Psql};
use crate::cmd::Cmd;
#[test]
@@ -219,16 +232,35 @@ mod tests {
#[test]
fn a_directory_dump_names_the_file_it_writes() {
let directory = PgDump::of("u", "db", Format::Directory).unwrap();
assert_eq!(
PgDump::new("u", "db", "d").to("/tmp/dump").argv().quoted(),
directory.to("/tmp/dump").argv().quoted(),
"pg_dump --username u --format d --file /tmp/dump -- db"
);
assert_eq!(
PgDump::new("u", "db", "c").argv().quoted(),
PgDump::of("u", "db", Format::Custom)
.unwrap()
.argv()
.quoted(),
"pg_dump --username u --format c -- db"
);
}
#[test]
fn a_cluster_is_pg_dumpall_rather_than_a_pg_dump_letter() {
assert!(PgDump::of("u", "db", Format::Cluster).is_none());
for format in [
Format::Custom,
Format::Plain,
Format::Tar,
Format::Directory,
] {
assert!(PgDump::of("u", "db", format).is_some(), "{format:?}");
}
}
#[test]
fn only_a_single_database_restore_stops_at_the_first_error() {
assert!(

View File

@@ -1,3 +1,5 @@
use anyhow::Result;
use super::{Argv, Cmd};
// sh -c, the only way to reach a shell feature inside a container
@@ -30,9 +32,9 @@ impl Pipeline {
}
}
pub fn pipe(mut self, next: &dyn Cmd) -> Self {
self.stages.push(next.shell());
self
pub fn pipe(mut self, next: &dyn Cmd) -> Result<Self> {
self.stages.push(next.shell()?);
Ok(self)
}
// for a pipeline whose last stage closes the pipe on purpose, where the
@@ -119,21 +121,26 @@ mod tests {
#[test]
fn a_pipeline_reports_a_stage_that_dies_mid_stream() {
let script = Gunzip.pipe(&Gzip).shell();
let script = Gunzip.pipe(&Gzip).unwrap().shell().unwrap();
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();
let script = Gunzip
.pipe(&Head::bytes(512))
.unwrap()
.allow_early_close()
.shell()
.unwrap();
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();
let argv = Gunzip.pipe(&Gzip).unwrap().in_container("abc123").argv();
assert_eq!(
argv.words(),

View File

@@ -4,7 +4,7 @@ use anyhow::{Result, anyhow};
use crate::cmd::{Bash, Cmd, Manage, Words};
use crate::ctx::Ctx;
use crate::fsops::{create_dir, touch, write_new};
use crate::output::note;
use crate::project::Project;
@@ -40,8 +40,8 @@ pub fn make_command(ctx: &Ctx, app: &Path, name: &str) -> Result<()> {
let management_dir = app_dir.join("management");
if !management_dir.exists() {
create_dir(ctx, &management_dir)?;
touch(ctx, &management_dir.join("__init__.py"))?;
ctx.fs().create_dir(&management_dir)?;
ctx.fs().touch(&management_dir.join("__init__.py"))?;
note!(ctx, "created module {app_name}.management")
};
@@ -49,14 +49,13 @@ pub fn make_command(ctx: &Ctx, app: &Path, name: &str) -> Result<()> {
let commands_dir = management_dir.join("commands");
if !commands_dir.exists() {
create_dir(ctx, &commands_dir)?;
touch(ctx, &commands_dir.join("__init__.py"))?;
ctx.fs().create_dir(&commands_dir)?;
ctx.fs().touch(&commands_dir.join("__init__.py"))?;
note!(ctx, "created module {app_name}.management.commands")
};
write_new(
ctx,
ctx.fs().write_new(
&commands_dir.join(format!("{name}.py")),
DEBUG_TEMPLATE.as_bytes(),
)?;
@@ -72,22 +71,18 @@ fn is_module_name(name: &str) -> bool {
}
pub fn bash(ctx: &Ctx) -> Result<()> {
Bash.in_service(&service(ctx)?)
.quiet(ctx.quiet)
.replace(ctx)
Bash.in_service(ctx, &service(ctx)?).replace(ctx)
}
pub fn run(ctx: &Ctx, rest: &[String]) -> Result<()> {
Words::new(rest)
.in_service(&service(ctx)?)
.quiet(ctx.quiet)
.in_service(ctx, &service(ctx)?)
.replace(ctx)
}
pub fn manage(ctx: &Ctx, rest: &[String]) -> Result<()> {
Manage::new(rest)
.in_service(&service(ctx)?)
.quiet(ctx.quiet)
.in_service(ctx, &service(ctx)?)
.replace(ctx)
}

View File

@@ -10,49 +10,62 @@ use std::path::{Path, PathBuf};
use anyhow::{Context, Result, anyhow, bail};
use self::store::{Leads, Repo, ignored, leads, resolve, symlink_metadata_opt, tracked};
use crate::cli::link as cli;
use crate::ctx::Ctx;
use crate::fsops::{
ensure_private_parent, move_path, place_link, prune_empty, remove_file, rename, suffixed,
};
use crate::output::{line, note, warning};
use crate::fsops::suffixed;
use crate::output::{line, note, plural, warning};
const BACKUP_SUFFIX: &str = ".ahab-bak";
// where the link waits while the payload comes back out of the store
const RESTORING_SUFFIX: &str = ".ahab-restoring";
// 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);
// one path's failure is its own; several are counted, so a run over a list says
// what happened to each and then how the run as a whole went. a single path has
// nothing to count, so its error is simply the command's
fn each(paths: &[PathBuf], mut act: impl FnMut(&Path) -> Result<()>) -> Result<()> {
if let [only] = paths {
return act(only);
}
let mut failed = 0;
for path in paths {
if let Err(e) = link_one(ctx, &repo, path, force, &report) {
if let Err(e) = act(path) {
warning!("{e:#}");
failed += 1;
}
}
if failed > 0 {
return Err(anyhow!("{failed} of {} paths failed", paths.len()));
match failed {
0 => Ok(()),
failed => Err(anyhow!("{failed} of {} paths failed", paths.len())),
}
Ok(())
}
// move untracked paths out of the repo and symlink them back
pub fn add(ctx: &Ctx, args: &cli::Add) -> Result<()> {
let repo = Repo::discover(ctx, args.store.root())?;
let report = Report::new(&repo);
each(&args.paths, |path| {
link_one(ctx, &repo, path, args.force, &report)
})
}
// 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)?;
pub fn restore(ctx: &Ctx, args: &cli::Restore) -> Result<()> {
let repo = Repo::discover(ctx, args.store.root())?;
let report = Report::new(&repo);
// clap requires one or the other and refuses both, so only the two real
// cases are left here
let stored = match all {
let stored = match args.all {
true => stored_paths(&repo, &repo.store)?,
false => paths.iter().cloned().map(|p| (p, Stored::Linked)).collect(),
false => args
.paths
.iter()
.cloned()
.map(|path| (path, Stored::Linked))
.collect(),
};
// only a linked path can be moved back; the store can hold orphans too
@@ -69,7 +82,7 @@ pub fn restore(ctx: &Ctx, paths: &[PathBuf], all: bool, store: Option<&Path>) ->
n => note!(
ctx,
"nothing to restore: {n} path{} in the store {} not linked, see `ahab link list`",
if n == 1 { "" } else { "s" },
plural(n),
if n == 1 { "is" } else { "are" }
),
}
@@ -81,26 +94,11 @@ pub fn restore(ctx: &Ctx, paths: &[PathBuf], all: bool, store: Option<&Path>) ->
note!(
ctx,
"leaving {skipped} path{} that the store holds but nothing links to",
if skipped == 1 { "" } else { "s" }
plural(skipped)
);
}
if let [path] = linked.as_slice() {
return restore_one(ctx, &repo, path, &report);
}
let mut failed = 0;
for path in &linked {
if let Err(e) = restore_one(ctx, &repo, path, &report) {
warning!("{e:#}");
failed += 1;
}
}
if failed > 0 {
return Err(anyhow!("{failed} of {} paths failed", linked.len()));
}
Ok(())
each(&linked, |path| restore_one(ctx, &repo, path, &report))
}
fn restore_one(ctx: &Ctx, repo: &Repo, path: &Path, report: &Report) -> Result<()> {
@@ -137,10 +135,10 @@ fn restore_one(ctx: &Ctx, repo: &Repo, path: &Path, report: &Report) -> Result<(
bail!("{} is in the way; move it aside", aside.display());
}
rename(ctx, &src, &aside)?;
ctx.fs().rename(&src, &aside)?;
if let Err(e) = move_path(ctx, &stored, &src) {
rename(ctx, &aside, &src).with_context(|| {
if let Err(e) = ctx.fs().move_path(&stored, &src) {
ctx.fs().rename(&aside, &src).with_context(|| {
format!(
"could not put the link at {} back after failing to restore it",
src.display()
@@ -150,8 +148,8 @@ fn restore_one(ctx: &Ctx, repo: &Repo, path: &Path, report: &Report) -> Result<(
return Err(e);
}
remove_file(ctx, &aside)?;
prune_empty(ctx, stored.parent(), &repo.base);
ctx.fs().remove_file(&aside)?;
ctx.fs().prune_empty(stored.parent(), &repo.base);
report.line("restored", &rel);
Ok(())
@@ -227,8 +225,8 @@ pub fn stored_summary(ctx: &Ctx, store: Option<&Path>) -> Result<(PathBuf, usize
}
// list what the store holds for this repository, the inverse of check
pub fn list(ctx: &Ctx, store: Option<&Path>) -> Result<()> {
let repo = Repo::discover(ctx, store)?;
pub fn list(ctx: &Ctx, args: &cli::List) -> Result<()> {
let repo = Repo::discover(ctx, args.store.root())?;
let stored = stored_paths(&repo, &repo.store)?;
line!("store: {}", repo.store.display());
@@ -246,12 +244,18 @@ pub fn list(ctx: &Ctx, store: Option<&Path>) -> Result<()> {
Stored::Taken => "shadowed",
};
line!("\t{:<11}{}", format!("{verb}:"), rel.display());
entry(verb, rel);
}
Ok(())
}
// one line of what the store did with a path, in the column width `status`
// prints too: the two used to set it separately and had to be kept in step
fn entry(verb: &str, path: &Path) {
line!("\t{:<11}{}", format!("{verb}:"), path.display());
}
struct Report {
store: PathBuf,
named: Cell<bool>,
@@ -271,7 +275,7 @@ impl Report {
line!("store: {}", self.store.display());
}
line!("\t{:<11}{}", format!("{verb}:"), path.display());
entry(verb, path);
}
}
@@ -295,7 +299,7 @@ fn link_one(ctx: &Ctx, repo: &Repo, path: &Path, force: bool, report: &Report) -
stays_in_store(repo, &rel)?;
// before anything is moved in, so the tree it lands in is never briefly
// readable by anyone else
ensure_private_parent(ctx, &repo.base, &target)?;
ctx.fs().ensure_private_parent(&repo.base, &target)?;
if tracked(ctx, repo, &rel)? {
return Err(anyhow!(
@@ -363,7 +367,7 @@ fn link_one(ctx: &Ctx, repo: &Repo, path: &Path, force: bool, report: &Report) -
return Err(needs_force(&target));
}
place_link(ctx, &src, &target)?;
ctx.fs().place_link(&src, &target)?;
report.line("repointed", &rel);
Ok(())
}
@@ -381,10 +385,10 @@ fn link_one(ctx: &Ctx, repo: &Repo, path: &Path, force: bool, report: &Report) -
));
}
rename(ctx, &src, &backup)?;
ctx.fs().rename(&src, &backup)?;
report.line("saved", &suffixed(&rel, BACKUP_SUFFIX));
place_link(ctx, &src, &target)?;
ctx.fs().place_link(&src, &target)?;
report.line("linked", &rel);
Ok(())
}
@@ -399,7 +403,7 @@ fn link_one(ctx: &Ctx, repo: &Repo, path: &Path, force: bool, report: &Report) -
if !force {
return Err(needs_force(&target));
}
place_link(ctx, &src, &target)?;
ctx.fs().place_link(&src, &target)?;
report.line("linked", &rel);
Ok(())
}
@@ -416,10 +420,10 @@ fn link_one(ctx: &Ctx, repo: &Repo, path: &Path, force: bool, report: &Report) -
// but no link placed, the store holds a path nothing points at and the working
// tree has lost it altogether, which is the one outcome worse than failing
fn move_and_link(ctx: &Ctx, src: &Path, target: &Path) -> Result<()> {
move_path(ctx, src, target)?;
ctx.fs().move_path(src, target)?;
if let Err(e) = place_link(ctx, src, target) {
move_path(ctx, target, src).with_context(|| {
if let Err(e) = ctx.fs().place_link(src, target) {
ctx.fs().move_path(target, src).with_context(|| {
format!(
"could not put {} back after failing to link it to {}",
src.display(),

View File

@@ -6,20 +6,15 @@ use std::path::{Path, PathBuf};
use anyhow::{Result, anyhow};
use super::store::{Leads, Repo, leads, resolve, symlink_metadata_opt};
use crate::cli::link as cli;
use crate::cmd::{Cmd, LsFiles};
use crate::ctx::Ctx;
use crate::output::{line, text, write_bytes};
// whether anything is outside the store, which main turns into an exit code
pub fn check(
ctx: &Ctx,
paths: &[PathBuf],
porcelain: bool,
null: bool,
store: Option<&Path>,
) -> Result<bool> {
let repo = Repo::discover(ctx, store)?;
let pathspecs = relative_pathspecs(&repo, paths)?;
pub fn check(ctx: &Ctx, args: &cli::Check) -> Result<bool> {
let repo = Repo::discover(ctx, args.store.root())?;
let pathspecs = relative_pathspecs(&repo, &args.paths)?;
let mut exposed = Vec::new();
// git lists untracked and ignored separately
@@ -40,8 +35,8 @@ pub fn check(
exposed.sort_by(|a, b| a.name.cmp(&b.name));
if porcelain || null {
print_porcelain(&exposed, null);
if args.porcelain || args.null {
print_porcelain(&exposed, args.null);
} else {
print_listing(&repo, &exposed);
}

View File

@@ -10,13 +10,13 @@ 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::cli::postgres as cli;
use crate::cmd::{
Cmd, Cp, CreateDb, DropDb, Gunzip, Gzip, Head, PgDump, PgDumpAll, PgRestore, Psql, Rm, Start,
Stop, Up,
Cmd, Cp, CreateDb, DropDb, Gunzip, Gzip, Head, PgDump, PgDumpAll, PgRestore, Psql, Rm,
};
use crate::ctx::Ctx;
use crate::fsops::{create_private_new, remove_file, rename, suffixed};
use crate::output::{note, warning};
use crate::fsops::{create_private_new, suffixed};
use crate::output::{note, plural, warning};
// 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
@@ -36,6 +36,13 @@ fn partial_path(file: &Path) -> PathBuf {
suffixed(file, &format!(".{}.partial", std::process::id()))
}
// the database was dropped before the restore was attempted, so a restore that
// failed leaves nothing behind: said the same way wherever it happens, since
// what the user has to do about it does not depend on which tool it was
fn left_empty(tool: &str) -> anyhow::Error {
anyhow::anyhow!("{tool} failed, the database is left empty")
}
// pg_dumpall recreates roles the cluster already has, so this is the expected
// complaint rather than a failure
fn is_existing_role_error(line: &str) -> bool {
@@ -75,17 +82,17 @@ fn restore_cluster(ctx: &Ctx, db: &Database, restore: &dyn Cmd, file: &Path) ->
note!(
ctx,
"left {existing} existing role{} alone",
if existing == 1 { "" } else { "s" }
plural(existing)
);
}
if !out.status.success() {
bail!("psql failed, the database is left empty");
return Err(left_empty("psql"));
}
if failed > 0 {
bail!(
"psql reported {failed} error{}, so the cluster restored only in part",
if failed == 1 { "" } else { "s" }
plural(failed)
);
}
@@ -99,7 +106,7 @@ fn gzip_kind(ctx: &Ctx, db: &Database, file: &Path) -> Result<Kind> {
wait_until_ready(ctx, db)?;
let out = Gunzip
.pipe(&Head::bytes(HEADER_LEN))
.pipe(&Head::bytes(HEADER_LEN))?
// head closes the pipe once it has its bytes, which kills gunzip
.allow_early_close()
.in_container(&db.container)
@@ -136,7 +143,7 @@ pub fn import(ctx: &Ctx, file: &Path) -> Result<()> {
}
note!(ctx, "stopping all containers");
Stop { quiet: ctx.quiet }.run(ctx)?;
ctx.compose().stop().run(ctx)?;
// everything from here runs with the project down, so an error leaves it
// that way and the user has no reason to guess as much
@@ -159,11 +166,7 @@ fn local_path_for_docker(file: &Path) -> Result<()> {
fn imported(ctx: &Ctx, db: &Database, dump: &Dump, file: &Path) -> Result<()> {
note!(ctx, "starting db container");
Start {
service: &db.service,
quiet: ctx.quiet,
}
.run(ctx)?;
ctx.compose().start(&db.service).run(ctx)?;
let remote = remote_dump();
@@ -229,8 +232,8 @@ fn imported(ctx: &Ctx, db: &Database, dump: &Dump, file: &Path) -> Result<()> {
restored?;
note!(ctx, "restarting containers");
Stop { quiet: ctx.quiet }.run(ctx)?;
Up { quiet: ctx.quiet }.run(ctx)?;
ctx.compose().stop().run(ctx)?;
ctx.compose().up().run(ctx)?;
Ok(())
}
@@ -261,14 +264,14 @@ fn restore_dump(
.status(ctx)?;
if !status.success() {
bail!("{tool} failed, the database is left empty");
return Err(left_empty(tool));
}
return Ok(());
}
let restore: Box<dyn Cmd> = match dump {
Dump::Gzip => Box::new(Gunzip.pipe(&*restore)),
Dump::Gzip => Box::new(Gunzip.pipe(&*restore)?),
_ => restore,
};
@@ -284,7 +287,7 @@ fn restore_dump(
.stdin_from(ctx, file)?;
if !status.success() {
bail!("{tool} failed, the database is left empty");
return Err(left_empty(tool));
}
Ok(())
@@ -302,10 +305,13 @@ pub fn psql(ctx: &Ctx, rest: &[String]) -> Result<()> {
.replace(ctx)
}
pub fn dump(ctx: &Ctx, file: &Path, format: Format, gzip: bool) -> Result<()> {
pub fn dump(ctx: &Ctx, args: &cli::Dump) -> Result<()> {
let (file, format, gzip) = (args.path.as_path(), args.format, args.gzip);
let db = Database::resolve(ctx)?;
if format == Format::Directory {
// main says this as an argument error before getting here; kept as the
// last word in case anything else ever calls dump
if gzip {
bail!("a directory dump is a directory of already compressed files, not a stream");
}
@@ -329,7 +335,7 @@ pub fn dump(ctx: &Ctx, file: &Path, format: Format, gzip: bool) -> Result<()> {
let dumped = if gzip {
dumping
.pipe(&Gzip)
.pipe(&Gzip)?
.in_container(&db.container)
.stream_to(ctx, stdout)
} else {
@@ -337,19 +343,19 @@ pub fn dump(ctx: &Ctx, file: &Path, format: Format, gzip: bool) -> Result<()> {
};
if let Err(e) = dumped {
let _ = remove_file(ctx, &partial);
let _ = ctx.fs().remove_file(&partial);
return Err(e);
}
rename(ctx, &partial, file)?;
ctx.fs().rename(&partial, file)?;
Ok(())
}
// pg_dump for one database, pg_dumpall for a cluster, which has no format letter
fn dump_command(db: &Database, format: Format) -> Box<dyn Cmd + '_> {
match format.flag() {
Some(flag) => Box::new(PgDump::new(&db.user, &db.name, flag)),
match PgDump::of(&db.user, &db.name, format) {
Some(dump) => Box::new(dump),
None => Box::new(PgDumpAll { username: &db.user }),
}
}
@@ -369,7 +375,8 @@ fn dump_directory(ctx: &Ctx, db: &Database, target: &Path) -> Result<()> {
note!(ctx, "dumping to local directory {}", target.display());
let remote = remote_dump();
PgDump::new(&db.user, &db.name, "d")
PgDump::of(&db.user, &db.name, Format::Directory)
.expect("a directory dump has a pg_dump letter")
.to(&remote)
.in_container(&db.container)
.run(ctx)?;

View File

@@ -4,7 +4,7 @@ use std::time::{Duration, Instant};
use anyhow::{Result, bail};
use super::shape::Kind;
use crate::cmd::{Cmd, PgIsReady, PgRestore, Ps, Psql};
use crate::cmd::{Cmd, PgIsReady, PgRestore, Psql};
use crate::ctx::Ctx;
use crate::project::Project;
@@ -26,7 +26,7 @@ impl Database {
let service = compose.postgres()?;
let (user, name) = compose.postgres_credentials(&service)?;
let listed = Ps::id_of(&service).capture(ctx)?;
let listed = ctx.compose().ps(&service).capture(ctx)?;
let mut ids = listed.lines().map(str::trim).filter(|id| !id.is_empty());
let container = match (ids.next(), ids.next()) {

View File

@@ -2,7 +2,7 @@ use std::path::Path;
use anyhow::Result;
use crate::cmd::{Cmd, Ps};
use crate::cmd::Cmd;
use crate::commands::link;
use crate::ctx::Ctx;
use crate::output::line;
@@ -62,7 +62,7 @@ fn role(ctx: &Ctx, role: &str, detected: Result<String>, project: &Project) {
line!("{role}: {service} ({image})");
match Ps::id_of(&service).capture(ctx) {
match ctx.compose().ps(&service).capture(ctx) {
Ok(id) if id.trim().is_empty() => line!("\tcontainer: not running"),
Ok(id) => line!("\tcontainer: {}", short(id.trim())),
Err(e) => line!("\tcontainer: {e:#}"),

View File

@@ -1,6 +1,22 @@
use crate::cmd::Compose;
use crate::fsops::Fs;
// how ahab was invoked, handed to everything that runs commands or writes files
pub struct Ctx {
pub verbose: bool,
pub dry_run: bool,
pub quiet: bool,
}
impl Ctx {
// the two things these flags actually decide, asked for by name rather than
// read field by field wherever they are needed: a dry run writes nothing,
// and --quiet stops compose narrating
pub fn fs(&self) -> Fs<'_> {
Fs::new(self)
}
pub fn compose(&self) -> Compose<'_> {
Compose::new(self)
}
}

View File

@@ -16,47 +16,59 @@ pub fn suffixed(path: &Path, suffix: &str) -> PathBuf {
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(());
// the working tree, as this invocation is allowed to touch it. every write goes
// through here, so what a dry run means is settled in one place rather than at
// the top of each operation, where the next one added could simply not ask
pub struct Fs<'a> {
ctx: &'a Ctx,
}
impl<'a> Fs<'a> {
pub(crate) fn new(ctx: &'a Ctx) -> Self {
Self { ctx }
}
ensure_parent(ctx, target)?;
// a dry run writes nothing and reports that nothing went wrong, which for
// every operation here is its empty answer
fn unless_planned<T: Default>(&self, act: impl FnOnce() -> Result<T>) -> Result<T> {
match self.ctx.dry_run {
true => Ok(T::default()),
false => act(),
}
}
pub fn move_path(&self, src: &Path, target: &Path) -> Result<()> {
self.unless_planned(|| {
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(ctx, src),
Ok(()) => remove_recursive(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(());
pub fn rename(&self, src: &Path, target: &Path) -> Result<()> {
self.unless_planned(|| Ok(fs::rename(src, target)?))
}
// the parent can be missing when the store holds a path the repository does
// not have any more, and symlink would only report the bare ENOENT
ensure_parent(ctx, link_path)?;
pub fn place_link(&self, link_path: &Path, target: &Path) -> Result<()> {
self.unless_planned(|| {
// the parent can be missing when the store holds a path the
// repository does not have any more, and symlink would only report
// the bare ENOENT
ensure_parent(link_path)?;
// symlink under a temp name and rename over the path: the rename is atomic
// symlink under a temp name and rename over the path: the rename is
// atomic
let tmp = suffixed(link_path, ".ahab-tmp");
// only ahab's own leftover is cleared away: anything else here belongs to
// the project, and silently unlinking it would lose it
// only ahab's own leftover is cleared away: anything else here
// belongs to the project, and silently unlinking it would lose it
match fs::symlink_metadata(&tmp) {
Ok(meta) if meta.is_symlink() => fs::remove_file(&tmp)?,
Ok(_) => {
@@ -72,29 +84,46 @@ pub fn place_link(ctx: &Ctx, link_path: &Path, target: &Path) -> Result<()> {
symlink(target, &tmp)?;
Ok(fs::rename(&tmp, link_path)?)
}
// 0600 and only where nothing is yet. a dump is the whole database, and
// pg_dumpall's is every role's password hash, so it is not the owner's to share
// by default; create_new also refuses to follow a symlink planted at the path,
// which File::create would open and truncate
pub fn create_private_new(path: &Path) -> Result<std::fs::File> {
std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(path)
.with_context(|| format!("creating {}", path.display()))
}
// the store holds what should not be reachable from the repository, so the
// directories it is kept in are the owner's alone. only the tree at or below
// `base` is created here; what is above it is the user's own business
pub fn ensure_private_parent(ctx: &Ctx, base: &Path, target: &Path) -> Result<()> {
if ctx.dry_run {
return Ok(());
})
}
pub fn remove_file(&self, path: &Path) -> Result<()> {
self.unless_planned(|| Ok(fs::remove_file(path)?))
}
pub fn create_dir(&self, path: &Path) -> Result<()> {
self.unless_planned(|| 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(&self, path: &Path) -> Result<()> {
self.unless_planned(|| {
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(&self, path: &Path, contents: &[u8]) -> Result<()> {
self.unless_planned(|| {
let mut file = fs::File::create_new(path)?;
file.write_all(contents)?;
Ok(())
})
}
// the store holds what should not be reachable from the repository, so the
// directories it is kept in are the owner's alone. only the tree at or
// below `base` is created here; what is above it is the user's own business
pub fn ensure_private_parent(&self, base: &Path, target: &Path) -> Result<()> {
self.unless_planned(|| {
let Some(parent) = target.parent().filter(|dir| dir.starts_with(base)) else {
return Ok(());
};
@@ -105,22 +134,55 @@ pub fn ensure_private_parent(ctx: &Ctx, base: &Path, target: &Path) -> Result<()
.mode(0o700)
.create(parent)
.with_context(|| format!("creating {}", parent.display()))
}
pub fn remove_file(ctx: &Ctx, path: &Path) -> Result<()> {
if ctx.dry_run {
return Ok(());
})
}
Ok(fs::remove_file(path)?)
// a restored path can leave the store holding nothing but empty directories
pub fn prune_empty(&self, dir: Option<&Path>, stop: &Path) {
let pruned: Result<()> = self.unless_planned(|| {
let mut dir = dir;
while let Some(path) = dir {
if path == stop || !path.starts_with(stop) || fs::remove_dir(path).is_err() {
break;
}
dir = path.parent();
}
Ok(())
});
// an empty directory left behind is untidy, not a failure
let _ = pruned;
}
}
// 0600 and only where nothing is yet. a dump is the whole database, and
// pg_dumpall's is every role's password hash, so it is not the owner's to share
// by default; create_new also refuses to follow a symlink planted at the path,
// which File::create would open and truncate.
//
// not on Fs: a dry run has no file to hand back, so its one caller asks for
// this only once it knows it is writing for real
pub fn create_private_new(path: &Path) -> Result<std::fs::File> {
std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(path)
.with_context(|| format!("creating {}", path.display()))
}
fn ensure_parent(target: &Path) -> Result<()> {
match target.parent() {
Some(parent) if !parent.as_os_str().is_empty() => Ok(fs::create_dir_all(parent)?),
_ => Ok(()),
}
}
// whatever is there, file, directory or symlink
fn remove_recursive(ctx: &Ctx, path: &Path) -> Result<()> {
if ctx.dry_run {
return Ok(());
}
fn remove_recursive(path: &Path) -> Result<()> {
if fs::symlink_metadata(path)?.is_dir() {
fs::remove_dir_all(path)?;
} else {
@@ -130,70 +192,6 @@ fn remove_recursive(ctx: &Ctx, path: &Path) -> Result<()> {
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)?;

View File

@@ -73,51 +73,32 @@ fn run(ctx: &Ctx, command: cli::Commands) -> Result<ExitCode> {
match command {
cli::Postgres::Import { path } => commands::postgres::import(ctx, &path),
cli::Postgres::Psql { rest } => commands::postgres::psql(ctx, &rest),
cli::Postgres::Dump { path, format, gzip } => {
cli::Postgres::Dump(args) => {
// clap cannot say that a flag conflicts with one value of
// another, and this is still an argument error: it belongs
// with the usage and the exit code the others get
if gzip && format == cli::Format::Directory {
if args.gzip && args.format == cli::Format::Directory {
usage(
"a directory dump is a directory of already \
compressed files, not a stream",
);
}
commands::postgres::dump(ctx, &path, format, gzip)
commands::postgres::dump(ctx, &args)
}
}?;
Ok(done)
}
cli::Commands::Link { command } => match command {
cli::Link::Add {
paths,
force,
store,
} => commands::link::add(ctx, &paths, force, store.root.as_deref()).map(|()| done),
cli::Link::List { store } => {
commands::link::list(ctx, store.root.as_deref()).map(|()| done)
}
cli::Link::Restore { paths, all, store } => {
commands::link::restore(ctx, &paths, all, store.root.as_deref()).map(|()| done)
}
cli::Link::Add(args) => commands::link::add(ctx, &args).map(|()| done),
cli::Link::List(args) => commands::link::list(ctx, &args).map(|()| done),
cli::Link::Restore(args) => commands::link::restore(ctx, &args).map(|()| done),
// the one command with something to say through its exit code
cli::Link::Check {
paths,
porcelain,
null,
exit_code,
store,
} => {
let exposed =
commands::link::check(ctx, &paths, porcelain, null, store.root.as_deref())?;
match exit_code && exposed {
cli::Link::Check(args) => match commands::link::check(ctx, &args)? && args.exit_code {
true => Ok(ExitCode::from(FINDINGS)),
false => Ok(done),
}
}
},
},
cli::Commands::Status { store } => {
commands::status::status(ctx, store.root.as_deref())?;

View File

@@ -107,3 +107,11 @@ pub(crate) fn delivered() -> Result<()> {
}
pub(crate) use {line, note, text, warning};
// the `s` on a counted noun, so a message reads right for one and for many
pub(crate) fn plural(n: usize) -> &'static str {
match n {
1 => "",
_ => "s",
}
}

View File

@@ -1,7 +1,7 @@
use anyhow::{Context, Result, anyhow};
use serde_json::Value;
use crate::cmd::{Cmd, Config};
use crate::cmd::Cmd;
use crate::ctx::Ctx;
const POSTGRES_IMAGES: [&str; 4] = ["postg", "timescale", "pgvector", "citus"];
@@ -13,7 +13,7 @@ pub struct Project {
impl Project {
pub fn resolve(ctx: &Ctx) -> Result<Self> {
let json = Config.capture(ctx)?;
let json = ctx.compose().config().capture(ctx)?;
let config: Value = serde_json::from_str(&json).context("parsing docker compose config")?;
let services = config
.get("services")

262
tests/link_store.rs Normal file
View File

@@ -0,0 +1,262 @@
// what the store is for: a path a sandbox should not read living somewhere it
// cannot. every case here is one that was once wrong.
mod support;
use std::fs;
use support::Case;
#[test]
fn a_path_moves_into_the_store_and_reads_back_through_the_link() {
let case = Case::new("roundtrip");
case.write(".env", b"SECRET=1\n");
case.write("secrets/token", b"tok\n");
case.gitignore(".env\nsecrets/\n");
case.ahab(&["link", "add", ".env", "secrets"]).ok();
assert!(case.is_symlink(".env"));
assert_eq!(fs::read(case.path(".env")).unwrap(), b"SECRET=1\n");
assert_eq!(fs::read(case.path("secrets/token")).unwrap(), b"tok\n");
case.ahab(&["link", "check", "--exit-code"]).ok();
case.ahab(&["link", "restore", "--all"]).ok();
assert!(!case.is_symlink(".env"));
assert_eq!(fs::read(case.path(".env")).unwrap(), b"SECRET=1\n");
}
#[test]
fn add_refuses_a_symlink_that_already_leads_out_of_the_repository() {
let case = Case::new("launder");
fs::write(case.outside.join("key"), b"KEY\n").unwrap();
case.link(&case.path("cache"), &case.outside);
// moving the link would move the pointer and leave the contents there,
// and check would then see a link into the store and call it clean
case.ahab(&["link", "add", "cache"])
.failed()
.says("outside the repository");
case.ahab(&["link", "check", "--exit-code"]).code_is(4);
}
#[test]
fn check_reports_a_store_entry_that_leads_back_out() {
let case = Case::new("poisoned");
fs::create_dir_all(&case.store).unwrap();
fs::write(case.outside.join("key"), b"KEY\n").unwrap();
// the state an older ahab left: the store holds a way back out
case.link(&case.store.join("cache"), &case.outside);
case.link(&case.path("cache"), &case.store.join("cache"));
case.ahab(&["link", "check", "--exit-code"]).code_is(4);
}
#[test]
fn check_reports_a_tracked_symlink_leading_out() {
let case = Case::new("tracked-link");
fs::create_dir_all(case.outside.join("aws")).unwrap();
case.link(&case.path("awsdir"), &case.outside.join("aws"));
case.git(&["add", "-f", "awsdir"]);
case.git(&["commit", "-qm", "commit a symlink out"]);
// git tracks symlinks, so this is in no untracked or ignored listing
case.ahab(&["link", "check", "--exit-code"]).code_is(4);
case.ahab(&["link", "check", "--porcelain"]).says("T>");
// and add cannot fix it, so saying so is all check can do
case.ahab(&["link", "add", "awsdir"])
.failed()
.says("tracked by git");
}
#[test]
fn a_stored_symlink_is_one_entry_rather_than_a_tree_to_walk() {
let case = Case::new("walk-out");
fs::create_dir_all(case.outside.join("private")).unwrap();
fs::write(case.outside.join("private/diary"), b"x\n").unwrap();
fs::write(case.outside.join(".netrc"), b"x\n").unwrap();
fs::create_dir_all(&case.store).unwrap();
case.link(&case.store.join("cache"), &case.outside);
let run = case.ahab(&["link", "list"]);
run.ok().says("cache");
// is_dir() would follow the link and enumerate what is behind it
run.silent_about(".netrc");
run.silent_about("diary");
}
#[test]
fn a_store_inside_the_repository_is_refused_however_it_is_spelled() {
let case = Case::new("store-inside");
case.write(".env", b"SECRET=1\n");
case.gitignore(".env\n");
// named directly
case.ahab(&["link", "add", "--store", "./within", ".env"])
.failed()
.says("inside the repository");
// and reached through a symlink, which a prefix test does not catch
case.mkdir("within");
let sneaky = case.repo.parent().unwrap().join("sneaky");
case.link(&sneaky, &case.path("within"));
let run = case.ahab(&[
"link".as_ref(),
"add".as_ref(),
"--store".as_ref(),
sneaky.as_os_str(),
".env".as_ref(),
]);
run.failed().says("inside the repository");
assert!(!case.is_symlink(".env"));
}
#[test]
fn a_relative_store_root_still_resolves_from_anywhere() {
let case = Case::new("relative-store");
case.write("sub/.env", b"SECRET=1\n");
case.gitignore("sub/.env\n");
case.ahab(&["link", "add", "--store", "../store", "sub/.env"])
.ok();
// the target is written into the symlink, so a relative one would resolve
// from the link's own directory rather than the working one
assert!(case.is_symlink("sub/.env"));
assert_eq!(fs::read(case.path("sub/.env")).unwrap(), b"SECRET=1\n");
}
#[test]
fn the_store_directories_are_the_owners_alone() {
let case = Case::new("modes");
case.write("deep/nested/.env", b"SECRET=1\n");
case.gitignore("deep\n");
case.ahab(&["link", "add", "deep/nested/.env"]).ok();
for dir in [
case.store.as_path(),
&case.store.join("deep"),
&case.store.join("deep/nested"),
] {
assert_eq!(case.mode(dir), 0o700, "{}", dir.display());
}
}
#[test]
fn a_failed_link_puts_the_payload_back() {
let case = Case::new("rollback");
case.write("x.env", b"SECRET=1\n");
// ahab's own temp name, but a real file: it must not be removed, and the
// payload must not be left in the store with nothing pointing at it
case.write("x.env.ahab-tmp", b"THE PROJECT OWNS THIS\n");
case.gitignore("x.env\n");
case.ahab(&["link", "add", "x.env"]).failed();
assert!(!case.is_symlink("x.env"));
assert_eq!(fs::read(case.path("x.env")).unwrap(), b"SECRET=1\n");
assert_eq!(
fs::read(case.path("x.env.ahab-tmp")).unwrap(),
b"THE PROJECT OWNS THIS\n"
);
assert!(!case.store.join("x.env").exists());
}
#[test]
fn a_directory_of_symlinks_out_keeps_its_destinations() {
let case = Case::new("collapse");
fs::write(case.outside.join("a"), b"A\n").unwrap();
case.link(&case.path("bundle/one"), &case.outside.join("a"));
// collapsing to `bundle/` would drop both the destination and the code
let run = case.ahab(&["link", "check", "--porcelain"]);
run.code_is(0).says("?>").says("bundle/one");
}
#[test]
fn a_filename_that_is_not_utf_8_is_reported_and_moved_as_itself() {
use std::ffi::OsStr;
use std::os::unix::ffi::OsStrExt;
let case = Case::new("latin1");
let name = OsStr::from_bytes(b"caf\xe9.env");
fs::write(case.repo.join(name), b"SECRET=1\n").unwrap();
// the whole listing used to fail on the one entry
let run = case.ahab(&["link", "check", "-z"]);
run.code_is(0);
assert!(
run.stdout.windows(4).any(|w| w == b"caf\xe9"),
"-z must write the bytes the name actually has"
);
case.gitignore("caf\u{e9}.env\n");
case.ahab(&["link".as_ref(), "add".as_ref(), name]).ok();
assert!(
fs::symlink_metadata(case.repo.join(name))
.unwrap()
.is_symlink()
);
}
#[test]
fn a_tracked_filename_that_is_not_utf_8_is_still_refused() {
use std::ffi::OsStr;
use std::os::unix::ffi::OsStrExt;
let case = Case::new("latin1-tracked");
let name = OsStr::from_bytes(b"caf\xe9.env");
fs::write(case.repo.join(name), b"TRACKED\n").unwrap();
case.git(&["add".as_ref(), "-f".as_ref(), name]);
case.git(&["commit", "-qm", "track a latin-1 name"]);
// asked about lossily, git answers about a path nothing has and says
// "not tracked", and a tracked file leaves the working tree
case.ahab(&["link".as_ref(), "add".as_ref(), name])
.failed()
.says("tracked by git");
assert!(
!fs::symlink_metadata(case.repo.join(name))
.unwrap()
.is_symlink()
);
}
#[test]
fn a_pathspec_is_asked_about_as_a_name_not_a_pattern() {
let case = Case::new("pathspec");
// git reads pathspec magic after `--` too, so this used to have git list
// every tracked file *except* the named one, which read as "it is tracked"
case.write(":!untracked.env", b"SECRET=1\n");
case.gitignore(":!untracked.env\n");
case.ahab(&["link", "add", ":!untracked.env"]).ok();
assert!(case.is_symlink(":!untracked.env"));
}
#[test]
fn distinct_remotes_do_not_share_one_store_directory() {
let case = Case::new("remotes");
case.git(&[
"remote",
"set-url",
"origin",
"https://git.example.org/a/my_api",
]);
let plain = case.ahab(&["link", "list"]).out();
case.git(&[
"remote",
"set-url",
"origin",
"https://git.example.org/a/my~api",
]);
let awkward = case.ahab(&["link", "list"]).out();
assert_ne!(plain, awkward);
// the ordinary remote keeps the path it already had
assert!(plain.contains("/a/my_api"), "{plain}");
}

99
tests/output_io.rs Normal file
View File

@@ -0,0 +1,99 @@
// what a command reports and how it exits, including when the write fails.
mod support;
use std::fs;
use support::Case;
#[test]
fn findings_are_reported_through_the_exit_code() {
let case = Case::new("findings");
case.write("loose.txt", b"x\n");
case.ahab(&["link", "check"]).ok();
case.ahab(&["link", "check", "--exit-code"]).code_is(4);
}
#[test]
fn an_argument_error_exits_two() {
let case = Case::new("usage");
case.ahab(&["link", "restore"]).code_is(2);
case.ahab(&["link", "restore", "--all", "some/path"])
.code_is(2);
case.ahab(&["postgres", "dump", "-F", "directory", "-z", "d"])
.code_is(2);
case.ahab(&["nonsense"]).code_is(2);
}
#[test]
fn a_write_that_cannot_be_delivered_is_a_failure_rather_than_a_success() {
let case = Case::new("devfull");
for n in 0..200 {
case.write(&format!("file-{n}.txt"), b"x\n");
}
// /dev/full accepts the open and fails the write. exiting 0 there would
// report a listing that was never delivered
let Ok(full) = fs::File::create("/dev/full") else {
eprintln!("skipped: this system has no /dev/full to fail a write against");
return;
};
let out = std::process::Command::new(env!("CARGO_BIN_EXE_ahab"))
.current_dir(&case.repo)
.env("XDG_DATA_HOME", case.repo.join("../xdg"))
.args(["link", "check", "--exit-code", "--porcelain"])
.stdout(full)
.output()
.expect("running ahab");
assert_eq!(out.status.code(), Some(1), "{:?}", out.status);
assert!(
String::from_utf8_lossy(&out.stderr).contains("writing to stdout failed"),
"{}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn every_named_path_is_moved_even_when_the_reader_leaves() {
let case = Case::new("partial");
for name in ["a.env", "b.env", "c.env"] {
case.write(name, b"x\n");
}
case.gitignore("a.env\nb.env\nc.env\n");
// a closed stdout must stop the writing, not the moving: the command had
// planned to move all three and reported success for doing so
let run = case.ahab(&["link", "add", "a.env", "b.env", "c.env"]);
run.ok();
for name in ["a.env", "b.env", "c.env"] {
assert!(case.is_symlink(name), "{name} was left behind");
}
}
#[test]
fn quiet_keeps_the_reason_a_path_failed() {
let case = Case::new("quiet");
case.write("ok.env", b"x\n");
case.gitignore("ok.env\n");
let run = case.ahab(&["-q", "link", "add", "ok.env", "missing.env"]);
run.failed()
.says("1 of 2 paths failed")
// the line saying *which* and *why* is a result, not progress
.says("missing.env");
}
#[test]
fn a_dry_run_changes_nothing_on_disk() {
let case = Case::new("dryrun");
case.write(".env", b"SECRET=1\n");
case.gitignore(".env\n");
case.ahab(&["--dry-run", "link", "add", ".env"]).ok();
assert!(!case.is_symlink(".env"));
assert!(!case.store.join(".env").exists());
}

207
tests/support/mod.rs Normal file
View File

@@ -0,0 +1,207 @@
// enough scaffolding to run ahab against a throwaway repository, hand-rolled
// rather than pulled in: the crate has no dependencies it does not need, and a
// dev-dependency is still something to trust and keep current.
// each integration test file compiles this module separately, so a helper only
// one of them needs looks unused to the others
#![allow(dead_code)]
use std::ffi::OsStr;
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::sync::atomic::{AtomicU32, Ordering};
use std::{env, fs};
// unique per case even with the suite running in parallel
static NEXT: AtomicU32 = AtomicU32::new(0);
pub struct Case {
root: PathBuf,
pub repo: PathBuf,
pub store: PathBuf,
pub outside: PathBuf,
}
impl Case {
// a repository with an origin remote, a store of its own, and one commit
pub fn new(label: &str) -> Self {
let root = env::temp_dir().join(format!(
"ahab-{label}-{}-{}",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed)
));
let _ = fs::remove_dir_all(&root);
let case = Self {
repo: root.join("repo"),
store: root
.join("xdg/ahab/git.example.org/acme/proj")
.to_path_buf(),
outside: root.join("outside"),
root,
};
for dir in [&case.repo, &case.outside] {
fs::create_dir_all(dir).expect("creating the case directories");
}
case.git(&["init", "-q", "."]);
case.git(&["config", "user.email", "test@example.org"]);
case.git(&["config", "user.name", "test"]);
case.git(&["config", "commit.gpgsign", "false"]);
case.git(&[
"remote",
"add",
"origin",
"https://git.example.org/acme/proj.git",
]);
case.write("tracked.txt", b"tracked\n");
case.git(&["add", "tracked.txt"]);
case.git(&["commit", "-qm", "init"]);
case
}
pub fn git<S: AsRef<OsStr>>(&self, args: &[S]) -> Output {
let out = Command::new("git")
.current_dir(&self.repo)
.args(args)
.output()
.expect("running git");
assert!(
out.status.success(),
"git {:?} failed: {}",
args.iter()
.map(|a| a.as_ref().to_string_lossy())
.collect::<Vec<_>>(),
String::from_utf8_lossy(&out.stderr)
);
out
}
// ahab, in the repository, with a store nothing else shares
pub fn ahab<S: AsRef<OsStr>>(&self, args: &[S]) -> Run {
let out = Command::new(env!("CARGO_BIN_EXE_ahab"))
.current_dir(&self.repo)
.env("XDG_DATA_HOME", self.root.join("xdg"))
// the git commands ahab runs must not read the developer's own config
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.args(args)
.output()
.expect("running ahab");
Run {
code: out.status.code(),
stdout: out.stdout,
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
}
}
pub fn path(&self, rel: &str) -> PathBuf {
self.repo.join(rel)
}
pub fn write(&self, rel: &str, contents: &[u8]) {
let path = self.path(rel);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("creating a parent directory");
}
fs::write(path, contents).expect("writing a file");
}
pub fn mkdir(&self, rel: &str) {
fs::create_dir_all(self.path(rel)).expect("creating a directory");
}
pub fn link(&self, at: &Path, to: &Path) {
if let Some(parent) = at.parent() {
fs::create_dir_all(parent).expect("creating a parent directory");
}
std::os::unix::fs::symlink(to, at).expect("creating a symlink");
}
pub fn gitignore(&self, lines: &str) {
self.write(".gitignore", lines.as_bytes());
self.git(&["add", ".gitignore"]);
self.git(&["commit", "-qm", "ignore"]);
}
pub fn is_symlink(&self, rel: &str) -> bool {
fs::symlink_metadata(self.path(rel)).is_ok_and(|meta| meta.is_symlink())
}
pub fn mode(&self, path: &Path) -> u32 {
use std::os::unix::fs::PermissionsExt;
fs::symlink_metadata(path)
.expect("reading a mode")
.permissions()
.mode()
& 0o777
}
}
impl Drop for Case {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.root);
}
}
pub struct Run {
pub code: Option<i32>,
pub stdout: Vec<u8>,
pub stderr: String,
}
impl Run {
pub fn out(&self) -> String {
String::from_utf8_lossy(&self.stdout).into_owned()
}
pub fn ok(&self) -> &Self {
assert_eq!(self.code, Some(0), "{}", self.report());
self
}
pub fn failed(&self) -> &Self {
assert_ne!(self.code, Some(0), "{}", self.report());
self
}
pub fn code_is(&self, want: i32) -> &Self {
assert_eq!(self.code, Some(want), "{}", self.report());
self
}
pub fn says(&self, needle: &str) -> &Self {
assert!(
self.out().contains(needle) || self.stderr.contains(needle),
"expected {needle:?}\n{}",
self.report()
);
self
}
pub fn silent_about(&self, needle: &str) -> &Self {
assert!(
!self.out().contains(needle) && !self.stderr.contains(needle),
"did not expect {needle:?}\n{}",
self.report()
);
self
}
fn report(&self) -> String {
let mut out = String::new();
let _ = write!(
out,
"exit: {:?}\n--- stdout\n{}--- stderr\n{}",
self.code,
self.out(),
self.stderr
);
out
}
}