7 Commits

11 changed files with 504 additions and 224 deletions

2
Cargo.lock generated
View File

@@ -4,7 +4,7 @@ version = 4
[[package]] [[package]]
name = "ahab" name = "ahab"
version = "0.5.0" version = "0.6.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap", "clap",

View File

@@ -2,7 +2,7 @@
name = "ahab" name = "ahab"
description = "docker compose wrapper for django projects, with service detection and database dumps" description = "docker compose wrapper for django projects, with service detection and database dumps"
readme = "README.md" readme = "README.md"
version = "0.5.0" version = "0.6.0"
edition = "2024" edition = "2024"
rust-version = "1.85" rust-version = "1.85"
license = "MIT" license = "MIT"

379
README.md
View File

@@ -3,18 +3,155 @@
A wrapper around `docker compose` for our dockerized Django projects, so the A wrapper around `docker compose` for our dockerized Django projects, so the
same commands work in every repository. same commands work in every repository.
It reads `docker compose config` to work out which service runs Django and
which one is the database, so nothing here takes a service name or a `-f`.
Everything it does, it does by running `docker`, `git` and the Postgres tools.
## Installing ## Installing
You will need Rust installed. Clone repo and run: You will need Rust installed. Clone the repo and run:
```bash ```bash
cargo install --path . cargo install --path .
``` ```
## What it makes of a project ## Django
`ahab status` prints the services it picked, the containers behind them, the Each of these runs `docker compose run --rm` against the Django service, so it
credentials it would use and what the store holds, reporting whatever it cannot goes through the image's own entrypoint:
work out rather than stopping at the first thing:
```bash
ahab django run <cmd> # anything, in a fresh container
ahab django bash # a shell in a fresh container
ahab django manage <args> # manage.py
ahab django makemigrations
ahab django migrate <args>
ahab django shell
ahab django make-command <app> <name>
```
## Postgres
```bash
ahab postgres dump <path> # pg_dump, custom format
ahab postgres dump -F plain <path> # pg_dump, plain SQL
ahab postgres dump -F tar <path> # pg_dump, tar
ahab postgres dump -F directory <d> # pg_dump, a directory of files
ahab postgres dump -F cluster <path> # pg_dumpall, roles and all databases
ahab postgres dump -F plain -z <path> # any of them, gzipped
ahab postgres import <path> # drop, create, then restore
ahab postgres psql <args> # psql in the database container
```
Dumps are written `0600`, under a name of their own until they are complete and
then renamed over the target.
Arguments reach `psql` untouched, and Docker is only asked for a terminal when
there is one to hand over, so both of these work:
```bash
ahab postgres psql # interactive
ahab postgres psql -tAc 'select count(*) from auth_user' | wc -l
```
### What an import makes of a dump
The shape is read from the file rather than from its name:
- a `PGDMP` or `toc.dat` header — an archive, restored with `pg_restore`, as is
a directory produced by `pg_dump -Fd`
- a `pg_dumpall` header — a whole cluster, which creates its own databases and
carries role statements, so the database is dropped but not recreated and the
dump goes to `psql` connected to `postgres`
- gzip magic — decompressed on the way in, whichever of the three is inside
- anything else — SQL, fed to `psql` with `ON_ERROR_STOP` and
`--single-transaction`, so a bad file rolls back instead of half applying
A cluster restore runs without `ON_ERROR_STOP`, so the expected complaints
about roles that already exist do not stop it. Any other error fails the
import. Importing into a cluster that has never held the database works either
way.
Containers are stopped before an import starts, so one that fails leaves the
project stopped and says so.
## Link
A sandbox that mounts the repository can read anything untracked sitting in it
— a `.env`, a dump, a key. Those paths can be moved out to a store of ahab's
own and symlinked back: the host resolves the link, the sandbox does not.
```bash
ahab link add .env secrets/ # move out, leave symlinks behind
ahab link restore .env # move it back into the repository
ahab link restore --all # everything this repo has linked
ahab link list # what the store holds for this repository
ahab link check # what a sandbox can still read
ahab link check --porcelain # `<code> <path>`, for scripts
ahab link check --exit-code # exit 4 when anything is outside the store
ahab link migrate # move out of the layout ahab 0.5 used
```
Restoring is the inverse of adding: the file comes back to where it was and the
store keeps nothing. Every checkout has a store directory of its own, so a
second clone of the same repository links and restores its files without
touching the first one's.
Every stored path is in one of three states:
```
$ ahab link list
store: /home/you/.local/share/ahab/git.aflabs.org/urnik/afurnik/afurnik-5f1c8e3a
linked: .env
shadowed: config.local.py
missing: secrets/token
```
- `linked` — the symlink is there and points at the store
- `shadowed` — a real file took the path back, so nothing reads the stored copy
- `missing` — the symlink is gone, and nothing reads the stored copy either
Only a linked path can be moved back, so `restore --all` says how many it left
alone.
Adding refuses two kinds of path:
- **a tracked path**, which git would restore anyway. Where one is a symlink
leading out of the repository, `check` reports it under the code `T>`
- **a symlink already leading out of the repository**, since moving it would
move the pointer and leave the contents where they are
For scripts there is `--porcelain`, and `-z` for filenames that need it:
entries end with a NUL, and the two paths of a symlink leading elsewhere are
separated by one as well, the way `git status -z` reports a rename.
The store lives under
`${XDG_DATA_HOME:-$HOME/.local/share}/ahab/<host>/<owner>/<repo>/<dir>-<hash>/`.
The repository part is derived from the Git `origin` remote, or
`_local/<checkout>` when there is no remote to name it after; the last part is
the checkout's directory name and a short hash of its full path, so two clones
of one repository never share a directory. Its directories are created `0700`.
A remote or checkout name that needed characters replacing carries a short
fingerprint of the original, so two of them cannot share a directory either.
Moving a checkout changes its hash. The symlinks still resolve, `check` still
counts them as in the store and `restore <path>` still brings them back, so the
way over is `restore` followed by `add`; `list` and `restore --all` only look
in the new directory.
### Upgrading from 0.5
Before 0.6 the store had no per-checkout part, so existing links point at
`<repo>/` directly. They keep working. `list` and `status` report them as
`legacy`, and `ahab link migrate` moves them into the checkout's own directory
and repoints the links. Only what the current checkout links is moved, so
another clone's files in the same directory are left where they are.
## What ahab makes of your project
Running `ahab status` prints the services it picked, the containers behind
them, the credentials it would use and what the store holds. It reports
whatever it cannot work out rather than stopping at the first problem:
``` ```
$ ahab status $ ahab status
@@ -25,17 +162,19 @@ postgres: db (postgres:18-alpine)
container: 189d4d395d62 container: 189d4d395d62
user: myproject user: myproject
database: myproject_db database: myproject_db
store: /home/you/.local/share/ahab/git.aflabs.org/urnik/afurnik store: /home/you/.local/share/ahab/git.aflabs.org/urnik/afurnik/afurnik-5f1c8e3a
linked: 2 linked: 2
`ahab link check` lists what a sandbox can still read `ahab link check` lists what a sandbox can still read
``` ```
## Seeing what it runs ## Seeing what it runs
`-v` prints each Docker command as it runs, `--dry-run` prints the ones it would - `-v` — print each Docker command as it runs
run without running them, and `-q` prints only what was asked for, dropping the - `--dry-run` — print the commands it would run, without running them
progress along the way (Compose's own progress included). All work before or - `-q` — print only what was asked for, dropping the progress along the way,
after the subcommand. Compose's own included
All three work before or after the subcommand:
```bash ```bash
ahab -v django migrate ahab -v django migrate
@@ -43,30 +182,50 @@ ahab --dry-run postgres import ./dump
ahab -q postgres dump ./dump ahab -q postgres dump ./dump
``` ```
Nothing is written under `--dry-run`, filesystem included: `link add` and Nothing is written under `--dry-run`, the filesystem included: `link add` and
`django make-command` say what they would do and leave the tree alone. `django make-command` say what they would do and leave the tree alone.
## Exit codes ## Service detection
- `0` — It ran and had nothing to report Services can be named anything; ahab matches on what they are:
- `1` — It could not finish
- `2` — The arguments were wrong - **postgres** — the service whose image is a Postgres flavour, matching
- `4` — It ran fine and found something worth reporting, i.e. `postg`, `timescale`, `pgvector` or `citus`
`link check --exit-code` with anything outside the store - **django** — the service that both builds an image and sets
`DJANGO_SETTINGS_MODULE`. Where more than one does, a worker beside the web
service say, the one publishing ports wins
If nothing matches, or two candidates cannot be told apart, ahab says so and
lists the services it looked at rather than guessing.
The role and database come from `POSTGRES_USER` and `POSTGRES_DB` on the
detected service, falling back to `db` when neither is set. Both have to be
names: a value holding an `=`, a URL or a leading dash is refused, libpq
reading such a name as a whole connection string.
## The compose file
Compose finds its own file and ahab does not pass `-f`, so set Docker's own
`COMPOSE_FILE` when the file is not in the working directory, including its
`base.yaml:override.yaml` form. A project's `.env` is a good place for it,
since Docker reads that too:
```
COMPOSE_FILE=docker/docker-compose.yaml
```
## Shell completion ## Shell completion
`ahab completions <shell>` writes a completion script to stdout, for Bash, Completion scripts are written to stdout, for Bash, Elvish, Fish, PowerShell
Elvish, Fish, PowerShell or Zsh: or Zsh:
```bash ```bash
ahab completions zsh > ~/.local/share/zsh/completions/_ahab ahab completions zsh > ~/.local/share/zsh/completions/_ahab
eval "$(ahab completions zsh)" # or one line in .zshrc, never goes stale eval "$(ahab completions zsh)" # or one line in .zshrc, never goes stale
``` ```
Completions are also generated during the build. They land in They are generated during the build as well, landing in
`target/*/build/*/out/` by default, and `SHELL_COMPLETIONS_DIR_<SHELL>` `target/*/build/*/out/`. To install a single shell's file straight into place:
installs a single shell's file straight into place:
```bash ```bash
SHELL_COMPLETIONS_DIR_ZSH=~/.local/share/zsh/completions \ SHELL_COMPLETIONS_DIR_ZSH=~/.local/share/zsh/completions \
@@ -74,170 +233,20 @@ SHELL_COMPLETIONS_DIR_FISH=~/.config/fish/completions \
cargo install --path . cargo install --path .
``` ```
`SHELL_COMPLETIONS_DIR` writes every shell into one directory instead. Set `SHELL_COMPLETIONS_DIR` to write every shell into one directory instead.
Either way the path has to be absolute.
## The compose file ## Exit codes
`ahab` does not pass `-f`. Docker Compose finds the file itself, so set - `0` — it ran and had nothing to report
Docker's own `COMPOSE_FILE` when it is not in the working directory, including - `1` — it could not finish
its `base.yaml:override.yaml` form. A project's `.env` is a good place for it, - `2` — the arguments were wrong
since Docker reads that too: - `4` — it ran fine and found something worth reporting, which today means
`link check --exit-code` with anything outside the store
``` ## Environment variables
COMPOSE_FILE=docker/docker-compose.yaml
```
## Service detection - `AHAB_LINK_ROOT` — where `ahab link` keeps its store, the same as `--store`,
which takes precedence. Defaults to
`ahab` finds the services it needs in `docker compose config`, so they can be `${XDG_DATA_HOME:-$HOME/.local/share}/ahab`. Read by `ahab status` too, so it
named anything: reports the store the link commands actually use
- **postgres**: The service whose image is a Postgres flavour, matching
`postg`, `timescale`, `pgvector` or `citus`
- **django**: The service that both builds an image and sets
`DJANGO_SETTINGS_MODULE`. Where more than one does — a worker beside the web
service, say — the one publishing ports wins
If nothing matches, or two candidates cannot be told apart, `ahab` says so and
lists the services it looked at rather than guessing. Two services that both
build and set the settings module are ambiguous unless exactly one of them
publishes ports.
`POSTGRES_USER` and `POSTGRES_DB` are read off the detected Postgres service, so
`dropdb`, `createdb`, `pg_restore` and `pg_dump` use the role and database the
project declares, falling back to `db` when it declares neither. Both have to
be names: `ahab` hands them to those tools as a role and a database, and libpq
reads a database name holding an `=` or a URL as a whole connection string,
which would send a dump to whatever server it names. A value that could be read
as something other than a name is refused rather than passed on.
## Django
```bash
ahab django run <cmd> # in a fresh container, through the entrypoint
ahab django bash # shell in a fresh container
ahab django manage <args> # manage.py
ahab django makemigrations
ahab django migrate <args>
ahab django shell
ahab django make-command <app> <name>
```
There is no `ahab django test`: which runner a project uses is the project's
choice, and `manage.py test` exits 0 having collected nothing when the tests are
written for pytest, so a wrong guess reads as a pass. Name the runner instead:
```bash
ahab django run pytest -x tests/
```
## Postgres
```bash
ahab postgres dump <path> # pg_dump, custom format
ahab postgres dump -F plain <path> # pg_dump, plain SQL
ahab postgres dump -F tar <path> # pg_dump, tar
ahab postgres dump -F directory <d> # pg_dump, a directory of files
ahab postgres dump -F cluster <path> # pg_dumpall, roles and all databases
ahab postgres dump -F plain -z <path> # any of them, gzipped
ahab postgres import <path> # drop, create, then restore
ahab postgres psql <args> # psql in the database container
```
A dump is written `0600` and under a name of its own until it is complete, then
renamed over the target: a cluster dump carries every role's password hash, and
the default `0644` would hand it to anyone else with an account on the machine.
`psql` passes its arguments through and only asks Docker for a terminal when it
has one to hand over, so both of these work:
```bash
ahab postgres psql # interactive
ahab postgres psql -tAc 'select count(*) from auth_user' | wc -l
```
The format of a dump being imported is read from the file rather than its name.
A custom format dump starts with `PGDMP` and a tar one with `toc.dat`, both of
which go to `pg_restore`, as does a directory produced by `pg_dump -Fd`.
Anything else is treated as SQL and fed to `psql` with `ON_ERROR_STOP` and
`--single-transaction`, so a bad file rolls back instead of half applying.
A whole cluster dump from `pg_dumpall` is recognised by its header and handled
differently again: it creates its own databases and carries role statements, so
the database is dropped but not recreated, the dump goes to `psql` connected to
`postgres`, and it runs without `ON_ERROR_STOP` because roles that already exist
report errors that are expected. Any error it does not expect is reported and
fails the import, since `psql` without `ON_ERROR_STOP` exits 0 having applied
only part of the dump. Gzipped dumps are decompressed on the way in, whichever
of the three they hold. A cluster that has never held the database yet is a
valid target either way, so importing into a fresh one works.
`import` stops the project before it starts, so an import that fails leaves it
stopped and says so: `docker compose up` brings it back.
## Link
`ahab link` moves untracked paths out of the repository into an out-of-repo
store and symlinks them back, so a sandbox that mounts the repository sees a
dangling symlink instead of the contents, while the host resolves it as before.
```bash
ahab link add .env secrets/ # move out, leave symlinks behind
ahab link restore .env # move it back into the repository
ahab link restore --all # everything this repo has linked
ahab link list # what the store holds for this repository
ahab link check # what a sandbox can still read
ahab link check --porcelain # `<code> <path>`, for scripts
ahab link check --exit-code # exit 4 when anything is outside the store
```
`restore` is the inverse of `add`: the file moves out of the store and back to
where it was, and the store keeps nothing. A second checkout linking the same
path is left with a dangling symlink, since there is only ever one stored copy.
`list` says what state each stored path is in, which `check` cannot see because
it asks Git about the repository rather than reading the store:
```
$ ahab link list
store: /home/you/.local/share/ahab/git.aflabs.org/urnik/afurnik
linked: .env
shadowed: config.local.py
missing: secrets/token
```
`missing` is a path whose symlink is gone, `shadowed` one that a real file took
back; either way the stored copy is the one nobody reads.
`restore --all` moves back the linked ones and says how many it left, since
there is nothing to undo for a path whose symlink is gone.
`-z` is the porcelain format for scripts that must survive any filename: entries
end with a NUL, and the two paths of a symlink leading elsewhere are separated
by one as well, the way `git status -z` reports a rename.
`check` looks at tracked paths too, since Git tracks symlinks and one can lead
out of the repository without appearing in any untracked listing. `add` cannot
externalize a tracked path, so all `check` can do is report it, under the code
`T>`.
A symlink already leading out of the repository is not something `add` will
take: moving the link would move the pointer and leave the contents where they
are, so the store would hold a way back out while `check`, seeing a link into
the store, called the repository clean.
The store lives under
`${XDG_DATA_HOME:-$HOME/.local/share}/ahab/<host>/<owner>/<repo>/`, derived
from the Git `origin` remote, or `_local/<checkout>` when there is no remote to
name it after. Its directories are created `0700`: it exists to hold what
should not be readable from the repository, and on a shared machine the default
`0755` would leave that to whoever else has an account. A store path derived
from a remote, or a checkout name, that needed characters replacing carries a
short fingerprint of the original, so two of them cannot land on one directory.
## Configuration
Currently `ahab` respects the following environment variables.
- `AHAB_LINK_ROOT`: Where `ahab link` keeps its store — the same as `--store`,
which takes precedence, and defaults to `${XDG_DATA_HOME:-$HOME/.local/share}/ahab`.
`ahab status` reads it too, so it reports the store the link commands use

View File

@@ -19,6 +19,15 @@ pub enum Link {
/// List untracked paths a sandbox would still see /// List untracked paths a sandbox would still see
Check(Check), Check(Check),
/// Move this checkout's paths from the old store layout into its own directory
Migrate(Migrate),
}
#[derive(Args, Debug)]
pub struct Migrate {
#[command(flatten)]
pub store: Store,
} }
#[derive(Args, Debug)] #[derive(Args, Debug)]

