merge: qol improvements
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
use super::{Django, Link, Postgres};
|
||||
use super::{Django, Link, Postgres, Store};
|
||||
use clap::builder::styling::{AnsiColor, Effects, Styles};
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
@@ -16,6 +16,10 @@ pub struct Ahab {
|
||||
#[arg(short, long, global = true)]
|
||||
pub verbose: bool,
|
||||
|
||||
/// Print only what was asked for, not the progress along the way
|
||||
#[arg(short, long, global = true, conflicts_with = "verbose")]
|
||||
pub quiet: bool,
|
||||
|
||||
/// Print the docker commands that would run, without running them
|
||||
#[arg(long, global = true)]
|
||||
pub dry_run: bool,
|
||||
@@ -40,6 +44,12 @@ pub enum Commands {
|
||||
command: Link,
|
||||
},
|
||||
|
||||
/// Show what ahab makes of this project
|
||||
Status {
|
||||
#[command(flatten)]
|
||||
store: Store,
|
||||
},
|
||||
|
||||
/// Print a shell completion script on stdout
|
||||
Completions {
|
||||
/// Shell to generate the script for
|
||||
|
||||
@@ -35,7 +35,4 @@ pub enum Django {
|
||||
|
||||
/// Run Django's manage.py shell
|
||||
Shell,
|
||||
|
||||
/// Run Django's manage.py test
|
||||
Test,
|
||||
}
|
||||
|
||||
@@ -30,6 +30,12 @@ pub enum Link {
|
||||
store: Store,
|
||||
},
|
||||
|
||||
/// List what this repository keeps in the store
|
||||
List {
|
||||
#[command(flatten)]
|
||||
store: Store,
|
||||
},
|
||||
|
||||
/// List untracked paths a sandbox would still see
|
||||
Check {
|
||||
/// Limit the listing to these paths
|
||||
@@ -39,7 +45,7 @@ pub enum Link {
|
||||
#[arg(long)]
|
||||
porcelain: bool,
|
||||
|
||||
/// Exit with 1 when anything is outside the store, for scripts
|
||||
/// Exit with 4 when anything is outside the store, for scripts
|
||||
#[arg(long)]
|
||||
exit_code: bool,
|
||||
|
||||
|
||||
@@ -7,5 +7,5 @@ mod postgres;
|
||||
|
||||
pub use ahab::{Ahab, Commands};
|
||||
pub use django::Django;
|
||||
pub use link::Link;
|
||||
pub use link::{Link, Store};
|
||||
pub use postgres::{Format, Postgres};
|
||||
|
||||
@@ -7,6 +7,13 @@ pub enum Postgres {
|
||||
/// Import a dump, in any format ahab can produce
|
||||
Import { path: PathBuf },
|
||||
|
||||
/// Open psql in the database container
|
||||
Psql {
|
||||
/// Arguments for psql itself
|
||||
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
|
||||
rest: Vec<String>,
|
||||
},
|
||||
|
||||
/// Dump via pg_dump, or pg_dumpall for a whole cluster
|
||||
Dump {
|
||||
path: PathBuf,
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
use super::{Argv, Cmd};
|
||||
|
||||
fn compose() -> Argv {
|
||||
Argv::new("docker").arg("compose")
|
||||
// compose prints its own progress, which --quiet has to ask it to stop
|
||||
fn compose(quiet: bool) -> Argv {
|
||||
let argv = Argv::new("docker").arg("compose");
|
||||
|
||||
match quiet {
|
||||
true => argv.flag("--progress", "quiet"),
|
||||
false => argv,
|
||||
}
|
||||
}
|
||||
|
||||
// docker compose run --rm, which runs the image's entrypoint and so fixes up the
|
||||
@@ -22,7 +28,7 @@ impl Run {
|
||||
|
||||
impl Cmd for Run {
|
||||
fn argv(&self) -> Argv {
|
||||
compose()
|
||||
compose(false)
|
||||
.arg("run")
|
||||
.arg("--rm")
|
||||
.arg(&self.service)
|
||||
@@ -35,7 +41,7 @@ pub struct Config;
|
||||
|
||||
impl Cmd for Config {
|
||||
fn argv(&self) -> Argv {
|
||||
compose().arg("config").flag("--format", "json")
|
||||
compose(false).arg("config").flag("--format", "json")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,39 +60,37 @@ impl Ps {
|
||||
|
||||
impl Cmd for Ps {
|
||||
fn argv(&self) -> Argv {
|
||||
compose().arg("ps").arg("--quiet").arg(&self.service)
|
||||
compose(false).arg("ps").arg("--quiet").arg(&self.service)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Up;
|
||||
pub struct Up {
|
||||
pub quiet: bool,
|
||||
}
|
||||
|
||||
impl Cmd for Up {
|
||||
fn argv(&self) -> Argv {
|
||||
compose().arg("up").arg("--detach")
|
||||
compose(self.quiet).arg("up").arg("--detach")
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Start {
|
||||
service: String,
|
||||
pub struct Start<'a> {
|
||||
pub service: &'a str,
|
||||
pub quiet: bool,
|
||||
}
|
||||
|
||||
impl Start {
|
||||
pub fn service(service: &str) -> Self {
|
||||
Self {
|
||||
service: service.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Cmd for Start {
|
||||
impl Cmd for Start<'_> {
|
||||
fn argv(&self) -> Argv {
|
||||
compose().arg("start").arg(&self.service)
|
||||
compose(self.quiet).arg("start").arg(self.service)
|
||||
}
|
||||
}
|
||||
pub struct Stop;
|
||||
|
||||
pub struct Stop {
|
||||
pub quiet: bool,
|
||||
}
|
||||
|
||||
impl Cmd for Stop {
|
||||
fn argv(&self) -> Argv {
|
||||
compose().arg("stop")
|
||||
compose(self.quiet).arg("stop")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ use super::{Argv, Cmd};
|
||||
pub struct Exec {
|
||||
container: String,
|
||||
interactive: bool,
|
||||
tty: bool,
|
||||
inner: Argv,
|
||||
}
|
||||
|
||||
@@ -12,10 +13,17 @@ impl Exec {
|
||||
Self {
|
||||
container: container.to_string(),
|
||||
interactive: false,
|
||||
tty: false,
|
||||
inner,
|
||||
}
|
||||
}
|
||||
|
||||
// a terminal, for a command that is a shell rather than a pipe stage
|
||||
pub fn tty(mut self, tty: bool) -> Self {
|
||||
self.tty = tty;
|
||||
self
|
||||
}
|
||||
|
||||
// keep stdin open, for a command that is fed a dump
|
||||
pub fn interactive(mut self) -> Self {
|
||||
self.interactive = true;
|
||||
@@ -31,6 +39,10 @@ impl Cmd for Exec {
|
||||
argv = argv.arg("--interactive");
|
||||
}
|
||||
|
||||
if self.tty {
|
||||
argv = argv.arg("--tty");
|
||||
}
|
||||
|
||||
argv.arg(&self.container).args(self.inner.words())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,8 +21,10 @@ pub struct DropDb<'a> {
|
||||
|
||||
impl Cmd for DropDb<'_> {
|
||||
fn argv(&self) -> Argv {
|
||||
// a cluster that never held this database is still a restore target
|
||||
Argv::new("dropdb")
|
||||
.flag("--username", self.username)
|
||||
.arg("--if-exists")
|
||||
.arg(self.dbname)
|
||||
}
|
||||
}
|
||||
@@ -80,11 +82,13 @@ impl Cmd for PgRestore<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
// reads a plain sql dump, of one database or of a whole cluster
|
||||
// reads a plain sql dump, or runs as the interactive shell it is
|
||||
pub struct Psql<'a> {
|
||||
username: &'a str,
|
||||
dbname: &'a str,
|
||||
quiet: bool,
|
||||
atomic: bool,
|
||||
rest: &'a [String],
|
||||
}
|
||||
|
||||
impl<'a> Psql<'a> {
|
||||
@@ -92,33 +96,51 @@ impl<'a> Psql<'a> {
|
||||
Self {
|
||||
username,
|
||||
dbname,
|
||||
quiet: false,
|
||||
atomic: false,
|
||||
rest: &[],
|
||||
}
|
||||
}
|
||||
|
||||
// say nothing and print no result rows, for a restore whose output is noise
|
||||
pub fn quiet(mut self) -> Self {
|
||||
self.quiet = true;
|
||||
self
|
||||
}
|
||||
|
||||
// stop at the first error and undo the rest, which a cluster dump cannot do:
|
||||
// it connects to each database itself, and trips over roles already there
|
||||
pub fn atomic(mut self) -> Self {
|
||||
self.atomic = true;
|
||||
self
|
||||
}
|
||||
|
||||
// whatever the caller typed, for psql's own flags
|
||||
pub fn args(mut self, rest: &'a [String]) -> Self {
|
||||
self.rest = rest;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Cmd for Psql<'_> {
|
||||
fn argv(&self) -> Argv {
|
||||
let argv = Argv::new("psql")
|
||||
.arg("--quiet")
|
||||
.flag("--output", "/dev/null")
|
||||
let mut argv = Argv::new("psql");
|
||||
|
||||
if self.quiet {
|
||||
argv = argv.arg("--quiet").flag("--output", "/dev/null");
|
||||
}
|
||||
|
||||
argv = argv
|
||||
.flag("--username", self.username)
|
||||
.flag("--dbname", self.dbname);
|
||||
|
||||
if self.atomic {
|
||||
return argv
|
||||
argv = argv
|
||||
.flag("--variable", "ON_ERROR_STOP=1")
|
||||
.arg("--single-transaction");
|
||||
}
|
||||
|
||||
argv
|
||||
argv.args(self.rest)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,9 +196,22 @@ impl Cmd for PgDumpAll<'_> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{PgDump, Psql};
|
||||
use super::{DropDb, PgDump, Psql};
|
||||
use crate::cmd::Cmd;
|
||||
|
||||
#[test]
|
||||
fn a_missing_database_is_not_a_failed_import() {
|
||||
assert_eq!(
|
||||
DropDb {
|
||||
username: "u",
|
||||
dbname: "db",
|
||||
}
|
||||
.argv()
|
||||
.quoted(),
|
||||
"dropdb --username u --if-exists db"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_directory_dump_names_the_file_it_writes() {
|
||||
assert_eq!(
|
||||
@@ -193,13 +228,14 @@ mod tests {
|
||||
fn only_a_single_database_restore_stops_at_the_first_error() {
|
||||
assert!(
|
||||
Psql::new("u", "db")
|
||||
.quiet()
|
||||
.atomic()
|
||||
.argv()
|
||||
.quoted()
|
||||
.ends_with("--variable 'ON_ERROR_STOP=1' --single-transaction")
|
||||
);
|
||||
assert_eq!(
|
||||
Psql::new("u", "postgres").argv().quoted(),
|
||||
Psql::new("u", "postgres").quiet().argv().quoted(),
|
||||
"psql --quiet --output /dev/null --username u --dbname postgres"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ pub fn make_command(ctx: &Ctx, app: &PathBuf, name: &str) -> Result<()> {
|
||||
return Err(anyhow!("directory {app_name} does not exist"));
|
||||
}
|
||||
|
||||
note!("found app {app_name}");
|
||||
note!(ctx, "found app {app_name}");
|
||||
|
||||
let management_dir = app_dir.join("management");
|
||||
|
||||
@@ -32,7 +32,7 @@ pub fn make_command(ctx: &Ctx, app: &PathBuf, name: &str) -> Result<()> {
|
||||
create_dir(ctx, &management_dir)?;
|
||||
touch(ctx, &management_dir.join("__init__.py"))?;
|
||||
|
||||
note!("created module {app_name}.management")
|
||||
note!(ctx, "created module {app_name}.management")
|
||||
};
|
||||
|
||||
let commands_dir = management_dir.join("commands");
|
||||
@@ -41,7 +41,7 @@ pub fn make_command(ctx: &Ctx, app: &PathBuf, name: &str) -> Result<()> {
|
||||
create_dir(ctx, &commands_dir)?;
|
||||
touch(ctx, &commands_dir.join("__init__.py"))?;
|
||||
|
||||
note!("created module {app_name}.management.commands")
|
||||
note!(ctx, "created module {app_name}.management.commands")
|
||||
};
|
||||
|
||||
write_new(
|
||||
@@ -50,7 +50,7 @@ pub fn make_command(ctx: &Ctx, app: &PathBuf, name: &str) -> Result<()> {
|
||||
DEBUG_TEMPLATE.as_bytes(),
|
||||
)?;
|
||||
|
||||
note!("created command {app_name}.management.commands.{name}");
|
||||
note!(ctx, "created command {app_name}.management.commands.{name}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -82,10 +82,6 @@ pub fn shell(ctx: &Ctx) -> Result<()> {
|
||||
manage(ctx, &["shell".to_string()])
|
||||
}
|
||||
|
||||
pub fn test(ctx: &Ctx) -> Result<()> {
|
||||
manage(ctx, &["test".to_string()])
|
||||
}
|
||||
|
||||
fn service(ctx: &Ctx) -> Result<String> {
|
||||
Project::resolve(ctx)?.django()
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ use anyhow::{Result, anyhow, bail};
|
||||
use self::store::{Repo, ignored, resolve, symlink_metadata_opt, tracked};
|
||||
use crate::ctx::Ctx;
|
||||
use crate::fsops::{move_path, place_link, prune_empty, remove_file, rename, suffixed};
|
||||
use crate::output::{note, warning};
|
||||
use crate::output::{line, note, warning};
|
||||
|
||||
const BACKUP_SUFFIX: &str = ".ahab-bak";
|
||||
|
||||
@@ -28,7 +28,7 @@ pub fn add(ctx: &Ctx, paths: &[PathBuf], force: bool, store: Option<&Path>) -> R
|
||||
let mut failed = 0;
|
||||
for path in paths {
|
||||
if let Err(e) = link_one(ctx, &repo, path, force, &report) {
|
||||
note!("error: {e:#}");
|
||||
note!(ctx, "error: {e:#}");
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
@@ -44,32 +44,57 @@ pub fn restore(ctx: &Ctx, paths: &[PathBuf], all: bool, store: Option<&Path>) ->
|
||||
let repo = Repo::discover(ctx, store)?;
|
||||
let report = Report::new(&repo);
|
||||
|
||||
let paths = match (all, paths) {
|
||||
(true, []) => linked_paths(&repo, &repo.store)?,
|
||||
let stored = match (all, paths) {
|
||||
(true, []) => stored_paths(&repo, &repo.store)?,
|
||||
(true, _) => bail!("--all restores everything, so it takes no paths"),
|
||||
(false, []) => bail!("name a path to restore, or pass --all"),
|
||||
(false, paths) => paths.to_vec(),
|
||||
(false, paths) => paths.iter().cloned().map(|p| (p, Stored::Linked)).collect(),
|
||||
};
|
||||
|
||||
if paths.is_empty() {
|
||||
note!("nothing in the store for this repository");
|
||||
// only a linked path can be moved back; the store can hold orphans too
|
||||
let linked: Vec<PathBuf> = stored
|
||||
.iter()
|
||||
.filter(|(_, state)| *state == Stored::Linked)
|
||||
.map(|(path, _)| path.clone())
|
||||
.collect();
|
||||
let skipped = stored.len() - linked.len();
|
||||
|
||||
if linked.is_empty() {
|
||||
match skipped {
|
||||
0 => note!(ctx, "nothing in the store for this repository"),
|
||||
n => note!(
|
||||
ctx,
|
||||
"nothing to restore: {n} path{} in the store {} not linked, see `ahab link list`",
|
||||
if n == 1 { "" } else { "s" },
|
||||
if n == 1 { "is" } else { "are" }
|
||||
),
|
||||
}
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let [path] = paths.as_slice() {
|
||||
if skipped > 0 {
|
||||
note!(
|
||||
ctx,
|
||||
"leaving {skipped} path{} that the store holds but nothing links to",
|
||||
if skipped == 1 { "" } else { "s" }
|
||||
);
|
||||
}
|
||||
|
||||
if let [path] = linked.as_slice() {
|
||||
return restore_one(ctx, &repo, path, &report);
|
||||
}
|
||||
|
||||
let mut failed = 0;
|
||||
for path in &paths {
|
||||
for path in &linked {
|
||||
if let Err(e) = restore_one(ctx, &repo, path, &report) {
|
||||
note!("error: {e:#}");
|
||||
note!(ctx, "error: {e:#}");
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if failed > 0 {
|
||||
return Err(anyhow!("{failed} of {} paths failed", paths.len()));
|
||||
return Err(anyhow!("{failed} of {} paths failed", linked.len()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -109,9 +134,20 @@ fn restore_one(ctx: &Ctx, repo: &Repo, path: &Path, report: &Report) -> Result<(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// what the repository looks like from the store's side
|
||||
#[derive(PartialEq)]
|
||||
enum Stored {
|
||||
// the symlink is there and points at the store, which is the whole point
|
||||
Linked,
|
||||
// nothing is at the path the store holds it for
|
||||
Missing,
|
||||
// something else took the path, so the stored copy is the one nobody reads
|
||||
Taken,
|
||||
}
|
||||
|
||||
// the store mirrors the repository layout, so walking it finds every path this
|
||||
// repository has linked without asking git anything
|
||||
fn linked_paths(repo: &Repo, dir: &Path) -> Result<Vec<PathBuf>> {
|
||||
// repository has put there without asking git anything
|
||||
fn stored_paths(repo: &Repo, dir: &Path) -> Result<Vec<(PathBuf, Stored)>> {
|
||||
let mut found = Vec::new();
|
||||
|
||||
let entries = match read_dir(dir) {
|
||||
@@ -127,20 +163,64 @@ fn linked_paths(repo: &Repo, dir: &Path) -> Result<Vec<PathBuf>> {
|
||||
.expect("walked out of the store");
|
||||
let src = repo.root.join(rel);
|
||||
|
||||
let linked = symlink_metadata_opt(&src)?.is_some_and(|meta| meta.is_symlink())
|
||||
&& read_link(&src).is_ok_and(|dest| dest == stored);
|
||||
let state = match symlink_metadata_opt(&src)? {
|
||||
None => Stored::Missing,
|
||||
Some(meta) if !meta.is_symlink() => Stored::Taken,
|
||||
Some(_) if read_link(&src).is_ok_and(|dest| dest == stored) => Stored::Linked,
|
||||
Some(_) => Stored::Taken,
|
||||
};
|
||||
|
||||
if linked {
|
||||
found.push(src);
|
||||
} else if stored.is_dir() {
|
||||
found.extend(linked_paths(repo, &stored)?);
|
||||
// a linked directory is one entry; otherwise the paths inside it are
|
||||
if state == Stored::Linked || !stored.is_dir() {
|
||||
found.push((src, state));
|
||||
} else {
|
||||
found.extend(stored_paths(repo, &stored)?);
|
||||
}
|
||||
}
|
||||
|
||||
found.sort();
|
||||
found.sort_by(|(a, _), (b, _)| a.cmp(b));
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
// what the store holds, without the git questions check asks
|
||||
pub fn stored_summary(ctx: &Ctx, store: Option<&Path>) -> Result<(PathBuf, usize, usize)> {
|
||||
let repo = Repo::discover(ctx, store)?;
|
||||
let stored = stored_paths(&repo, &repo.store)?;
|
||||
|
||||
let linked = stored
|
||||
.iter()
|
||||
.filter(|(_, state)| *state == Stored::Linked)
|
||||
.count();
|
||||
|
||||
Ok((repo.store, linked, stored.len() - linked))
|
||||
}
|
||||
|
||||
// list what the store holds for this repository, the inverse of check
|
||||
pub fn list(ctx: &Ctx, store: Option<&Path>) -> Result<()> {
|
||||
let repo = Repo::discover(ctx, store)?;
|
||||
let stored = stored_paths(&repo, &repo.store)?;
|
||||
|
||||
line!("store: {}", repo.store.display());
|
||||
|
||||
if stored.is_empty() {
|
||||
line!("nothing in the store for this repository");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for (path, state) in stored {
|
||||
let rel = path.strip_prefix(&repo.root).unwrap_or(&path);
|
||||
let verb = match state {
|
||||
Stored::Linked => "linked",
|
||||
Stored::Missing => "missing",
|
||||
Stored::Taken => "shadowed",
|
||||
};
|
||||
|
||||
line!("\t{:<11}{}", format!("{verb}:"), rel.display());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct Report {
|
||||
store: PathBuf,
|
||||
named: Cell<bool>,
|
||||
@@ -157,14 +237,13 @@ impl Report {
|
||||
fn line(&self, verb: &str, path: &Path) {
|
||||
// worth naming once per run
|
||||
if !self.named.replace(true) {
|
||||
println!("store: {}", self.store.display());
|
||||
line!("store: {}", self.store.display());
|
||||
}
|
||||
|
||||
println!("\t{:<11}{}", format!("{verb}:"), path.display());
|
||||
line!("\t{:<11}{}", format!("{verb}:"), path.display());
|
||||
}
|
||||
}
|
||||
|
||||
// list untracked paths not in the store, i.e. what a sandbox can still read
|
||||
fn link_one(ctx: &Ctx, repo: &Repo, path: &Path, force: bool, report: &Report) -> Result<()> {
|
||||
let src = resolve(path)?;
|
||||
let rel = repo.relative(&src)?;
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
use fs_err::{read_dir, read_link};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::ExitCode;
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
|
||||
use super::store::{Repo, resolve, symlink_metadata_opt};
|
||||
use crate::cmd::{Cmd, LsFiles};
|
||||
use crate::ctx::Ctx;
|
||||
use crate::output::{line, text};
|
||||
|
||||
// whether anything is outside the store, which main turns into an exit code
|
||||
pub fn check(
|
||||
ctx: &Ctx,
|
||||
paths: &[PathBuf],
|
||||
porcelain: bool,
|
||||
null: bool,
|
||||
exit_code: bool,
|
||||
store: Option<&Path>,
|
||||
) -> Result<ExitCode> {
|
||||
) -> Result<bool> {
|
||||
let repo = Repo::discover(ctx, store)?;
|
||||
let pathspecs = relative_pathspecs(&repo, paths)?;
|
||||
|
||||
@@ -42,30 +42,32 @@ pub fn check(
|
||||
print_listing(&repo, &exposed);
|
||||
}
|
||||
|
||||
// git's --exit-code convention: nothing to report is 0, anything is 1
|
||||
if exit_code && !exposed.is_empty() {
|
||||
return Ok(ExitCode::FAILURE);
|
||||
}
|
||||
|
||||
Ok(ExitCode::SUCCESS)
|
||||
Ok(!exposed.is_empty())
|
||||
}
|
||||
|
||||
fn print_porcelain(exposed: &[Exposed], null: bool) {
|
||||
let end = if null { '\0' } else { '\n' };
|
||||
// a filename can hold an arrow but not a NUL, as `git status -z` also assumes
|
||||
let between = if null { "\0" } else { " -> " };
|
||||
|
||||
for item in exposed {
|
||||
match &item.dest {
|
||||
Some(dest) => print!("{} {} -> {}{end}", item.code(), item.name, dest.display()),
|
||||
None => print!("{} {}{end}", item.code(), item.name),
|
||||
Some(dest) => text!(
|
||||
"{} {}{between}{}{end}",
|
||||
item.code(),
|
||||
item.name,
|
||||
dest.display()
|
||||
),
|
||||
None => text!("{} {}{end}", item.code(), item.name),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn print_listing(repo: &Repo, exposed: &[Exposed]) {
|
||||
println!("store: {}", repo.store.display());
|
||||
line!("store: {}", repo.store.display());
|
||||
|
||||
if exposed.is_empty() {
|
||||
println!("nothing outside the store, a sandbox would see tracked files only");
|
||||
line!("nothing outside the store, a sandbox would see tracked files only");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -93,11 +95,11 @@ fn print_listing(repo: &Repo, exposed: &[Exposed]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
println!("\n{heading}\n{hint}");
|
||||
line!("\n{heading}\n{hint}");
|
||||
for item in items {
|
||||
match &item.dest {
|
||||
Some(dest) => println!("\t{} -> {}", item.name, dest.display()),
|
||||
None => println!("\t{}", item.name),
|
||||
Some(dest) => line!("\t{} -> {}", item.name, dest.display()),
|
||||
None => line!("\t{}", item.name),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,7 +86,10 @@ fn repo_components(ctx: &Ctx, root: &Path) -> Result<PathBuf> {
|
||||
if let Some(components) = components_from_remote(&url) {
|
||||
return Ok(components);
|
||||
}
|
||||
note!("could not parse git remote `{url}`, falling back to the checkout name");
|
||||
note!(
|
||||
ctx,
|
||||
"could not parse git remote `{url}`, falling back to the checkout name"
|
||||
);
|
||||
}
|
||||
|
||||
let name = root
|
||||
|
||||
@@ -2,3 +2,4 @@ pub mod completions;
|
||||
pub mod django;
|
||||
pub mod link;
|
||||
pub mod postgres;
|
||||
pub mod status;
|
||||
|
||||
@@ -2,7 +2,7 @@ mod server;
|
||||
mod shape;
|
||||
|
||||
use fs_err::File;
|
||||
use std::io::{self, Write};
|
||||
use std::io::{self, IsTerminal, Write};
|
||||
use std::path::Path;
|
||||
use std::process::Stdio;
|
||||
|
||||
@@ -12,8 +12,8 @@ use self::server::{Database, wait_until_ready, when_ready};
|
||||
use self::shape::{Dump, HEADER_LEN, Kind};
|
||||
use crate::cli::Format;
|
||||
use crate::cmd::{
|
||||
Cmd, Cp, CreateDb, DropDb, Gunzip, Gzip, Head, PgDump, PgDumpAll, PgRestore, Rm, Start, Stop,
|
||||
Up,
|
||||
Cmd, Cp, CreateDb, DropDb, Gunzip, Gzip, Head, PgDump, PgDumpAll, PgRestore, Psql, Rm, Start,
|
||||
Stop, Up,
|
||||
};
|
||||
use crate::ctx::Ctx;
|
||||
use crate::fsops::{remove_file, rename, suffixed};
|
||||
@@ -45,11 +45,12 @@ fn restore_cluster(ctx: &Ctx, db: &Database, restore: &dyn Cmd, file: &Path) ->
|
||||
continue;
|
||||
}
|
||||
|
||||
note!("{line}");
|
||||
note!(ctx, "{line}");
|
||||
}
|
||||
|
||||
if existing > 0 {
|
||||
note!(
|
||||
ctx,
|
||||
"left {existing} existing role{} alone",
|
||||
if existing == 1 { "" } else { "s" }
|
||||
);
|
||||
@@ -66,11 +67,15 @@ pub fn import(ctx: &Ctx, file: &Path) -> Result<()> {
|
||||
let dump = Dump::of(file)?;
|
||||
let db = Database::resolve(ctx)?;
|
||||
|
||||
note!("stopping all containers");
|
||||
Stop.run(ctx)?;
|
||||
note!(ctx, "stopping all containers");
|
||||
Stop { quiet: ctx.quiet }.run(ctx)?;
|
||||
|
||||
note!("starting db container");
|
||||
Start::service(&db.service).run(ctx)?;
|
||||
note!(ctx, "starting db container");
|
||||
Start {
|
||||
service: &db.service,
|
||||
quiet: ctx.quiet,
|
||||
}
|
||||
.run(ctx)?;
|
||||
|
||||
let remote = remote_dump();
|
||||
|
||||
@@ -121,7 +126,7 @@ pub fn import(ctx: &Ctx, file: &Path) -> Result<()> {
|
||||
let restore = db.restore_with(kind);
|
||||
// the name of what actually runs, so the message cannot drift from it
|
||||
let tool = restore.argv().program().to_string();
|
||||
note!("restoring database with {tool}");
|
||||
note!(ctx, "restoring database with {tool}");
|
||||
|
||||
when_ready(
|
||||
ctx,
|
||||
@@ -149,7 +154,7 @@ pub fn import(ctx: &Ctx, file: &Path) -> Result<()> {
|
||||
|
||||
wait_until_ready(ctx, &db)?;
|
||||
if ctx.dry_run {
|
||||
note!("would restore with {tool}");
|
||||
note!(ctx, "would restore with {tool}");
|
||||
} else {
|
||||
// a directory dump was copied in whole, so pg_restore reads it from the
|
||||
// container; every other shape is fed in on stdin, through gunzip when it
|
||||
@@ -190,13 +195,25 @@ pub fn import(ctx: &Ctx, file: &Path) -> Result<()> {
|
||||
let _ = Rm::recursive(&remote).in_container(&db.container).run(ctx);
|
||||
}
|
||||
|
||||
note!("restarting containers");
|
||||
Stop.run(ctx)?;
|
||||
Up.run(ctx)?;
|
||||
note!(ctx, "restarting containers");
|
||||
Stop { quiet: ctx.quiet }.run(ctx)?;
|
||||
Up { quiet: ctx.quiet }.run(ctx)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn psql(ctx: &Ctx, rest: &[String]) -> Result<()> {
|
||||
let db = Database::resolve(ctx)?;
|
||||
|
||||
// a terminal only if this one has one, so `psql -c ... | cat` still works
|
||||
Psql::new(&db.user, &db.name)
|
||||
.args(rest)
|
||||
.in_container(&db.container)
|
||||
.interactive()
|
||||
.tty(io::stdin().is_terminal())
|
||||
.replace(ctx)
|
||||
}
|
||||
|
||||
pub fn dump(ctx: &Ctx, file: &Path, format: Format, gzip: bool) -> Result<()> {
|
||||
let db = Database::resolve(ctx)?;
|
||||
|
||||
@@ -208,7 +225,7 @@ pub fn dump(ctx: &Ctx, file: &Path, format: Format, gzip: bool) -> Result<()> {
|
||||
return dump_directory(ctx, &db, file);
|
||||
}
|
||||
|
||||
note!("dumping to local file {}", file.to_string_lossy());
|
||||
note!(ctx, "dumping to local file {}", file.to_string_lossy());
|
||||
|
||||
// written beside the target and renamed once the dump succeeds, so a failure
|
||||
// cannot destroy the dump that is already there
|
||||
@@ -259,7 +276,7 @@ fn dump_directory(ctx: &Ctx, db: &Database, target: &Path) -> Result<()> {
|
||||
);
|
||||
}
|
||||
|
||||
note!("dumping to local directory {}", target.display());
|
||||
note!(ctx, "dumping to local directory {}", target.display());
|
||||
let remote = remote_dump();
|
||||
|
||||
PgDump::new(&db.user, &db.name, "d")
|
||||
|
||||
@@ -42,8 +42,8 @@ impl Database {
|
||||
pub(super) fn restore_with(&self, kind: Kind) -> Box<dyn Cmd + '_> {
|
||||
match kind {
|
||||
Kind::Archive => Box::new(PgRestore::new(&self.user, &self.name)),
|
||||
Kind::Sql => Box::new(Psql::new(&self.user, &self.name).atomic()),
|
||||
Kind::Cluster => Box::new(Psql::new(&self.user, "postgres")),
|
||||
Kind::Sql => Box::new(Psql::new(&self.user, &self.name).quiet().atomic()),
|
||||
Kind::Cluster => Box::new(Psql::new(&self.user, "postgres").quiet()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
86
src/commands/status.rs
Normal file
86
src/commands/status.rs
Normal file
@@ -0,0 +1,86 @@
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::cmd::{Cmd, Ps};
|
||||
use crate::commands::link;
|
||||
use crate::ctx::Ctx;
|
||||
use crate::output::line;
|
||||
use crate::project::Project;
|
||||
|
||||
// which services ahab picked, what it would talk to, what the store holds
|
||||
pub fn status(ctx: &Ctx, store: Option<&Path>) -> Result<()> {
|
||||
// a role it cannot pick out is worth reporting; a project it cannot read is not
|
||||
let project = match Project::resolve(ctx) {
|
||||
Ok(project) => project,
|
||||
Err(e) => {
|
||||
stored(ctx, store);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
line!("services: {}", project.names().join(", "));
|
||||
|
||||
role(ctx, "django", project.django(), &project);
|
||||
role(ctx, "postgres", project.postgres(), &project);
|
||||
stored(ctx, store);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// what the store holds, and where to look for what it does not
|
||||
fn stored(ctx: &Ctx, store: Option<&Path>) {
|
||||
match link::stored_summary(ctx, store) {
|
||||
Err(e) => line!("store: {e:#}"),
|
||||
Ok((store, linked, other)) => {
|
||||
line!("store: {}", store.display());
|
||||
line!("\tlinked: {linked}");
|
||||
|
||||
if other > 0 {
|
||||
line!("\tnot linked: {other} (see `ahab link list`)");
|
||||
}
|
||||
|
||||
line!("\t`ahab link check` lists what a sandbox can still read");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// one detected service: the name, the image behind it, and whether it is up
|
||||
fn role(ctx: &Ctx, role: &str, detected: Result<String>, project: &Project) {
|
||||
let service = match detected {
|
||||
Ok(service) => service,
|
||||
Err(e) => {
|
||||
line!("{role}: {e:#}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let image = project
|
||||
.image(&service)
|
||||
.unwrap_or("built from the project")
|
||||
.to_string();
|
||||
|
||||
line!("{role}: {service} ({image})");
|
||||
|
||||
match Ps::id_of(&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:#}"),
|
||||
}
|
||||
|
||||
if role == "postgres" {
|
||||
let (user, database) = project.postgres_credentials(&service);
|
||||
let source = |key: &str| match project.env(&service, key) {
|
||||
Some(_) => "",
|
||||
None => " (default, nothing in the environment)",
|
||||
};
|
||||
|
||||
line!("\tuser: {user}{}", source("POSTGRES_USER"));
|
||||
line!("\tdatabase: {database}{}", source("POSTGRES_DB"));
|
||||
}
|
||||
}
|
||||
|
||||
// a container id is 64 characters and only the first few are ever typed
|
||||
fn short(id: &str) -> String {
|
||||
id.chars().take(12).collect()
|
||||
}
|
||||
@@ -2,4 +2,5 @@
|
||||
pub struct Ctx {
|
||||
pub verbose: bool,
|
||||
pub dry_run: bool,
|
||||
pub quiet: bool,
|
||||
}
|
||||
|
||||
33
src/main.rs
33
src/main.rs
@@ -14,18 +14,22 @@ use clap::Parser;
|
||||
use crate::ctx::Ctx;
|
||||
use crate::output::note;
|
||||
|
||||
// 0 ran with nothing to report, 1 could not finish, 2 bad arguments, 4 found something
|
||||
const FINDINGS: u8 = 4;
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let args = cli::Ahab::parse();
|
||||
|
||||
let ctx = Ctx {
|
||||
verbose: args.verbose,
|
||||
dry_run: args.dry_run,
|
||||
quiet: args.quiet,
|
||||
};
|
||||
|
||||
// said once here rather than by each command, so every line that follows
|
||||
// reads as the plan it is
|
||||
if ctx.dry_run {
|
||||
note!("dry run, nothing will be changed");
|
||||
note!(ctx, "dry run, nothing will be changed");
|
||||
}
|
||||
|
||||
match run(&ctx, args.command) {
|
||||
@@ -52,7 +56,6 @@ fn run(ctx: &Ctx, command: cli::Commands) -> Result<ExitCode> {
|
||||
cli::Django::Manage { rest } => commands::django::manage(ctx, &rest),
|
||||
cli::Django::Migrate { rest } => commands::django::migrate(ctx, &rest),
|
||||
cli::Django::Shell => commands::django::shell(ctx),
|
||||
cli::Django::Test => commands::django::test(ctx),
|
||||
}?;
|
||||
|
||||
Ok(done)
|
||||
@@ -60,6 +63,7 @@ fn run(ctx: &Ctx, command: cli::Commands) -> Result<ExitCode> {
|
||||
cli::Commands::Postgres { command } => {
|
||||
match command {
|
||||
cli::Postgres::Import { path } => commands::postgres::import(ctx, &path),
|
||||
cli::Postgres::Psql { rest } => commands::postgres::psql(ctx, &rest),
|
||||
cli::Postgres::Dump { path, format, gzip } => {
|
||||
commands::postgres::dump(ctx, &path, format, gzip)
|
||||
}
|
||||
@@ -73,6 +77,9 @@ fn run(ctx: &Ctx, command: cli::Commands) -> Result<ExitCode> {
|
||||
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)
|
||||
}
|
||||
@@ -83,15 +90,21 @@ fn run(ctx: &Ctx, command: cli::Commands) -> Result<ExitCode> {
|
||||
null,
|
||||
exit_code,
|
||||
store,
|
||||
} => commands::link::check(
|
||||
ctx,
|
||||
&paths,
|
||||
porcelain,
|
||||
null,
|
||||
exit_code,
|
||||
store.root.as_deref(),
|
||||
),
|
||||
} => {
|
||||
let exposed =
|
||||
commands::link::check(ctx, &paths, porcelain, null, store.root.as_deref())?;
|
||||
|
||||
match exit_code && exposed {
|
||||
true => Ok(ExitCode::from(FINDINGS)),
|
||||
false => Ok(done),
|
||||
}
|
||||
}
|
||||
},
|
||||
cli::Commands::Status { store } => {
|
||||
commands::status::status(ctx, store.root.as_deref())?;
|
||||
|
||||
Ok(done)
|
||||
}
|
||||
cli::Commands::Completions { shell } => {
|
||||
commands::completions::completions(shell)?;
|
||||
|
||||
|
||||
@@ -1,11 +1,43 @@
|
||||
// progress and warnings go to stderr, so what a caller pipes is only ever the
|
||||
// data a command was asked for
|
||||
use std::fmt::Arguments;
|
||||
use std::io::{self, Write};
|
||||
|
||||
// progress, on stderr and only when it was asked for
|
||||
macro_rules! note {
|
||||
($($arg:tt)*) => { eprintln!($($arg)*) };
|
||||
($ctx:expr, $($arg:tt)*) => {
|
||||
if !$ctx.quiet {
|
||||
eprintln!($($arg)*)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// not progress: it speaks about the result, so --quiet keeps it
|
||||
macro_rules! warning {
|
||||
($($arg:tt)*) => { eprintln!("warning: {}", format_args!($($arg)*)) };
|
||||
}
|
||||
|
||||
pub(crate) use {note, warning};
|
||||
// what a command was asked for, on stdout
|
||||
macro_rules! line {
|
||||
($($arg:tt)*) => { $crate::output::write_line(format_args!($($arg)*)) };
|
||||
}
|
||||
|
||||
// the same, for a caller that terminates its own entries
|
||||
macro_rules! text {
|
||||
($($arg:tt)*) => { $crate::output::write_text(format_args!($($arg)*)) };
|
||||
}
|
||||
|
||||
pub(crate) fn write_line(args: Arguments) {
|
||||
finish(writeln!(io::stdout(), "{args}"));
|
||||
}
|
||||
|
||||
pub(crate) fn write_text(args: Arguments) {
|
||||
finish(write!(io::stdout(), "{args}"));
|
||||
}
|
||||
|
||||
// a reader leaving early ends the pipe; println! would panic instead
|
||||
fn finish(written: io::Result<()>) {
|
||||
if written.is_err() {
|
||||
std::process::exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) use {line, note, text, warning};
|
||||
|
||||
@@ -91,7 +91,7 @@ impl Project {
|
||||
(user, database)
|
||||
}
|
||||
|
||||
fn names(&self) -> Vec<&str> {
|
||||
pub fn names(&self) -> Vec<&str> {
|
||||
let mut names: Vec<&str> = self
|
||||
.services
|
||||
.as_object()
|
||||
@@ -102,6 +102,16 @@ impl Project {
|
||||
names
|
||||
}
|
||||
|
||||
// the image a service runs, which a service that builds its own does not have
|
||||
pub fn image(&self, service: &str) -> Option<&str> {
|
||||
self.services[service].get("image")?.as_str()
|
||||
}
|
||||
|
||||
// whether a value was set in the project or is ahab's own fallback
|
||||
pub fn env(&self, service: &str, key: &str) -> Option<String> {
|
||||
env_var(&self.services[service], key)
|
||||
}
|
||||
|
||||
fn publishes_ports(&self, service: &str) -> bool {
|
||||
self.services[service]
|
||||
.get("ports")
|
||||
|
||||
Reference in New Issue
Block a user