Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
921c303c6d
|
|||
|
0969e5a91d
|
|||
|
a10e6b17b0
|
|||
|
a6a4b033a7
|
|||
|
22d735d52c
|
|||
|
c96211a28b
|
|||
|
9adcfed28e
|
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
347
README.md
347
README.md
@@ -3,119 +3,26 @@
|
|||||||
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
|
|
||||||
|
|
||||||
`ahab status` prints the services it picked, the containers behind them, the
|
|
||||||
credentials it would use and what the store holds, reporting whatever it cannot
|
|
||||||
work out rather than stopping at the first thing:
|
|
||||||
|
|
||||||
```
|
|
||||||
$ ahab status
|
|
||||||
services: db, django
|
|
||||||
django: django (built from the project)
|
|
||||||
container: 0a8b19e27634
|
|
||||||
postgres: db (postgres:18-alpine)
|
|
||||||
container: 189d4d395d62
|
|
||||||
user: myproject
|
|
||||||
database: myproject_db
|
|
||||||
store: /home/you/.local/share/ahab/git.aflabs.org/urnik/afurnik
|
|
||||||
linked: 2
|
|
||||||
`ahab link check` lists what a sandbox can still read
|
|
||||||
```
|
|
||||||
|
|
||||||
## Seeing what it runs
|
|
||||||
|
|
||||||
`-v` prints each Docker command as it runs, `--dry-run` prints the ones it would
|
|
||||||
run without running them, and `-q` prints only what was asked for, dropping the
|
|
||||||
progress along the way (Compose's own progress included). All work before or
|
|
||||||
after the subcommand.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ahab -v django migrate
|
|
||||||
ahab --dry-run postgres import ./dump
|
|
||||||
ahab -q postgres dump ./dump
|
|
||||||
```
|
|
||||||
|
|
||||||
Nothing is written under `--dry-run`, filesystem included: `link add` and
|
|
||||||
`django make-command` say what they would do and leave the tree alone.
|
|
||||||
|
|
||||||
## Exit codes
|
|
||||||
|
|
||||||
- `0` — It ran and had nothing to report
|
|
||||||
- `1` — It could not finish
|
|
||||||
- `2` — The arguments were wrong
|
|
||||||
- `4` — It ran fine and found something worth reporting, i.e.
|
|
||||||
`link check --exit-code` with anything outside the store
|
|
||||||
|
|
||||||
## Shell completion
|
|
||||||
|
|
||||||
`ahab completions <shell>` writes a completion script to stdout, for Bash,
|
|
||||||
Elvish, Fish, PowerShell or Zsh:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ahab completions zsh > ~/.local/share/zsh/completions/_ahab
|
|
||||||
eval "$(ahab completions zsh)" # or one line in .zshrc, never goes stale
|
|
||||||
```
|
|
||||||
|
|
||||||
Completions are also generated during the build. They land in
|
|
||||||
`target/*/build/*/out/` by default, and `SHELL_COMPLETIONS_DIR_<SHELL>`
|
|
||||||
installs a single shell's file straight into place:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
SHELL_COMPLETIONS_DIR_ZSH=~/.local/share/zsh/completions \
|
|
||||||
SHELL_COMPLETIONS_DIR_FISH=~/.config/fish/completions \
|
|
||||||
cargo install --path .
|
|
||||||
```
|
|
||||||
|
|
||||||
`SHELL_COMPLETIONS_DIR` writes every shell into one directory instead.
|
|
||||||
|
|
||||||
## The compose file
|
|
||||||
|
|
||||||
`ahab` does not pass `-f`. Docker Compose finds the file itself, so set
|
|
||||||
Docker's own `COMPOSE_FILE` when it 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
|
|
||||||
```
|
|
||||||
|
|
||||||
## Service detection
|
|
||||||
|
|
||||||
`ahab` finds the services it needs in `docker compose config`, so they can be
|
|
||||||
named anything:
|
|
||||||
|
|
||||||
- **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
|
## Django
|
||||||
|
|
||||||
|
Each of these runs `docker compose run --rm` against the Django service, so it
|
||||||
|
goes through the image's own entrypoint:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ahab django run <cmd> # in a fresh container, through the entrypoint
|
ahab django run <cmd> # anything, in a fresh container
|
||||||
ahab django bash # shell in a fresh container
|
ahab django bash # a shell in a fresh container
|
||||||
ahab django manage <args> # manage.py
|
ahab django manage <args> # manage.py
|
||||||
ahab django makemigrations
|
ahab django makemigrations
|
||||||
ahab django migrate <args>
|
ahab django migrate <args>
|
||||||
@@ -123,14 +30,6 @@ ahab django shell
|
|||||||
ahab django make-command <app> <name>
|
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
|
## Postgres
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -144,41 +43,43 @@ ahab postgres import <path> # drop, create, then restore
|
|||||||
ahab postgres psql <args> # psql in the database container
|
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
|
Dumps are written `0600`, under a name of their own until they are complete and
|
||||||
renamed over the target: a cluster dump carries every role's password hash, and
|
then renamed over the target.
|
||||||
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
|
Arguments reach `psql` untouched, and Docker is only asked for a terminal when
|
||||||
has one to hand over, so both of these work:
|
there is one to hand over, so both of these work:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ahab postgres psql # interactive
|
ahab postgres psql # interactive
|
||||||
ahab postgres psql -tAc 'select count(*) from auth_user' | wc -l
|
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.
|
### What an import makes of a dump
|
||||||
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
|
The shape is read from the file rather than from its name:
|
||||||
stopped and says so: `docker compose up` brings it back.
|
|
||||||
|
- 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
|
## Link
|
||||||
|
|
||||||
`ahab link` moves untracked paths out of the repository into an out-of-repo
|
A sandbox that mounts the repository can read anything untracked sitting in it
|
||||||
store and symlinks them back, so a sandbox that mounts the repository sees a
|
— a `.env`, a dump, a key. Those paths can be moved out to a store of ahab's
|
||||||
dangling symlink instead of the contents, while the host resolves it as before.
|
own and symlinked back: the host resolves the link, the sandbox does not.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ahab link add .env secrets/ # move out, leave symlinks behind
|
ahab link add .env secrets/ # move out, leave symlinks behind
|
||||||
@@ -188,56 +89,164 @@ ahab link list # what the store holds for this repository
|
|||||||
ahab link check # what a sandbox can still read
|
ahab link check # what a sandbox can still read
|
||||||
ahab link check --porcelain # `<code> <path>`, for scripts
|
ahab link check --porcelain # `<code> <path>`, for scripts
|
||||||
ahab link check --exit-code # exit 4 when anything is outside the store
|
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
|
||||||
```
|
```
|
||||||
|
|
||||||
`restore` is the inverse of `add`: the file moves out of the store and back to
|
Restoring is the inverse of adding: the file comes back to where it was and the
|
||||||
where it was, and the store keeps nothing. A second checkout linking the same
|
store keeps nothing. Every checkout has a store directory of its own, so a
|
||||||
path is left with a dangling symlink, since there is only ever one stored copy.
|
second clone of the same repository links and restores its files without
|
||||||
|
touching the first one's.
|
||||||
|
|
||||||
`list` says what state each stored path is in, which `check` cannot see because
|
Every stored path is in one of three states:
|
||||||
it asks Git about the repository rather than reading the store:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
$ ahab link list
|
$ ahab link list
|
||||||
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: .env
|
linked: .env
|
||||||
shadowed: config.local.py
|
shadowed: config.local.py
|
||||||
missing: secrets/token
|
missing: secrets/token
|
||||||
```
|
```
|
||||||
|
|
||||||
`missing` is a path whose symlink is gone, `shadowed` one that a real file took
|
- `linked` — the symlink is there and points at the store
|
||||||
back; either way the stored copy is the one nobody reads.
|
- `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
|
||||||
|
|
||||||
`restore --all` moves back the linked ones and says how many it left, since
|
Only a linked path can be moved back, so `restore --all` says how many it left
|
||||||
there is nothing to undo for a path whose symlink is gone.
|
alone.
|
||||||
|
|
||||||
`-z` is the porcelain format for scripts that must survive any filename: entries
|
Adding refuses two kinds of path:
|
||||||
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
|
- **a tracked path**, which git would restore anyway. Where one is a symlink
|
||||||
out of the repository without appearing in any untracked listing. `add` cannot
|
leading out of the repository, `check` reports it under the code `T>`
|
||||||
externalize a tracked path, so all `check` can do is report it, under the code
|
- **a symlink already leading out of the repository**, since moving it would
|
||||||
`T>`.
|
move the pointer and leave the contents where they are
|
||||||
|
|
||||||
A symlink already leading out of the repository is not something `add` will
|
For scripts there is `--porcelain`, and `-z` for filenames that need it:
|
||||||
take: moving the link would move the pointer and leave the contents where they
|
entries end with a NUL, and the two paths of a symlink leading elsewhere are
|
||||||
are, so the store would hold a way back out while `check`, seeing a link into
|
separated by one as well, the way `git status -z` reports a rename.
|
||||||
the store, called the repository clean.
|
|
||||||
|
|
||||||
The store lives under
|
The store lives under
|
||||||
`${XDG_DATA_HOME:-$HOME/.local/share}/ahab/<host>/<owner>/<repo>/`, derived
|
`${XDG_DATA_HOME:-$HOME/.local/share}/ahab/<host>/<owner>/<repo>/<dir>-<hash>/`.
|
||||||
from the Git `origin` remote, or `_local/<checkout>` when there is no remote to
|
The repository part is derived from the Git `origin` remote, or
|
||||||
name it after. Its directories are created `0700`: it exists to hold what
|
`_local/<checkout>` when there is no remote to name it after; the last part is
|
||||||
should not be readable from the repository, and on a shared machine the default
|
the checkout's directory name and a short hash of its full path, so two clones
|
||||||
`0755` would leave that to whoever else has an account. A store path derived
|
of one repository never share a directory. Its directories are created `0700`.
|
||||||
from a remote, or a checkout name, that needed characters replacing carries a
|
A remote or checkout name that needed characters replacing carries a short
|
||||||
short fingerprint of the original, so two of them cannot land on one directory.
|
fingerprint of the original, so two of them cannot share a directory either.
|
||||||
|
|
||||||
## Configuration
|
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.
|
||||||
|
|
||||||
Currently `ahab` respects the following environment variables.
|
### Upgrading from 0.5
|
||||||
|
|
||||||
- `AHAB_LINK_ROOT`: Where `ahab link` keeps its store — the same as `--store`,
|
Before 0.6 the store had no per-checkout part, so existing links point at
|
||||||
which takes precedence, and defaults to `${XDG_DATA_HOME:-$HOME/.local/share}/ahab`.
|
`<repo>/` directly. They keep working. `list` and `status` report them as
|
||||||
`ahab status` reads it too, so it reports the store the link commands use
|
`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
|
||||||
|
services: db, django
|
||||||
|
django: django (built from the project)
|
||||||
|
container: 0a8b19e27634
|
||||||
|
postgres: db (postgres:18-alpine)
|
||||||
|
container: 189d4d395d62
|
||||||
|
user: myproject
|
||||||
|
database: myproject_db
|
||||||
|
store: /home/you/.local/share/ahab/git.aflabs.org/urnik/afurnik/afurnik-5f1c8e3a
|
||||||
|
linked: 2
|
||||||
|
`ahab link check` lists what a sandbox can still read
|
||||||
|
```
|
||||||
|
|
||||||
|
## Seeing what it runs
|
||||||
|
|
||||||
|
- `-v` — print each Docker command as it runs
|
||||||
|
- `--dry-run` — print the commands it would run, without running them
|
||||||
|
- `-q` — print only what was asked for, dropping the progress along the way,
|
||||||
|
Compose's own included
|
||||||
|
|
||||||
|
All three work before or after the subcommand:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ahab -v django migrate
|
||||||
|
ahab --dry-run postgres import ./dump
|
||||||
|
ahab -q postgres dump ./dump
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Service detection
|
||||||
|
|
||||||
|
Services can be named anything; ahab matches on what they are:
|
||||||
|
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
Completion scripts are written to stdout, for Bash, Elvish, Fish, PowerShell
|
||||||
|
or Zsh:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ahab completions zsh > ~/.local/share/zsh/completions/_ahab
|
||||||
|
eval "$(ahab completions zsh)" # or one line in .zshrc, never goes stale
|
||||||
|
```
|
||||||
|
|
||||||
|
They are generated during the build as well, landing in
|
||||||
|
`target/*/build/*/out/`. To install a single shell's file straight into place:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SHELL_COMPLETIONS_DIR_ZSH=~/.local/share/zsh/completions \
|
||||||
|
SHELL_COMPLETIONS_DIR_FISH=~/.config/fish/completions \
|
||||||
|
cargo install --path .
|
||||||
|
```
|
||||||
|
|
||||||
|
Set `SHELL_COMPLETIONS_DIR` to write every shell into one directory instead.
|
||||||
|
Either way the path has to be absolute.
|
||||||
|
|
||||||
|
## Exit codes
|
||||||
|
|
||||||
|
- `0` — it ran and had nothing to report
|
||||||
|
- `1` — it could not finish
|
||||||
|
- `2` — the arguments were wrong
|
||||||
|
- `4` — it ran fine and found something worth reporting, which today means
|
||||||
|
`link check --exit-code` with anything outside the store
|
||||||
|
|
||||||
|
## Environment variables
|
||||||
|
|
||||||
|
- `AHAB_LINK_ROOT` — where `ahab link` keeps its store, the same as `--store`,
|
||||||
|
which takes precedence. Defaults to
|
||||||
|
`${XDG_DATA_HOME:-$HOME/.local/share}/ahab`. Read by `ahab status` too, so it
|
||||||
|
reports the store the link commands actually use
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user