View File

@@ -9,7 +9,7 @@ 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, held, ignored, leads, resolve, symlink_metadata_opt, tracked};
use crate::cli::link as cli; use crate::cli::link as cli;
use crate::ctx::Ctx; use crate::ctx::Ctx;
use crate::fsops::suffixed; use crate::fsops::suffixed;
@@ -104,7 +104,6 @@ pub fn restore(ctx: &Ctx, args: &cli::Restore) -> Result<()> {
fn restore_one(ctx: &Ctx, repo: &Repo, path: &Path, report: &Report) -> Result<()> { fn restore_one(ctx: &Ctx, repo: &Repo, path: &Path, report: &Report) -> Result<()> {
let src = resolve(path)?; let src = resolve(path)?;
let rel = repo.relative(&src)?; let rel = repo.relative(&src)?;
let stored = repo.store.join(&rel);
let Some(meta) = symlink_metadata_opt(&src)? else { let Some(meta) = symlink_metadata_opt(&src)? else {
bail!("{} does not exist", rel.display()); bail!("{} does not exist", rel.display());
@@ -116,14 +115,16 @@ fn restore_one(ctx: &Ctx, repo: &Repo, path: &Path, report: &Report) -> Result<(
); );
} }
// followed to wherever under the base it points, so a checkout that moved
// or a store laid out by an older ahab can still take its files back
let dest = read_link(&src)?; let dest = read_link(&src)?;
if dest != stored { let Some(stored) = held(repo, &src, &dest) else {
bail!( bail!(
"{} points at {}, which is not where the store keeps it", "{} points at {}, which is not in the store",
rel.display(), rel.display(),
dest.display() dest.display()
); );
} };
if symlink_metadata_opt(&stored)?.is_none() { if symlink_metadata_opt(&stored)?.is_none() {
bail!("{} is missing from the store", rel.display()); bail!("{} is missing from the store", rel.display());
} }
@@ -169,6 +170,12 @@ enum Stored {
// the store mirrors the repository layout, so walking it finds every path this // the store mirrors the repository layout, so walking it finds every path this
// repository has put there without asking git anything // repository has put there without asking git anything
fn stored_paths(repo: &Repo, dir: &Path) -> Result<Vec<(PathBuf, Stored)>> { fn stored_paths(repo: &Repo, dir: &Path) -> Result<Vec<(PathBuf, Stored)>> {
mirrored_paths(repo, &repo.store, dir)
}
// the paths under `dir` as the repository would link them, with `mirror` as
// the directory that stands for the repository root
fn mirrored_paths(repo: &Repo, mirror: &Path, dir: &Path) -> Result<Vec<(PathBuf, Stored)>> {
let mut found = Vec::new(); let mut found = Vec::new();
let entries = match read_dir(dir) { let entries = match read_dir(dir) {
@@ -179,11 +186,11 @@ fn stored_paths(repo: &Repo, dir: &Path) -> Result<Vec<(PathBuf, Stored)>> {
for entry in entries { for entry in entries {
let stored = entry?.path(); let stored = entry?.path();
let rel = stored.strip_prefix(&repo.store).with_context(|| { let rel = stored.strip_prefix(mirror).with_context(|| {
format!( format!(
"{} is not under the store {}", "{} is not under the store {}",
stored.display(), stored.display(),
repo.store.display() mirror.display()
) )
})?; })?;
let src = repo.root.join(rel); let src = repo.root.join(rel);
@@ -203,7 +210,7 @@ fn stored_paths(repo: &Repo, dir: &Path) -> Result<Vec<(PathBuf, Stored)>> {
if state == Stored::Linked || !holds_dir { if state == Stored::Linked || !holds_dir {
found.push((src, state)); found.push((src, state));
} else { } else {
found.extend(stored_paths(repo, &stored)?); found.extend(mirrored_paths(repo, mirror, &stored)?);
} }
} }
@@ -211,8 +218,29 @@ fn stored_paths(repo: &Repo, dir: &Path) -> Result<Vec<(PathBuf, Stored)>> {
Ok(found) Ok(found)
} }
// what this repository links into the flat layout above its own directory,
// from before the store was keyed by checkout
fn legacy_paths(repo: &Repo) -> Result<Vec<PathBuf>> {
let Some(legacy) = repo.store.parent() else {
return Ok(Vec::new());
};
Ok(mirrored_paths(repo, legacy, legacy)?
.into_iter()
.filter(|(_, state)| *state == Stored::Linked)
.map(|(src, _)| src)
.collect())
}
pub struct Summary {
pub store: PathBuf,
pub linked: usize,
pub other: usize,
pub legacy: usize,
}
// what the store holds, without the git questions check asks // what the store holds, without the git questions check asks
pub fn stored_summary(ctx: &Ctx, store: Option<&Path>) -> Result<(PathBuf, usize, usize)> { pub fn stored_summary(ctx: &Ctx, store: Option<&Path>) -> Result<Summary> {
let repo = Repo::discover(ctx, store)?; let repo = Repo::discover(ctx, store)?;
let stored = stored_paths(&repo, &repo.store)?; let stored = stored_paths(&repo, &repo.store)?;
@@ -221,17 +249,27 @@ pub fn stored_summary(ctx: &Ctx, store: Option<&Path>) -> Result<(PathBuf, usize
.filter(|(_, state)| *state == Stored::Linked) .filter(|(_, state)| *state == Stored::Linked)
.count(); .count();
Ok((repo.store, linked, stored.len() - linked)) let legacy = legacy_paths(&repo)?.len();
Ok(Summary {
store: repo.store,
linked,
other: stored.len() - linked,
legacy,
})
} }
pub const MIGRATE_HINT: &str = "run `ahab link migrate` to move them into it";
// 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, args: &cli::List) -> Result<()> { pub fn list(ctx: &Ctx, args: &cli::List) -> Result<()> {
let repo = Repo::discover(ctx, args.store.root())?; let repo = Repo::discover(ctx, args.store.root())?;
let stored = stored_paths(&repo, &repo.store)?; let stored = stored_paths(&repo, &repo.store)?;
let legacy = legacy_paths(&repo)?;
line!("store: {}", repo.store.display()); line!("store: {}", repo.store.display());
if stored.is_empty() { if stored.is_empty() && legacy.is_empty() {
line!("nothing in the store for this repository"); line!("nothing in the store for this repository");
return Ok(()); return Ok(());
} }
@@ -247,6 +285,67 @@ pub fn list(ctx: &Ctx, args: &cli::List) -> Result<()> {
entry(verb, rel); entry(verb, rel);
} }
for path in &legacy {
entry("legacy", path.strip_prefix(&repo.root).unwrap_or(path));
}
if !legacy.is_empty() {
line!(
"{} path{} in the old layout, {MIGRATE_HINT}",
legacy.len(),
plural(legacy.len())
);
}
Ok(())
}
// move this checkout's paths from the old layout into its own directory
pub fn migrate(ctx: &Ctx, args: &cli::Migrate) -> Result<()> {
let repo = Repo::discover(ctx, args.store.root())?;
let report = Report::new(&repo);
let legacy = legacy_paths(&repo)?;
if legacy.is_empty() {
note!(ctx, "nothing in the old layout for this repository");
return Ok(());
}
each(&legacy, |src| migrate_one(ctx, &repo, src, &report))
}
fn migrate_one(ctx: &Ctx, repo: &Repo, src: &Path, report: &Report) -> Result<()> {
let rel = repo.relative(src)?;
let old = read_link(src)?;
let target = repo.store.join(&rel);
if symlink_metadata_opt(&target)?.is_some() {
bail!(
"{} already exists; restore {} and add it again instead",
target.display(),
rel.display()
);
}
stays_in_store(repo, &rel)?;
ctx.fs().ensure_private_parent(&repo.base, &target)?;
ctx.fs().move_path(&old, &target)?;
// the link still names the old place until it is repointed, so a failure
// here puts the payload back where it points
if let Err(e) = ctx.fs().place_link(src, &target) {
ctx.fs().move_path(&target, &old).with_context(|| {
format!(
"could not put {} back after failing to link {} to it",
old.display(),
rel.display()
)
})?;
return Err(e);
}
ctx.fs().prune_empty(old.parent(), &repo.base);
report.line("migrated", &rel);
Ok(()) Ok(())
} }

View File

@@ -5,7 +5,7 @@ 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, held, leads, resolve, symlink_metadata_opt};
use crate::cli::link as cli; use crate::cli::link as cli;
use crate::cmd::{Cmd, LsFiles}; use crate::cmd::{Cmd, LsFiles};
use crate::ctx::Ctx; use crate::ctx::Ctx;
@@ -179,10 +179,10 @@ fn classify(repo: &Repo, rel: &Path, mark: char) -> Result<Option<Exposed>> {
} }
let dest = read_link(&src)?; let dest = read_link(&src)?;
if dest == repo.store.join(rel) { if held(repo, &src, &dest).is_some() {
// it names the store, but what the store holds there can be a symlink of // it names the store, but what the store holds there can be a symlink of
// its own leading straight back out, which is not being held at all // its own leading straight back out, which is not being held at all
return Ok(match leads(&src, &repo.store) { return Ok(match leads(&src, &repo.base) {
Leads::Inside | Leads::Dangling => None, Leads::Inside | Leads::Dangling => None,
Leads::Outside(end) => Some(Exposed { Leads::Outside(end) => Some(Exposed {
mark, mark,

View File

@@ -109,7 +109,24 @@ fn git_root(ctx: &Ctx) -> Result<PathBuf> {
)))?) )))?)
} }
// the remote names the project and the checkout names its clone, so two clones
// never share a directory
fn repo_components(ctx: &Ctx, root: &Path) -> Result<PathBuf> { fn repo_components(ctx: &Ctx, root: &Path) -> Result<PathBuf> {
Ok(project_components(ctx, root)?.join(checkout_name(root)))
}
// `<dir>-<hash>`: the directory for a human, the hash of the canonical path
// for two checkouts in directories of one name
fn checkout_name(root: &Path) -> String {
let hash = fingerprint(root.as_os_str().as_bytes());
match root.file_name() {
Some(dir) => format!("{}-{hash}", cleaned(&dir.to_string_lossy())),
None => hash,
}
}
fn project_components(ctx: &Ctx, root: &Path) -> Result<PathBuf> {
if let Some(url) = git_origin_url(ctx) { if let Some(url) = git_origin_url(ctx) {
if let Some(components) = components_from_remote(&url) { if let Some(components) = components_from_remote(&url) {
return Ok(components); return Ok(components);
@@ -272,6 +289,19 @@ pub(super) fn symlink_metadata_opt(path: &Path) -> Result<Option<std::fs::Metada
} }
} }
// where a link into the store points, if it is one: absolute, under the base.
// the directory need not be this checkout's own, since a moved checkout or an
// older layout still holds the file
pub(super) fn held(repo: &Repo, link: &Path, dest: &Path) -> Option<PathBuf> {
let dest = match dest.is_absolute() {
true => dest.to_path_buf(),
false => link.parent()?.join(dest),
};
let dest = normalized(dest).ok()?;
dest.starts_with(&repo.base).then_some(dest)
}
// where a chain of symlinks actually ends up // where a chain of symlinks actually ends up
pub(super) enum Leads { pub(super) enum Leads {
// somewhere under the directory it was supposed to stay in // somewhere under the directory it was supposed to stay in
@@ -302,11 +332,13 @@ pub(super) fn leads(path: &Path, root: &Path) -> Leads {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{components_from_remote, fingerprint, normalized, sanitize, sanitize_name}; use super::{
checkout_name, components_from_remote, fingerprint, normalized, sanitize, sanitize_name,
};
use std::collections::HashSet; use std::collections::HashSet;
use std::ffi::OsStr; use std::ffi::OsStr;
use std::os::unix::ffi::OsStrExt; use std::os::unix::ffi::OsStrExt;
use std::path::PathBuf; use std::path::{Path, PathBuf};
#[test] #[test]
fn parses_every_spelling_of_a_remote() { fn parses_every_spelling_of_a_remote() {
@@ -407,6 +439,17 @@ mod tests {
assert_eq!(sanitize_name(OsStr::new("afurnik")), "afurnik"); assert_eq!(sanitize_name(OsStr::new("afurnik")), "afurnik");
} }
#[test]
fn a_checkout_is_named_by_its_directory_and_its_path() {
let one = checkout_name(Path::new("/home/a/afurnik"));
let two = checkout_name(Path::new("/home/b/afurnik"));
assert!(one.starts_with("afurnik-"), "{one}");
assert_ne!(one, two);
// the root itself has no directory to be named after
assert_eq!(checkout_name(Path::new("/")), fingerprint(b"/"));
}
#[test] #[test]
fn a_store_root_is_absolute_with_the_dots_folded_out() { fn a_store_root_is_absolute_with_the_dots_folded_out() {
// it is written into every symlink add creates, and compared against the // it is written into every symlink add creates, and compared against the

View File

@@ -32,12 +32,15 @@ pub fn status(ctx: &Ctx, store: Option<&Path>) -> Result<()> {
fn stored(ctx: &Ctx, store: Option<&Path>) { fn stored(ctx: &Ctx, store: Option<&Path>) {
match link::stored_summary(ctx, store) { match link::stored_summary(ctx, store) {
Err(e) => line!("store: {e:#}"), Err(e) => line!("store: {e:#}"),
Ok((store, linked, other)) => { Ok(summary) => {
line!("store: {}", store.display()); line!("store: {}", summary.store.display());
line!("\tlinked: {linked}"); line!("\tlinked: {}", summary.linked);
if other > 0 { if summary.other > 0 {
line!("\tnot linked: {other} (see `ahab link list`)"); line!("\tnot linked: {} (see `ahab link list`)", summary.other);
}
if summary.legacy > 0 {
line!("\tlegacy: {} ({})", summary.legacy, link::MIGRATE_HINT);
} }
line!("\t`ahab link check` lists what a sandbox can still read"); line!("\t`ahab link check` lists what a sandbox can still read");

View File

@@ -94,6 +94,7 @@ fn run(ctx: &Ctx, command: cli::Commands) -> Result<ExitCode> {
cli::Link::Add(args) => commands::link::add(ctx, &args).map(|()| done), cli::Link::Add(args) => commands::link::add(ctx, &args).map(|()| done),
cli::Link::List(args) => commands::link::list(ctx, &args).map(|()| done), cli::Link::List(args) => commands::link::list(ctx, &args).map(|()| done),
cli::Link::Restore(args) => commands::link::restore(ctx, &args).map(|()| done), cli::Link::Restore(args) => commands::link::restore(ctx, &args).map(|()| done),
cli::Link::Migrate(args) => commands::link::migrate(ctx, &args).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(args) => match commands::link::check(ctx, &args)? && args.exit_code { cli::Link::Check(args) => match commands::link::check(ctx, &args)? && args.exit_code {
true => Ok(ExitCode::from(FINDINGS)), true => Ok(ExitCode::from(FINDINGS)),

View File

@@ -260,3 +260,90 @@ fn distinct_remotes_do_not_share_one_store_directory() {
// the ordinary remote keeps the path it already had // the ordinary remote keeps the path it already had
assert!(plain.contains("/a/my_api"), "{plain}"); assert!(plain.contains("/a/my_api"), "{plain}");
} }
#[test]
fn two_checkouts_of_one_remote_keep_their_own_files() {
let case = Case::new("clones");
let second = case.second_checkout();
case.write(".env", b"FIRST\n");
second.write(".env", b"SECOND\n");
case.ahab(&["link", "add", ".env"]).ok();
// no collision, and no --force needed
second.ahab(&["link", "add", ".env"]).ok();
assert_ne!(case.store, second.store);
assert_eq!(fs::read(case.path(".env")).unwrap(), b"FIRST\n");
assert_eq!(fs::read(second.path(".env")).unwrap(), b"SECOND\n");
// one restoring leaves the other linked
case.ahab(&["link", "restore", "--all"]).ok();
assert!(second.is_symlink(".env"));
assert_eq!(fs::read(second.path(".env")).unwrap(), b"SECOND\n");
second.ahab(&["link", "list"]).ok().says("linked: .env");
}
#[test]
fn a_link_into_an_older_store_layout_still_checks_clean_and_restores() {
let case = Case::new("legacy");
// the flat layout, before the store was keyed by checkout
let old = case.store.parent().unwrap().join(".env");
fs::create_dir_all(old.parent().unwrap()).unwrap();
fs::write(&old, b"SECRET=1\n").unwrap();
case.link(&case.path(".env"), &old);
case.gitignore(".env\n");
case.ahab(&["link", "check", "--exit-code"]).ok();
case.ahab(&["link", "restore", ".env"]).ok();
assert!(!case.is_symlink(".env"));
assert_eq!(fs::read(case.path(".env")).unwrap(), b"SECRET=1\n");
assert!(!old.exists());
// a link that leaves the store altogether is still refused
case.link(&case.path("key"), &case.outside.join("key"));
case.ahab(&["link", "restore", "key"])
.failed()
.says("not in the store");
}
#[test]
fn migrate_moves_only_this_checkouts_paths_out_of_the_old_layout() {
let case = Case::new("migrate");
let old = case.store.parent().unwrap().to_path_buf();
fs::create_dir_all(old.join("secrets")).unwrap();
fs::write(old.join(".env"), b"SECRET=1\n").unwrap();
fs::write(old.join("secrets/token"), b"tok\n").unwrap();
// another checkout's file in the same flat layout, linked from nowhere here
fs::write(old.join("other.env"), b"OTHER\n").unwrap();
case.link(&case.path(".env"), &old.join(".env"));
case.link(&case.path("secrets"), &old.join("secrets"));
case.gitignore(".env\nsecrets/\n");
case.ahab(&["link", "list"])
.ok()
.says("legacy: .env")
.says("legacy: secrets")
.says("ahab link migrate");
case.ahab(&["status"]).says("legacy: 2");
case.ahab(&["link", "migrate"]).ok().says("migrated: .env");
assert_eq!(
fs::read_link(case.path(".env")).unwrap(),
case.store.join(".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");
assert!(!old.join(".env").exists());
assert!(!old.join("secrets").exists());
assert_eq!(fs::read(old.join("other.env")).unwrap(), b"OTHER\n");
case.ahab(&["link", "list"])
.ok()
.says("linked: .env")
.silent_about("legacy");
case.ahab(&["link", "migrate"])
.ok()
.says("nothing in the old layout");
}

View File

@@ -7,6 +7,7 @@
#![allow(dead_code)] #![allow(dead_code)]
use std::ffi::OsStr; use std::ffi::OsStr;
use std::fmt::Write as _; use std::fmt::Write as _;
use std::os::unix::ffi::OsStrExt;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::{Command, Output}; use std::process::{Command, Output};
use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::atomic::{AtomicU32, Ordering};
@@ -15,8 +16,12 @@ use std::{env, fs};
// unique per case even with the suite running in parallel // unique per case even with the suite running in parallel
static NEXT: AtomicU32 = AtomicU32::new(0); static NEXT: AtomicU32 = AtomicU32::new(0);
pub const REMOTE: &str = "https://git.example.org/acme/proj.git";
pub struct Case { pub struct Case {
root: PathBuf, root: PathBuf,
// the data home every checkout of a case shares, as one user's would
xdg: PathBuf,
pub repo: PathBuf, pub repo: PathBuf,
pub store: PathBuf, pub store: PathBuf,
pub outside: PathBuf, pub outside: PathBuf,
@@ -32,29 +37,41 @@ impl Case {
)); ));
let _ = fs::remove_dir_all(&root); let _ = fs::remove_dir_all(&root);
let case = Self { Self::checkout(root.clone(), root.join("xdg"))
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] { // a second clone of the same remote, sharing the first one's data home
pub fn second_checkout(&self) -> Self {
Self::checkout(self.root.join("second"), self.xdg.clone())
}
fn checkout(root: PathBuf, xdg: PathBuf) -> Self {
let repo = root.join("repo");
let outside = root.join("outside");
for dir in [&repo, &outside] {
fs::create_dir_all(dir).expect("creating the case directories"); fs::create_dir_all(dir).expect("creating the case directories");
} }
// `<dir>-<hash>` of the canonical checkout, as ahab spells it
let canonical = fs::canonicalize(&repo).expect("resolving the repository");
let store = xdg.join("ahab/git.example.org/acme/proj").join(format!(
"repo-{}",
fingerprint(canonical.as_os_str().as_bytes())
));
let case = Self {
root,
xdg,
repo,
store,
outside,
};
case.git(&["init", "-q", "."]); case.git(&["init", "-q", "."]);
case.git(&["config", "user.email", "test@example.org"]); case.git(&["config", "user.email", "test@example.org"]);
case.git(&["config", "user.name", "test"]); case.git(&["config", "user.name", "test"]);
case.git(&["config", "commit.gpgsign", "false"]); case.git(&["config", "commit.gpgsign", "false"]);
case.git(&[ case.git(&["remote", "add", "origin", REMOTE]);
"remote",
"add",
"origin",
"https://git.example.org/acme/proj.git",
]);
case.write("tracked.txt", b"tracked\n"); case.write("tracked.txt", b"tracked\n");
case.git(&["add", "tracked.txt"]); case.git(&["add", "tracked.txt"]);
case.git(&["commit", "-qm", "init"]); case.git(&["commit", "-qm", "init"]);
@@ -84,7 +101,7 @@ impl Case {
pub fn ahab<S: AsRef<OsStr>>(&self, args: &[S]) -> Run { pub fn ahab<S: AsRef<OsStr>>(&self, args: &[S]) -> Run {
let out = Command::new(env!("CARGO_BIN_EXE_ahab")) let out = Command::new(env!("CARGO_BIN_EXE_ahab"))
.current_dir(&self.repo) .current_dir(&self.repo)
.env("XDG_DATA_HOME", self.root.join("xdg")) .env("XDG_DATA_HOME", &self.xdg)
// the git commands ahab runs must not read the developer's own config // the git commands ahab runs must not read the developer's own config
.env("GIT_CONFIG_GLOBAL", "/dev/null") .env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null") .env("GIT_CONFIG_SYSTEM", "/dev/null")
@@ -205,3 +222,15 @@ impl Run {
out out
} }
} }
// fnv-1a, as the store spells a checkout
pub fn fingerprint(bytes: &[u8]) -> String {
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for byte in bytes {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(0x100_0000_01b3);
}
format!("{:08x}", hash as u32)
}