merge: structural code changes
This commit is contained in:
@@ -2,64 +2,80 @@ use std::path::PathBuf;
|
|||||||
|
|
||||||
use clap::{Args, Subcommand};
|
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)]
|
#[derive(Subcommand, Debug)]
|
||||||
pub enum Link {
|
pub enum Link {
|
||||||
/// Move untracked paths into the store and symlink them back
|
/// 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
|
/// Untracked or ignored paths inside the repository
|
||||||
#[arg(required = true)]
|
#[arg(required = true)]
|
||||||
paths: Vec<PathBuf>,
|
pub paths: Vec<PathBuf>,
|
||||||
|
|
||||||
/// Link to a store path that already exists
|
/// Link to a store path that already exists
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
force: bool,
|
pub force: bool,
|
||||||
|
|
||||||
#[command(flatten)]
|
#[command(flatten)]
|
||||||
store: Store,
|
pub store: Store,
|
||||||
},
|
}
|
||||||
|
|
||||||
/// Move paths in the store back into the repository
|
#[derive(Args, Debug)]
|
||||||
Restore {
|
pub struct Restore {
|
||||||
// said here rather than checked at runtime, so a mistake in the
|
// 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
|
// arguments is reported as one, with the usage and the exit code clap
|
||||||
// gives every other argument error
|
// gives every other argument error
|
||||||
#[arg(required_unless_present = "all", conflicts_with = "all")]
|
#[arg(required_unless_present = "all", conflicts_with = "all")]
|
||||||
paths: Vec<PathBuf>,
|
pub paths: Vec<PathBuf>,
|
||||||
|
|
||||||
/// Restore every path this repository has in the store
|
/// Restore every path this repository has in the store
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
all: bool,
|
pub all: bool,
|
||||||
|
|
||||||
#[command(flatten)]
|
#[command(flatten)]
|
||||||
store: Store,
|
pub store: Store,
|
||||||
},
|
}
|
||||||
|
|
||||||
/// List what this repository keeps in the store
|
#[derive(Args, Debug)]
|
||||||
List {
|
pub struct List {
|
||||||
#[command(flatten)]
|
#[command(flatten)]
|
||||||
store: Store,
|
pub store: Store,
|
||||||
},
|
}
|
||||||
|
|
||||||
/// List untracked paths a sandbox would still see
|
#[derive(Args, Debug)]
|
||||||
Check {
|
pub struct Check {
|
||||||
/// Limit the listing to these paths
|
/// Limit the listing to these paths
|
||||||
paths: Vec<PathBuf>,
|
pub paths: Vec<PathBuf>,
|
||||||
|
|
||||||
/// Print `<code> <path>` for scripts, as `git status --porcelain` does
|
/// Print `<code> <path>` for scripts, as `git status --porcelain` does
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
porcelain: bool,
|
pub porcelain: bool,
|
||||||
|
|
||||||
/// Exit with 4 when anything is outside the store, for scripts
|
/// Exit with 4 when anything is outside the store, for scripts
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
exit_code: bool,
|
pub exit_code: bool,
|
||||||
|
|
||||||
/// Terminate porcelain entries with NUL
|
/// Terminate porcelain entries with NUL
|
||||||
#[arg(short = 'z')]
|
#[arg(short = 'z')]
|
||||||
null: bool,
|
pub null: bool,
|
||||||
|
|
||||||
#[command(flatten)]
|
#[command(flatten)]
|
||||||
store: Store,
|
pub store: Store,
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Args, Debug)]
|
#[derive(Args, Debug)]
|
||||||
@@ -70,3 +86,9 @@ pub struct Store {
|
|||||||
#[arg(long = "store", env = "AHAB_LINK_ROOT", value_name = "DIR")]
|
#[arg(long = "store", env = "AHAB_LINK_ROOT", value_name = "DIR")]
|
||||||
pub root: Option<PathBuf>,
|
pub root: Option<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Store {
|
||||||
|
pub fn root(&self) -> Option<&std::path::Path> {
|
||||||
|
self.root.as_deref()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
// build.rs reaches these definitions with include!, so nothing here may refer to
|
// 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
|
// the rest of the crate: keep this tree to clap definitions only
|
||||||
mod ahab;
|
pub mod ahab;
|
||||||
mod django;
|
pub mod django;
|
||||||
mod link;
|
pub mod link;
|
||||||
mod postgres;
|
pub mod postgres;
|
||||||
|
|
||||||
pub use ahab::{Ahab, Commands};
|
pub use ahab::{Ahab, Commands};
|
||||||
pub use django::Django;
|
pub use django::Django;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use clap::{Subcommand, ValueEnum};
|
use clap::{Args, Subcommand, ValueEnum};
|
||||||
|
|
||||||
#[derive(Subcommand, Debug)]
|
#[derive(Subcommand, Debug)]
|
||||||
pub enum Postgres {
|
pub enum Postgres {
|
||||||
@@ -15,17 +15,20 @@ pub enum Postgres {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/// Dump via pg_dump, or pg_dumpall for a whole cluster
|
/// Dump via pg_dump, or pg_dumpall for a whole cluster
|
||||||
Dump {
|
Dump(Dump),
|
||||||
path: PathBuf,
|
}
|
||||||
|
|
||||||
|
#[derive(Args, Debug)]
|
||||||
|
pub struct Dump {
|
||||||
|
pub path: PathBuf,
|
||||||
|
|
||||||
/// Dump format
|
/// Dump format
|
||||||
#[arg(short = 'F', long, value_enum, default_value_t = Format::Custom)]
|
#[arg(short = 'F', long, value_enum, default_value_t = Format::Custom)]
|
||||||
format: Format,
|
pub format: Format,
|
||||||
|
|
||||||
/// Compress the dump with gzip
|
/// Compress the dump with gzip
|
||||||
#[arg(short = 'z', long)]
|
#[arg(short = 'z', long)]
|
||||||
gzip: bool,
|
pub gzip: bool,
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(ValueEnum, Clone, Copy, Debug, Default, PartialEq)]
|
#[derive(ValueEnum, Clone, Copy, Debug, Default, PartialEq)]
|
||||||
@@ -51,16 +54,3 @@ pub enum Format {
|
|||||||
#[value(alias = "all")]
|
#[value(alias = "all")]
|
||||||
Cluster,
|
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -65,9 +65,8 @@ impl Argv {
|
|||||||
&self.0
|
&self.0
|
||||||
}
|
}
|
||||||
|
|
||||||
// one word list for `sh -c`, quoted so a value with a space survives the
|
// for reading: the -v echo, the dry run's plan, and the command named in an
|
||||||
// shell. lossy: this is for reading, and for scripts built only out of the
|
// error. lossy, because a message about a path is still worth printing
|
||||||
// ascii and compose-derived words that reach a pipeline
|
|
||||||
pub fn quoted(&self) -> String {
|
pub fn quoted(&self) -> String {
|
||||||
let words: Vec<String> = self
|
let words: Vec<String> = self
|
||||||
.0
|
.0
|
||||||
@@ -82,6 +81,28 @@ impl Argv {
|
|||||||
shlex::try_join(words.iter().map(String::as_str)).unwrap_or_else(|_| words.join(" "))
|
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<()> {
|
pub fn run(&self, ctx: &Ctx) -> Result<()> {
|
||||||
if self.skipped(ctx) {
|
if self.skipped(ctx) {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use super::{Argv, Cmd};
|
use super::{Argv, Cmd};
|
||||||
|
use crate::ctx::Ctx;
|
||||||
|
|
||||||
// compose prints its own progress, which --quiet has to ask it to stop
|
// compose prints its own progress, which --quiet has to ask it to stop
|
||||||
fn compose(quiet: bool) -> Argv {
|
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
|
// docker compose run --rm, which runs the image's entrypoint and so fixes up the
|
||||||
// container user before handing over
|
// container user before handing over
|
||||||
pub struct Run {
|
pub struct Run {
|
||||||
@@ -19,20 +63,15 @@ pub struct Run {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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 {
|
Self {
|
||||||
service: service.to_string(),
|
service: service.to_string(),
|
||||||
inner,
|
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 {
|
impl Cmd for Run {
|
||||||
@@ -59,14 +98,6 @@ pub struct Ps {
|
|||||||
service: String,
|
service: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Ps {
|
|
||||||
pub fn id_of(service: &str) -> Self {
|
|
||||||
Self {
|
|
||||||
service: service.to_string(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Cmd for Ps {
|
impl Cmd for Ps {
|
||||||
fn argv(&self) -> Argv {
|
fn argv(&self) -> Argv {
|
||||||
compose(false).arg("ps").arg("--quiet").arg(&self.service)
|
compose(false).arg("ps").arg("--quiet").arg(&self.service)
|
||||||
@@ -74,7 +105,7 @@ impl Cmd for Ps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct Up {
|
pub struct Up {
|
||||||
pub quiet: bool,
|
quiet: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Cmd for Up {
|
impl Cmd for Up {
|
||||||
@@ -84,8 +115,8 @@ impl Cmd for Up {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct Start<'a> {
|
pub struct Start<'a> {
|
||||||
pub service: &'a str,
|
service: &'a str,
|
||||||
pub quiet: bool,
|
quiet: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Cmd for Start<'_> {
|
impl Cmd for Start<'_> {
|
||||||
@@ -95,7 +126,7 @@ impl Cmd for Start<'_> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct Stop {
|
pub struct Stop {
|
||||||
pub quiet: bool,
|
quiet: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Cmd for Stop {
|
impl Cmd for Stop {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ use std::{
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
|
||||||
pub use argv::Argv;
|
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 django::{Bash, Manage, Words};
|
||||||
pub use docker::{Cp, Exec};
|
pub use docker::{Cp, Exec};
|
||||||
pub use git::{CheckIgnore, ConfigGet, LsFiles, RevParse};
|
pub use git::{CheckIgnore, ConfigGet, LsFiles, RevParse};
|
||||||
@@ -30,22 +30,25 @@ pub trait Cmd {
|
|||||||
fn argv(&self) -> Argv;
|
fn argv(&self) -> Argv;
|
||||||
|
|
||||||
// this command as one word list, for a shell to read
|
// this command as one word list, for a shell to read
|
||||||
fn shell(&self) -> String {
|
fn shell(&self) -> Result<String> {
|
||||||
self.argv().quoted()
|
self.argv().for_shell()
|
||||||
}
|
}
|
||||||
|
|
||||||
// adapters
|
// adapters
|
||||||
fn pipe(&self, next: &dyn Cmd) -> Pipeline {
|
fn pipe(&self, next: &dyn Cmd) -> Result<Pipeline> {
|
||||||
Pipeline::starting(self.shell()).pipe(next)
|
Pipeline::starting(self.shell()?).pipe(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn in_container(&self, container: &str) -> Exec {
|
fn in_container(&self, container: &str) -> Exec {
|
||||||
Exec::wrapping(container, self.argv())
|
Exec::wrapping(container, self.argv())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn in_service(&self, service: &str) -> Run {
|
// the context comes in here because compose narrates what it is doing, and
|
||||||
Run::wrapping(service, self.argv())
|
// 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
|
// terminals
|
||||||
fn run(&self, ctx: &Ctx) -> Result<()> {
|
fn run(&self, ctx: &Ctx) -> Result<()> {
|
||||||
self.argv().run(ctx)
|
self.argv().run(ctx)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use super::{Argv, Cmd};
|
use super::{Argv, Cmd};
|
||||||
|
use crate::cli::Format;
|
||||||
|
|
||||||
// whether the server is accepting connections yet
|
// whether the server is accepting connections yet
|
||||||
pub struct PgIsReady<'a> {
|
pub struct PgIsReady<'a> {
|
||||||
@@ -156,13 +157,25 @@ pub struct PgDump<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> PgDump<'a> {
|
impl<'a> PgDump<'a> {
|
||||||
pub fn new(username: &'a str, dbname: &'a str, format: &'a str) -> Self {
|
// pg_dump's -F letter, which belongs with the rest of what this module
|
||||||
Self {
|
// 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,
|
username,
|
||||||
dbname,
|
dbname,
|
||||||
format,
|
format,
|
||||||
to: None,
|
to: None,
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// a path in the container, for the directory format, which pg_dump writes
|
// a path in the container, for the directory format, which pg_dump writes
|
||||||
@@ -201,7 +214,7 @@ impl Cmd for PgDumpAll<'_> {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{DropDb, PgDump, Psql};
|
use super::{DropDb, Format, PgDump, Psql};
|
||||||
use crate::cmd::Cmd;
|
use crate::cmd::Cmd;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -219,16 +232,35 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_directory_dump_names_the_file_it_writes() {
|
fn a_directory_dump_names_the_file_it_writes() {
|
||||||
|
let directory = PgDump::of("u", "db", Format::Directory).unwrap();
|
||||||
|
|
||||||
assert_eq!(
|
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"
|
"pg_dump --username u --format d --file /tmp/dump -- db"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
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"
|
"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]
|
#[test]
|
||||||
fn only_a_single_database_restore_stops_at_the_first_error() {
|
fn only_a_single_database_restore_stops_at_the_first_error() {
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
use super::{Argv, Cmd};
|
use super::{Argv, Cmd};
|
||||||
|
|
||||||
// sh -c, the only way to reach a shell feature inside a container
|
// 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 {
|
pub fn pipe(mut self, next: &dyn Cmd) -> Result<Self> {
|
||||||
self.stages.push(next.shell());
|
self.stages.push(next.shell()?);
|
||||||
self
|
Ok(self)
|
||||||
}
|
}
|
||||||
|
|
||||||
// for a pipeline whose last stage closes the pipe on purpose, where the
|
// for a pipeline whose last stage closes the pipe on purpose, where the
|
||||||
@@ -119,21 +121,26 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_pipeline_reports_a_stage_that_dies_mid_stream() {
|
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'");
|
assert_eq!(script, "sh -c 'set -o pipefail; gunzip -c | gzip'");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_pipeline_that_closes_the_pipe_on_purpose_keeps_the_default() {
|
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'");
|
assert_eq!(script, "sh -c 'gunzip -c | head -c 512'");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_pipeline_reaches_a_container_as_one_argument() {
|
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!(
|
assert_eq!(
|
||||||
argv.words(),
|
argv.words(),
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use anyhow::{Result, anyhow};
|
|||||||
|
|
||||||
use crate::cmd::{Bash, Cmd, Manage, Words};
|
use crate::cmd::{Bash, Cmd, Manage, Words};
|
||||||
use crate::ctx::Ctx;
|
use crate::ctx::Ctx;
|
||||||
use crate::fsops::{create_dir, touch, write_new};
|
|
||||||
use crate::output::note;
|
use crate::output::note;
|
||||||
use crate::project::Project;
|
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");
|
let management_dir = app_dir.join("management");
|
||||||
|
|
||||||
if !management_dir.exists() {
|
if !management_dir.exists() {
|
||||||
create_dir(ctx, &management_dir)?;
|
ctx.fs().create_dir(&management_dir)?;
|
||||||
touch(ctx, &management_dir.join("__init__.py"))?;
|
ctx.fs().touch(&management_dir.join("__init__.py"))?;
|
||||||
|
|
||||||
note!(ctx, "created module {app_name}.management")
|
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");
|
let commands_dir = management_dir.join("commands");
|
||||||
|
|
||||||
if !commands_dir.exists() {
|
if !commands_dir.exists() {
|
||||||
create_dir(ctx, &commands_dir)?;
|
ctx.fs().create_dir(&commands_dir)?;
|
||||||
touch(ctx, &commands_dir.join("__init__.py"))?;
|
ctx.fs().touch(&commands_dir.join("__init__.py"))?;
|
||||||
|
|
||||||
note!(ctx, "created module {app_name}.management.commands")
|
note!(ctx, "created module {app_name}.management.commands")
|
||||||
};
|
};
|
||||||
|
|
||||||
write_new(
|
ctx.fs().write_new(
|
||||||
ctx,
|
|
||||||
&commands_dir.join(format!("{name}.py")),
|
&commands_dir.join(format!("{name}.py")),
|
||||||
DEBUG_TEMPLATE.as_bytes(),
|
DEBUG_TEMPLATE.as_bytes(),
|
||||||
)?;
|
)?;
|
||||||
@@ -72,22 +71,18 @@ fn is_module_name(name: &str) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn bash(ctx: &Ctx) -> Result<()> {
|
pub fn bash(ctx: &Ctx) -> Result<()> {
|
||||||
Bash.in_service(&service(ctx)?)
|
Bash.in_service(ctx, &service(ctx)?).replace(ctx)
|
||||||
.quiet(ctx.quiet)
|
|
||||||
.replace(ctx)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run(ctx: &Ctx, rest: &[String]) -> Result<()> {
|
pub fn run(ctx: &Ctx, rest: &[String]) -> Result<()> {
|
||||||
Words::new(rest)
|
Words::new(rest)
|
||||||
.in_service(&service(ctx)?)
|
.in_service(ctx, &service(ctx)?)
|
||||||
.quiet(ctx.quiet)
|
|
||||||
.replace(ctx)
|
.replace(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn manage(ctx: &Ctx, rest: &[String]) -> Result<()> {
|
pub fn manage(ctx: &Ctx, rest: &[String]) -> Result<()> {
|
||||||
Manage::new(rest)
|
Manage::new(rest)
|
||||||
.in_service(&service(ctx)?)
|
.in_service(ctx, &service(ctx)?)
|
||||||
.quiet(ctx.quiet)
|
|
||||||
.replace(ctx)
|
.replace(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,49 +10,62 @@ use std::path::{Path, PathBuf};
|
|||||||
use anyhow::{Context, Result, anyhow, bail};
|
use anyhow::{Context, Result, anyhow, bail};
|
||||||
|
|
||||||
use self::store::{Leads, Repo, ignored, leads, resolve, symlink_metadata_opt, tracked};
|
use self::store::{Leads, Repo, ignored, leads, resolve, symlink_metadata_opt, tracked};
|
||||||
|
use crate::cli::link as cli;
|
||||||
use crate::ctx::Ctx;
|
use crate::ctx::Ctx;
|
||||||
use crate::fsops::{
|
use crate::fsops::suffixed;
|
||||||
ensure_private_parent, move_path, place_link, prune_empty, remove_file, rename, suffixed,
|
use crate::output::{line, note, plural, warning};
|
||||||
};
|
|
||||||
use crate::output::{line, note, warning};
|
|
||||||
|
|
||||||
const BACKUP_SUFFIX: &str = ".ahab-bak";
|
const BACKUP_SUFFIX: &str = ".ahab-bak";
|
||||||
// where the link waits while the payload comes back out of the store
|
// where the link waits while the payload comes back out of the store
|
||||||
const RESTORING_SUFFIX: &str = ".ahab-restoring";
|
const RESTORING_SUFFIX: &str = ".ahab-restoring";
|
||||||
|
|
||||||
// move untracked paths out of the repo and symlink them back
|
// one path's failure is its own; several are counted, so a run over a list says
|
||||||
pub fn add(ctx: &Ctx, paths: &[PathBuf], force: bool, store: Option<&Path>) -> Result<()> {
|
// what happened to each and then how the run as a whole went. a single path has
|
||||||
let repo = Repo::discover(ctx, store)?;
|
// nothing to count, so its error is simply the command's
|
||||||
let report = Report::new(&repo);
|
fn each(paths: &[PathBuf], mut act: impl FnMut(&Path) -> Result<()>) -> Result<()> {
|
||||||
|
if let [only] = paths {
|
||||||
if let [path] = paths {
|
return act(only);
|
||||||
return link_one(ctx, &repo, path, force, &report);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut failed = 0;
|
let mut failed = 0;
|
||||||
for path in paths {
|
for path in paths {
|
||||||
if let Err(e) = link_one(ctx, &repo, path, force, &report) {
|
if let Err(e) = act(path) {
|
||||||
warning!("{e:#}");
|
warning!("{e:#}");
|
||||||
failed += 1;
|
failed += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if failed > 0 {
|
match failed {
|
||||||
return Err(anyhow!("{failed} of {} paths failed", paths.len()));
|
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
|
// 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<()> {
|
pub fn restore(ctx: &Ctx, args: &cli::Restore) -> Result<()> {
|
||||||
let repo = Repo::discover(ctx, store)?;
|
let repo = Repo::discover(ctx, args.store.root())?;
|
||||||
let report = Report::new(&repo);
|
let report = Report::new(&repo);
|
||||||
|
|
||||||
// clap requires one or the other and refuses both, so only the two real
|
// clap requires one or the other and refuses both, so only the two real
|
||||||
// cases are left here
|
// cases are left here
|
||||||
let stored = match all {
|
let stored = match args.all {
|
||||||
true => stored_paths(&repo, &repo.store)?,
|
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
|
// 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!(
|
n => note!(
|
||||||
ctx,
|
ctx,
|
||||||
"nothing to restore: {n} path{} in the store {} not linked, see `ahab link list`",
|
"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" }
|
if n == 1 { "is" } else { "are" }
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
@@ -81,26 +94,11 @@ pub fn restore(ctx: &Ctx, paths: &[PathBuf], all: bool, store: Option<&Path>) ->
|
|||||||
note!(
|
note!(
|
||||||
ctx,
|
ctx,
|
||||||
"leaving {skipped} path{} that the store holds but nothing links to",
|
"leaving {skipped} path{} that the store holds but nothing links to",
|
||||||
if skipped == 1 { "" } else { "s" }
|
plural(skipped)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let [path] = linked.as_slice() {
|
each(&linked, |path| restore_one(ctx, &repo, path, &report))
|
||||||
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(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn restore_one(ctx: &Ctx, repo: &Repo, path: &Path, report: &Report) -> Result<()> {
|
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());
|
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) {
|
if let Err(e) = ctx.fs().move_path(&stored, &src) {
|
||||||
rename(ctx, &aside, &src).with_context(|| {
|
ctx.fs().rename(&aside, &src).with_context(|| {
|
||||||
format!(
|
format!(
|
||||||
"could not put the link at {} back after failing to restore it",
|
"could not put the link at {} back after failing to restore it",
|
||||||
src.display()
|
src.display()
|
||||||
@@ -150,8 +148,8 @@ fn restore_one(ctx: &Ctx, repo: &Repo, path: &Path, report: &Report) -> Result<(
|
|||||||
return Err(e);
|
return Err(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
remove_file(ctx, &aside)?;
|
ctx.fs().remove_file(&aside)?;
|
||||||
prune_empty(ctx, stored.parent(), &repo.base);
|
ctx.fs().prune_empty(stored.parent(), &repo.base);
|
||||||
|
|
||||||
report.line("restored", &rel);
|
report.line("restored", &rel);
|
||||||
Ok(())
|
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
|
// list what the store holds for this repository, the inverse of check
|
||||||
pub fn list(ctx: &Ctx, store: Option<&Path>) -> Result<()> {
|
pub fn list(ctx: &Ctx, args: &cli::List) -> Result<()> {
|
||||||
let repo = Repo::discover(ctx, store)?;
|
let repo = Repo::discover(ctx, args.store.root())?;
|
||||||
let stored = stored_paths(&repo, &repo.store)?;
|
let stored = stored_paths(&repo, &repo.store)?;
|
||||||
|
|
||||||
line!("store: {}", repo.store.display());
|
line!("store: {}", repo.store.display());
|
||||||
@@ -246,12 +244,18 @@ pub fn list(ctx: &Ctx, store: Option<&Path>) -> Result<()> {
|
|||||||
Stored::Taken => "shadowed",
|
Stored::Taken => "shadowed",
|
||||||
};
|
};
|
||||||
|
|
||||||
line!("\t{:<11}{}", format!("{verb}:"), rel.display());
|
entry(verb, rel);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
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 {
|
struct Report {
|
||||||
store: PathBuf,
|
store: PathBuf,
|
||||||
named: Cell<bool>,
|
named: Cell<bool>,
|
||||||
@@ -271,7 +275,7 @@ impl Report {
|
|||||||
line!("store: {}", self.store.display());
|
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)?;
|
stays_in_store(repo, &rel)?;
|
||||||
// before anything is moved in, so the tree it lands in is never briefly
|
// before anything is moved in, so the tree it lands in is never briefly
|
||||||
// readable by anyone else
|
// readable by anyone else
|
||||||
ensure_private_parent(ctx, &repo.base, &target)?;
|
ctx.fs().ensure_private_parent(&repo.base, &target)?;
|
||||||
|
|
||||||
if tracked(ctx, repo, &rel)? {
|
if tracked(ctx, repo, &rel)? {
|
||||||
return Err(anyhow!(
|
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));
|
return Err(needs_force(&target));
|
||||||
}
|
}
|
||||||
|
|
||||||
place_link(ctx, &src, &target)?;
|
ctx.fs().place_link(&src, &target)?;
|
||||||
report.line("repointed", &rel);
|
report.line("repointed", &rel);
|
||||||
Ok(())
|
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));
|
report.line("saved", &suffixed(&rel, BACKUP_SUFFIX));
|
||||||
|
|
||||||
place_link(ctx, &src, &target)?;
|
ctx.fs().place_link(&src, &target)?;
|
||||||
report.line("linked", &rel);
|
report.line("linked", &rel);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -399,7 +403,7 @@ fn link_one(ctx: &Ctx, repo: &Repo, path: &Path, force: bool, report: &Report) -
|
|||||||
if !force {
|
if !force {
|
||||||
return Err(needs_force(&target));
|
return Err(needs_force(&target));
|
||||||
}
|
}
|
||||||
place_link(ctx, &src, &target)?;
|
ctx.fs().place_link(&src, &target)?;
|
||||||
report.line("linked", &rel);
|
report.line("linked", &rel);
|
||||||
Ok(())
|
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
|
// 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
|
// tree has lost it altogether, which is the one outcome worse than failing
|
||||||
fn move_and_link(ctx: &Ctx, src: &Path, target: &Path) -> Result<()> {
|
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) {
|
if let Err(e) = ctx.fs().place_link(src, target) {
|
||||||
move_path(ctx, target, src).with_context(|| {
|
ctx.fs().move_path(target, src).with_context(|| {
|
||||||
format!(
|
format!(
|
||||||
"could not put {} back after failing to link it to {}",
|
"could not put {} back after failing to link it to {}",
|
||||||
src.display(),
|
src.display(),
|
||||||
|
|||||||
@@ -6,20 +6,15 @@ use std::path::{Path, PathBuf};
|
|||||||
use anyhow::{Result, anyhow};
|
use anyhow::{Result, anyhow};
|
||||||
|
|
||||||
use super::store::{Leads, Repo, leads, resolve, symlink_metadata_opt};
|
use super::store::{Leads, Repo, leads, resolve, symlink_metadata_opt};
|
||||||
|
use crate::cli::link as cli;
|
||||||
use crate::cmd::{Cmd, LsFiles};
|
use crate::cmd::{Cmd, LsFiles};
|
||||||
use crate::ctx::Ctx;
|
use crate::ctx::Ctx;
|
||||||
use crate::output::{line, text, write_bytes};
|
use crate::output::{line, text, write_bytes};
|
||||||
|
|
||||||
// whether anything is outside the store, which main turns into an exit code
|
// whether anything is outside the store, which main turns into an exit code
|
||||||
pub fn check(
|
pub fn check(ctx: &Ctx, args: &cli::Check) -> Result<bool> {
|
||||||
ctx: &Ctx,
|
let repo = Repo::discover(ctx, args.store.root())?;
|
||||||
paths: &[PathBuf],
|
let pathspecs = relative_pathspecs(&repo, &args.paths)?;
|
||||||
porcelain: bool,
|
|
||||||
null: bool,
|
|
||||||
store: Option<&Path>,
|
|
||||||
) -> Result<bool> {
|
|
||||||
let repo = Repo::discover(ctx, store)?;
|
|
||||||
let pathspecs = relative_pathspecs(&repo, paths)?;
|
|
||||||
|
|
||||||
let mut exposed = Vec::new();
|
let mut exposed = Vec::new();
|
||||||
// git lists untracked and ignored separately
|
// git lists untracked and ignored separately
|
||||||
@@ -40,8 +35,8 @@ pub fn check(
|
|||||||
|
|
||||||
exposed.sort_by(|a, b| a.name.cmp(&b.name));
|
exposed.sort_by(|a, b| a.name.cmp(&b.name));
|
||||||
|
|
||||||
if porcelain || null {
|
if args.porcelain || args.null {
|
||||||
print_porcelain(&exposed, null);
|
print_porcelain(&exposed, args.null);
|
||||||
} else {
|
} else {
|
||||||
print_listing(&repo, &exposed);
|
print_listing(&repo, &exposed);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,13 +10,13 @@ use anyhow::{Context, Result, bail};
|
|||||||
use self::server::{Database, wait_until_ready, when_ready};
|
use self::server::{Database, wait_until_ready, when_ready};
|
||||||
use self::shape::{Dump, HEADER_LEN, Kind};
|
use self::shape::{Dump, HEADER_LEN, Kind};
|
||||||
use crate::cli::Format;
|
use crate::cli::Format;
|
||||||
|
use crate::cli::postgres as cli;
|
||||||
use crate::cmd::{
|
use crate::cmd::{
|
||||||
Cmd, Cp, CreateDb, DropDb, Gunzip, Gzip, Head, PgDump, PgDumpAll, PgRestore, Psql, Rm, Start,
|
Cmd, Cp, CreateDb, DropDb, Gunzip, Gzip, Head, PgDump, PgDumpAll, PgRestore, Psql, Rm,
|
||||||
Stop, Up,
|
|
||||||
};
|
};
|
||||||
use crate::ctx::Ctx;
|
use crate::ctx::Ctx;
|
||||||
use crate::fsops::{create_private_new, remove_file, rename, suffixed};
|
use crate::fsops::{create_private_new, suffixed};
|
||||||
use crate::output::{note, warning};
|
use crate::output::{note, plural, warning};
|
||||||
|
|
||||||
// unique per run: docker cp will not copy a directory over an existing path, and
|
// 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
|
// 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()))
|
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
|
// pg_dumpall recreates roles the cluster already has, so this is the expected
|
||||||
// complaint rather than a failure
|
// complaint rather than a failure
|
||||||
fn is_existing_role_error(line: &str) -> bool {
|
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!(
|
note!(
|
||||||
ctx,
|
ctx,
|
||||||
"left {existing} existing role{} alone",
|
"left {existing} existing role{} alone",
|
||||||
if existing == 1 { "" } else { "s" }
|
plural(existing)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if !out.status.success() {
|
if !out.status.success() {
|
||||||
bail!("psql failed, the database is left empty");
|
return Err(left_empty("psql"));
|
||||||
}
|
}
|
||||||
if failed > 0 {
|
if failed > 0 {
|
||||||
bail!(
|
bail!(
|
||||||
"psql reported {failed} error{}, so the cluster restored only in part",
|
"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)?;
|
wait_until_ready(ctx, db)?;
|
||||||
|
|
||||||
let out = Gunzip
|
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
|
// head closes the pipe once it has its bytes, which kills gunzip
|
||||||
.allow_early_close()
|
.allow_early_close()
|
||||||
.in_container(&db.container)
|
.in_container(&db.container)
|
||||||
@@ -136,7 +143,7 @@ pub fn import(ctx: &Ctx, file: &Path) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
note!(ctx, "stopping all containers");
|
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
|
// 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
|
// 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<()> {
|
fn imported(ctx: &Ctx, db: &Database, dump: &Dump, file: &Path) -> Result<()> {
|
||||||
note!(ctx, "starting db container");
|
note!(ctx, "starting db container");
|
||||||
Start {
|
ctx.compose().start(&db.service).run(ctx)?;
|
||||||
service: &db.service,
|
|
||||||
quiet: ctx.quiet,
|
|
||||||
}
|
|
||||||
.run(ctx)?;
|
|
||||||
|
|
||||||
let remote = remote_dump();
|
let remote = remote_dump();
|
||||||
|
|
||||||
@@ -229,8 +232,8 @@ fn imported(ctx: &Ctx, db: &Database, dump: &Dump, file: &Path) -> Result<()> {
|
|||||||
restored?;
|
restored?;
|
||||||
|
|
||||||
note!(ctx, "restarting containers");
|
note!(ctx, "restarting containers");
|
||||||
Stop { quiet: ctx.quiet }.run(ctx)?;
|
ctx.compose().stop().run(ctx)?;
|
||||||
Up { quiet: ctx.quiet }.run(ctx)?;
|
ctx.compose().up().run(ctx)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -261,14 +264,14 @@ fn restore_dump(
|
|||||||
.status(ctx)?;
|
.status(ctx)?;
|
||||||
|
|
||||||
if !status.success() {
|
if !status.success() {
|
||||||
bail!("{tool} failed, the database is left empty");
|
return Err(left_empty(tool));
|
||||||
}
|
}
|
||||||
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let restore: Box<dyn Cmd> = match dump {
|
let restore: Box<dyn Cmd> = match dump {
|
||||||
Dump::Gzip => Box::new(Gunzip.pipe(&*restore)),
|
Dump::Gzip => Box::new(Gunzip.pipe(&*restore)?),
|
||||||
_ => restore,
|
_ => restore,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -284,7 +287,7 @@ fn restore_dump(
|
|||||||
.stdin_from(ctx, file)?;
|
.stdin_from(ctx, file)?;
|
||||||
|
|
||||||
if !status.success() {
|
if !status.success() {
|
||||||
bail!("{tool} failed, the database is left empty");
|
return Err(left_empty(tool));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -302,10 +305,13 @@ pub fn psql(ctx: &Ctx, rest: &[String]) -> Result<()> {
|
|||||||
.replace(ctx)
|
.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)?;
|
let db = Database::resolve(ctx)?;
|
||||||
|
|
||||||
if format == Format::Directory {
|
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 {
|
if gzip {
|
||||||
bail!("a directory dump is a directory of already compressed files, not a stream");
|
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 {
|
let dumped = if gzip {
|
||||||
dumping
|
dumping
|
||||||
.pipe(&Gzip)
|
.pipe(&Gzip)?
|
||||||
.in_container(&db.container)
|
.in_container(&db.container)
|
||||||
.stream_to(ctx, stdout)
|
.stream_to(ctx, stdout)
|
||||||
} else {
|
} else {
|
||||||
@@ -337,19 +343,19 @@ pub fn dump(ctx: &Ctx, file: &Path, format: Format, gzip: bool) -> Result<()> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if let Err(e) = dumped {
|
if let Err(e) = dumped {
|
||||||
let _ = remove_file(ctx, &partial);
|
let _ = ctx.fs().remove_file(&partial);
|
||||||
return Err(e);
|
return Err(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
rename(ctx, &partial, file)?;
|
ctx.fs().rename(&partial, file)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// pg_dump for one database, pg_dumpall for a cluster, which has no format letter
|
// 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 + '_> {
|
fn dump_command(db: &Database, format: Format) -> Box<dyn Cmd + '_> {
|
||||||
match format.flag() {
|
match PgDump::of(&db.user, &db.name, format) {
|
||||||
Some(flag) => Box::new(PgDump::new(&db.user, &db.name, flag)),
|
Some(dump) => Box::new(dump),
|
||||||
None => Box::new(PgDumpAll { username: &db.user }),
|
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());
|
note!(ctx, "dumping to local directory {}", target.display());
|
||||||
let remote = remote_dump();
|
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)
|
.to(&remote)
|
||||||
.in_container(&db.container)
|
.in_container(&db.container)
|
||||||
.run(ctx)?;
|
.run(ctx)?;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use std::time::{Duration, Instant};
|
|||||||
use anyhow::{Result, bail};
|
use anyhow::{Result, bail};
|
||||||
|
|
||||||
use super::shape::Kind;
|
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::ctx::Ctx;
|
||||||
use crate::project::Project;
|
use crate::project::Project;
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ impl Database {
|
|||||||
let service = compose.postgres()?;
|
let service = compose.postgres()?;
|
||||||
let (user, name) = compose.postgres_credentials(&service)?;
|
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 mut ids = listed.lines().map(str::trim).filter(|id| !id.is_empty());
|
||||||
|
|
||||||
let container = match (ids.next(), ids.next()) {
|
let container = match (ids.next(), ids.next()) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use std::path::Path;
|
|||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
|
||||||
use crate::cmd::{Cmd, Ps};
|
use crate::cmd::Cmd;
|
||||||
use crate::commands::link;
|
use crate::commands::link;
|
||||||
use crate::ctx::Ctx;
|
use crate::ctx::Ctx;
|
||||||
use crate::output::line;
|
use crate::output::line;
|
||||||
@@ -62,7 +62,7 @@ fn role(ctx: &Ctx, role: &str, detected: Result<String>, project: &Project) {
|
|||||||
|
|
||||||
line!("{role}: {service} ({image})");
|
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) if id.trim().is_empty() => line!("\tcontainer: not running"),
|
||||||
Ok(id) => line!("\tcontainer: {}", short(id.trim())),
|
Ok(id) => line!("\tcontainer: {}", short(id.trim())),
|
||||||
Err(e) => line!("\tcontainer: {e:#}"),
|
Err(e) => line!("\tcontainer: {e:#}"),
|
||||||
|
|||||||
16
src/ctx.rs
16
src/ctx.rs
@@ -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
|
// how ahab was invoked, handed to everything that runs commands or writes files
|
||||||
pub struct Ctx {
|
pub struct Ctx {
|
||||||
pub verbose: bool,
|
pub verbose: bool,
|
||||||
pub dry_run: bool,
|
pub dry_run: bool,
|
||||||
pub quiet: 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
226
src/fsops.rs
226
src/fsops.rs
@@ -16,47 +16,59 @@ pub fn suffixed(path: &Path, suffix: &str) -> PathBuf {
|
|||||||
PathBuf::from(out)
|
PathBuf::from(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
// every write to the working tree goes through this module, so a dry run is held
|
// the working tree, as this invocation is allowed to touch it. every write goes
|
||||||
// back in one place rather than at each call site
|
// through here, so what a dry run means is settled in one place rather than at
|
||||||
pub fn move_path(ctx: &Ctx, src: &Path, target: &Path) -> Result<()> {
|
// the top of each operation, where the next one added could simply not ask
|
||||||
if ctx.dry_run {
|
pub struct Fs<'a> {
|
||||||
return Ok(());
|
ctx: &'a Ctx,
|
||||||
}
|
}
|
||||||
|
|
||||||
ensure_parent(ctx, target)?;
|
impl<'a> Fs<'a> {
|
||||||
|
pub(crate) fn new(ctx: &'a Ctx) -> Self {
|
||||||
|
Self { ctx }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
// rename cannot cross filesystems, and the store often is another one
|
||||||
match fs::rename(src, target) {
|
match fs::rename(src, target) {
|
||||||
Ok(()) => Ok(()),
|
Ok(()) => Ok(()),
|
||||||
Err(rename_err) => match copy_recursive(src, target) {
|
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}")),
|
Err(copy_err) => Err(copy_err).with_context(|| format!("after {rename_err}")),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn rename(ctx: &Ctx, src: &Path, target: &Path) -> Result<()> {
|
pub fn rename(&self, src: &Path, target: &Path) -> Result<()> {
|
||||||
if ctx.dry_run {
|
self.unless_planned(|| Ok(fs::rename(src, target)?))
|
||||||
return Ok(());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(fs::rename(src, target)?)
|
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)?;
|
||||||
|
|
||||||
pub fn place_link(ctx: &Ctx, link_path: &Path, target: &Path) -> Result<()> {
|
// symlink under a temp name and rename over the path: the rename is
|
||||||
if ctx.dry_run {
|
// atomic
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)?;
|
|
||||||
|
|
||||||
// symlink under a temp name and rename over the path: the rename is atomic
|
|
||||||
let tmp = suffixed(link_path, ".ahab-tmp");
|
let tmp = suffixed(link_path, ".ahab-tmp");
|
||||||
|
|
||||||
// only ahab's own leftover is cleared away: anything else here belongs to
|
// only ahab's own leftover is cleared away: anything else here
|
||||||
// the project, and silently unlinking it would lose it
|
// belongs to the project, and silently unlinking it would lose it
|
||||||
match fs::symlink_metadata(&tmp) {
|
match fs::symlink_metadata(&tmp) {
|
||||||
Ok(meta) if meta.is_symlink() => fs::remove_file(&tmp)?,
|
Ok(meta) if meta.is_symlink() => fs::remove_file(&tmp)?,
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
@@ -72,29 +84,46 @@ pub fn place_link(ctx: &Ctx, link_path: &Path, target: &Path) -> Result<()> {
|
|||||||
|
|
||||||
symlink(target, &tmp)?;
|
symlink(target, &tmp)?;
|
||||||
Ok(fs::rename(&tmp, link_path)?)
|
Ok(fs::rename(&tmp, link_path)?)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 0600 and only where nothing is yet. a dump is the whole database, and
|
pub fn remove_file(&self, path: &Path) -> Result<()> {
|
||||||
// pg_dumpall's is every role's password hash, so it is not the owner's to share
|
self.unless_planned(|| Ok(fs::remove_file(path)?))
|
||||||
// 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> {
|
pub fn create_dir(&self, path: &Path) -> Result<()> {
|
||||||
std::fs::OpenOptions::new()
|
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)
|
.write(true)
|
||||||
.create_new(true)
|
.create(true)
|
||||||
.mode(0o600)
|
.truncate(false)
|
||||||
.open(path)
|
.open(path)?;
|
||||||
.with_context(|| format!("creating {}", path.display()))
|
|
||||||
|
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
|
// 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
|
// directories it is kept in are the owner's alone. only the tree at or
|
||||||
// `base` is created here; what is above it is the user's own business
|
// 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<()> {
|
pub fn ensure_private_parent(&self, base: &Path, target: &Path) -> Result<()> {
|
||||||
if ctx.dry_run {
|
self.unless_planned(|| {
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(parent) = target.parent().filter(|dir| dir.starts_with(base)) else {
|
let Some(parent) = target.parent().filter(|dir| dir.starts_with(base)) else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
@@ -105,22 +134,55 @@ pub fn ensure_private_parent(ctx: &Ctx, base: &Path, target: &Path) -> Result<()
|
|||||||
.mode(0o700)
|
.mode(0o700)
|
||||||
.create(parent)
|
.create(parent)
|
||||||
.with_context(|| format!("creating {}", parent.display()))
|
.with_context(|| format!("creating {}", parent.display()))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn remove_file(ctx: &Ctx, path: &Path) -> Result<()> {
|
// a restored path can leave the store holding nothing but empty directories
|
||||||
if ctx.dry_run {
|
pub fn prune_empty(&self, dir: Option<&Path>, stop: &Path) {
|
||||||
return Ok(());
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(fs::remove_file(path)?)
|
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
|
// whatever is there, file, directory or symlink
|
||||||
fn remove_recursive(ctx: &Ctx, path: &Path) -> Result<()> {
|
fn remove_recursive(path: &Path) -> Result<()> {
|
||||||
if ctx.dry_run {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
if fs::symlink_metadata(path)?.is_dir() {
|
if fs::symlink_metadata(path)?.is_dir() {
|
||||||
fs::remove_dir_all(path)?;
|
fs::remove_dir_all(path)?;
|
||||||
} else {
|
} else {
|
||||||
@@ -130,70 +192,6 @@ fn remove_recursive(ctx: &Ctx, path: &Path) -> Result<()> {
|
|||||||
Ok(())
|
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<()> {
|
fn copy_recursive(src: &Path, target: &Path) -> Result<()> {
|
||||||
let meta = fs::symlink_metadata(src)?;
|
let meta = fs::symlink_metadata(src)?;
|
||||||
|
|
||||||
|
|||||||
35
src/main.rs
35
src/main.rs
@@ -73,51 +73,32 @@ fn run(ctx: &Ctx, command: cli::Commands) -> Result<ExitCode> {
|
|||||||
match command {
|
match command {
|
||||||
cli::Postgres::Import { path } => commands::postgres::import(ctx, &path),
|
cli::Postgres::Import { path } => commands::postgres::import(ctx, &path),
|
||||||
cli::Postgres::Psql { rest } => commands::postgres::psql(ctx, &rest),
|
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
|
// clap cannot say that a flag conflicts with one value of
|
||||||
// another, and this is still an argument error: it belongs
|
// another, and this is still an argument error: it belongs
|
||||||
// with the usage and the exit code the others get
|
// 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(
|
usage(
|
||||||
"a directory dump is a directory of already \
|
"a directory dump is a directory of already \
|
||||||
compressed files, not a stream",
|
compressed files, not a stream",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
commands::postgres::dump(ctx, &path, format, gzip)
|
commands::postgres::dump(ctx, &args)
|
||||||
}
|
}
|
||||||
}?;
|
}?;
|
||||||
|
|
||||||
Ok(done)
|
Ok(done)
|
||||||
}
|
}
|
||||||
cli::Commands::Link { command } => match command {
|
cli::Commands::Link { command } => match command {
|
||||||
cli::Link::Add {
|
cli::Link::Add(args) => commands::link::add(ctx, &args).map(|()| done),
|
||||||
paths,
|
cli::Link::List(args) => commands::link::list(ctx, &args).map(|()| done),
|
||||||
force,
|
cli::Link::Restore(args) => commands::link::restore(ctx, &args).map(|()| done),
|
||||||
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)
|
|
||||||
}
|
|
||||||
// the one command with something to say through its exit code
|
// the one command with something to say through its exit code
|
||||||
cli::Link::Check {
|
cli::Link::Check(args) => match commands::link::check(ctx, &args)? && args.exit_code {
|
||||||
paths,
|
|
||||||
porcelain,
|
|
||||||
null,
|
|
||||||
exit_code,
|
|
||||||
store,
|
|
||||||
} => {
|
|
||||||
let exposed =
|
|
||||||
commands::link::check(ctx, &paths, porcelain, null, store.root.as_deref())?;
|
|
||||||
|
|
||||||
match exit_code && exposed {
|
|
||||||
true => Ok(ExitCode::from(FINDINGS)),
|
true => Ok(ExitCode::from(FINDINGS)),
|
||||||
false => Ok(done),
|
false => Ok(done),
|
||||||
}
|
},
|
||||||
}
|
|
||||||
},
|
},
|
||||||
cli::Commands::Status { store } => {
|
cli::Commands::Status { store } => {
|
||||||
commands::status::status(ctx, store.root.as_deref())?;
|
commands::status::status(ctx, store.root.as_deref())?;
|
||||||
|
|||||||
@@ -107,3 +107,11 @@ pub(crate) fn delivered() -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) use {line, note, text, warning};
|
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",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use anyhow::{Context, Result, anyhow};
|
use anyhow::{Context, Result, anyhow};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::cmd::{Cmd, Config};
|
use crate::cmd::Cmd;
|
||||||
use crate::ctx::Ctx;
|
use crate::ctx::Ctx;
|
||||||
|
|
||||||
const POSTGRES_IMAGES: [&str; 4] = ["postg", "timescale", "pgvector", "citus"];
|
const POSTGRES_IMAGES: [&str; 4] = ["postg", "timescale", "pgvector", "citus"];
|
||||||
@@ -13,7 +13,7 @@ pub struct Project {
|
|||||||
|
|
||||||
impl Project {
|
impl Project {
|
||||||
pub fn resolve(ctx: &Ctx) -> Result<Self> {
|
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 config: Value = serde_json::from_str(&json).context("parsing docker compose config")?;
|
||||||
let services = config
|
let services = config
|
||||||
.get("services")
|
.get("services")
|
||||||
|
|||||||
262
tests/link_store.rs
Normal file
262
tests/link_store.rs
Normal 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
99
tests/output_io.rs
Normal 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
207
tests/support/mod.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user