Compare commits
1 Commits
main
...
51e940ef9f
| Author | SHA1 | Date | |
|---|---|---|---|
|
51e940ef9f
|
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -4,7 +4,7 @@ version = 4
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ahab"
|
name = "ahab"
|
||||||
version = "0.6.0"
|
version = "0.5.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.6.0"
|
version = "0.5.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.85"
|
rust-version = "1.85"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
91
README.md
91
README.md
@@ -30,6 +30,15 @@ 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
|
||||||
@@ -44,7 +53,8 @@ ahab postgres psql <args> # psql in the database container
|
|||||||
```
|
```
|
||||||
|
|
||||||
Dumps are written `0600`, under a name of their own until they are complete and
|
Dumps are written `0600`, under a name of their own until they are complete and
|
||||||
then renamed over the target.
|
then renamed over the target. A cluster dump carries every role's password
|
||||||
|
hash, so the default `0644` would hand it to anyone else with an account.
|
||||||
|
|
||||||
Arguments reach `psql` untouched, and Docker is only asked for a terminal when
|
Arguments reach `psql` untouched, and Docker is only asked for a terminal when
|
||||||
there is one to hand over, so both of these work:
|
there is one to hand over, so both of these work:
|
||||||
@@ -67,10 +77,10 @@ The shape is read from the file rather than from its name:
|
|||||||
- anything else — SQL, fed to `psql` with `ON_ERROR_STOP` and
|
- anything else — SQL, fed to `psql` with `ON_ERROR_STOP` and
|
||||||
`--single-transaction`, so a bad file rolls back instead of half applying
|
`--single-transaction`, so a bad file rolls back instead of half applying
|
||||||
|
|
||||||
A cluster restore runs without `ON_ERROR_STOP`, so the expected complaints
|
A cluster restore is the one that runs without `ON_ERROR_STOP`, because roles
|
||||||
about roles that already exist do not stop it. Any other error fails the
|
that already exist report errors which are expected. Any other error fails the
|
||||||
import. Importing into a cluster that has never held the database works either
|
import, since psql would otherwise exit 0 having applied part of the dump.
|
||||||
way.
|
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
|
Containers are stopped before an import starts, so one that fails leaves the
|
||||||
project stopped and says so.
|
project stopped and says so.
|
||||||
@@ -79,7 +89,8 @@ project stopped and says so.
|
|||||||
|
|
||||||
A sandbox that mounts the repository can read anything untracked sitting in it
|
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
|
— 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.
|
own and symlinked back, so the host resolves the link as before while the
|
||||||
|
sandbox is left with a symlink into somewhere it was never given.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ahab link add .env secrets/ # move out, leave symlinks behind
|
ahab link add .env secrets/ # move out, leave symlinks behind
|
||||||
@@ -89,69 +100,60 @@ 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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Restoring is the inverse of adding: the file comes back to where it was and the
|
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
|
store keeps nothing. A second checkout linking the same path is left with a
|
||||||
second clone of the same repository links and restores its files without
|
dangling symlink, there being only ever one stored copy.
|
||||||
touching the first one's.
|
|
||||||
|
|
||||||
Every stored path is in one of three states:
|
Every stored path is in one of three states, which `list` reads from the store
|
||||||
|
rather than from git:
|
||||||
|
|
||||||
```
|
```
|
||||||
$ ahab link list
|
$ ahab link list
|
||||||
store: /home/you/.local/share/ahab/git.aflabs.org/urnik/afurnik/afurnik-5f1c8e3a
|
store: /home/you/.local/share/ahab/git.aflabs.org/urnik/afurnik
|
||||||
linked: .env
|
linked: .env
|
||||||
shadowed: config.local.py
|
shadowed: config.local.py
|
||||||
missing: secrets/token
|
missing: secrets/token
|
||||||
```
|
```
|
||||||
|
|
||||||
- `linked` — the symlink is there and points at the store
|
- `linked` — the symlink is there and points at the store, which is the point
|
||||||
- `shadowed` — a real file took the path back, so nothing reads the stored copy
|
- `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
|
- `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
|
Only a linked path can be moved back, so `restore --all` says how many it left
|
||||||
alone.
|
alone.
|
||||||
|
|
||||||
Adding refuses two kinds of path:
|
Two things adding will not do:
|
||||||
|
|
||||||
- **a tracked path**, which git would restore anyway. Where one is a symlink
|
- **a tracked path**, which git would restore into the repository anyway. Where
|
||||||
leading out of the repository, `check` reports it under the code `T>`
|
one is a symlink leading out, `check` reports it under the code `T>`, and
|
||||||
- **a symlink already leading out of the repository**, since moving it would
|
saying so is all it can do
|
||||||
move the pointer and leave the contents where they are
|
- **a symlink already leading out of the repository**, because moving the link
|
||||||
|
would move the pointer and leave the contents where they are. The store would
|
||||||
|
then hold a way back out, while `check` saw a link into the store and called
|
||||||
|
the repository clean
|
||||||
|
|
||||||
For scripts there is `--porcelain`, and `-z` for filenames that need it:
|
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
|
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.
|
separated by one as well, the way `git status -z` reports a rename.
|
||||||
|
|
||||||
The store lives under
|
The store lives under
|
||||||
`${XDG_DATA_HOME:-$HOME/.local/share}/ahab/<host>/<owner>/<repo>/<dir>-<hash>/`.
|
`${XDG_DATA_HOME:-$HOME/.local/share}/ahab/<host>/<owner>/<repo>/`, derived
|
||||||
The repository part is derived from the Git `origin` remote, or
|
from the Git `origin` remote, or `_local/<checkout>` when there is no remote to
|
||||||
`_local/<checkout>` when there is no remote to name it after; the last part is
|
name it after. Two details of that:
|
||||||
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
|
- its directories are created `0700`, since it holds what should not be
|
||||||
counts them as in the store and `restore <path>` still brings them back, so the
|
readable from the repository and `0755` would leave that to whoever else has
|
||||||
way over is `restore` followed by `add`; `list` and `restore --all` only look
|
an account on the machine
|
||||||
in the new directory.
|
- a remote or checkout name that needed characters replacing carries a short
|
||||||
|
fingerprint of the original, so two of them cannot land on one 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
|
## What ahab makes of your project
|
||||||
|
|
||||||
Running `ahab status` prints the services it picked, the containers behind
|
Running `ahab status` prints the services it picked, the containers behind
|
||||||
them, the credentials it would use and what the store holds. It reports
|
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:
|
whatever it cannot work out rather than stopping at the first thing:
|
||||||
|
|
||||||
```
|
```
|
||||||
$ ahab status
|
$ ahab status
|
||||||
@@ -162,7 +164,7 @@ 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/afurnik-5f1c8e3a
|
store: /home/you/.local/share/ahab/git.aflabs.org/urnik/afurnik
|
||||||
linked: 2
|
linked: 2
|
||||||
`ahab link check` lists what a sandbox can still read
|
`ahab link check` lists what a sandbox can still read
|
||||||
```
|
```
|
||||||
@@ -187,7 +189,8 @@ Nothing is written under `--dry-run`, the filesystem included: `link add` and
|
|||||||
|
|
||||||
## Service detection
|
## Service detection
|
||||||
|
|
||||||
Services can be named anything; ahab matches on what they are:
|
Services can be named anything, since ahab looks at what they are rather than
|
||||||
|
what they are called:
|
||||||
|
|
||||||
- **postgres** — the service whose image is a Postgres flavour, matching
|
- **postgres** — the service whose image is a Postgres flavour, matching
|
||||||
`postg`, `timescale`, `pgvector` or `citus`
|
`postg`, `timescale`, `pgvector` or `citus`
|
||||||
@@ -200,8 +203,9 @@ lists the services it looked at rather than guessing.
|
|||||||
|
|
||||||
The role and database come from `POSTGRES_USER` and `POSTGRES_DB` on the
|
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
|
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
|
names: libpq reads a database name holding an `=` or a URL as a whole
|
||||||
reading such a name as a whole connection string.
|
connection string, which would send a dump to whatever server that names, so a
|
||||||
|
value which could be read as anything but a name is refused.
|
||||||
|
|
||||||
## The compose file
|
## The compose file
|
||||||
|
|
||||||
@@ -234,7 +238,8 @@ SHELL_COMPLETIONS_DIR_FISH=~/.config/fish/completions \
|
|||||||
```
|
```
|
||||||
|
|
||||||
Set `SHELL_COMPLETIONS_DIR` to write 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.
|
Either way the path has to be absolute, since a build script runs from wherever
|
||||||
|
cargo happens to be building.
|
||||||
|
|
||||||
## Exit codes
|
## Exit codes
|
||||||
|
|
||||||
|
|||||||
@@ -19,15 +19,6 @@ 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, held, ignored, leads, resolve, symlink_metadata_opt, tracked};
|
use self::store::{Leads, Repo, ignored, leads, resolve, symlink_metadata_opt, tracked};
|
||||||
use crate::cli::link as cli;
|
use crate::cli::link as cli;
|
||||||
use crate::ctx::Ctx;
|
use crate::ctx::Ctx;
|
||||||
use crate::fsops::suffixed;
|
use crate::fsops::suffixed;
|
||||||
@@ -104,6 +104,7 @@ 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());
|
||||||
@@ -115,16 +116,14 @@ 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)?;
|
||||||
let Some(stored) = held(repo, &src, &dest) else {
|
if dest != stored {
|
||||||
bail!(
|
bail!(
|
||||||
"{} points at {}, which is not in the store",
|
"{} points at {}, which is not where the store keeps it",
|
||||||
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());
|
||||||
}
|
}
|
||||||
@@ -170,12 +169,6 @@ 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) {
|
||||||
@@ -186,11 +179,11 @@ fn mirrored_paths(repo: &Repo, mirror: &Path, dir: &Path) -> Result<Vec<(PathBuf
|
|||||||
|
|
||||||
for entry in entries {
|
for entry in entries {
|
||||||
let stored = entry?.path();
|
let stored = entry?.path();
|
||||||
let rel = stored.strip_prefix(mirror).with_context(|| {
|
let rel = stored.strip_prefix(&repo.store).with_context(|| {
|
||||||
format!(
|
format!(
|
||||||
"{} is not under the store {}",
|
"{} is not under the store {}",
|
||||||
stored.display(),
|
stored.display(),
|
||||||
mirror.display()
|
repo.store.display()
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
let src = repo.root.join(rel);
|
let src = repo.root.join(rel);
|
||||||
@@ -210,7 +203,7 @@ fn mirrored_paths(repo: &Repo, mirror: &Path, dir: &Path) -> Result<Vec<(PathBuf
|
|||||||
if state == Stored::Linked || !holds_dir {
|
if state == Stored::Linked || !holds_dir {
|
||||||
found.push((src, state));
|
found.push((src, state));
|
||||||
} else {
|
} else {
|
||||||
found.extend(mirrored_paths(repo, mirror, &stored)?);
|
found.extend(stored_paths(repo, &stored)?);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,29 +211,8 @@ fn mirrored_paths(repo: &Repo, mirror: &Path, dir: &Path) -> Result<Vec<(PathBuf
|
|||||||
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<Summary> {
|
pub fn stored_summary(ctx: &Ctx, store: Option<&Path>) -> Result<(PathBuf, usize, usize)> {
|
||||||
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)?;
|
||||||
|
|
||||||
@@ -249,27 +221,17 @@ pub fn stored_summary(ctx: &Ctx, store: Option<&Path>) -> Result<Summary> {
|
|||||||
.filter(|(_, state)| *state == Stored::Linked)
|
.filter(|(_, state)| *state == Stored::Linked)
|
||||||
.count();
|
.count();
|
||||||
|
|
||||||
let legacy = legacy_paths(&repo)?.len();
|
Ok((repo.store, linked, stored.len() - linked))
|
||||||
|
|
||||||
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() && legacy.is_empty() {
|
if stored.is_empty() {
|
||||||
line!("nothing in the store for this repository");
|
line!("nothing in the store for this repository");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -285,67 +247,6 @@ 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, held, leads, resolve, symlink_metadata_opt};
|
use super::store::{Leads, Repo, 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 held(repo, &src, &dest).is_some() {
|
if dest == repo.store.join(rel) {
|
||||||
// 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.base) {
|
return Ok(match leads(&src, &repo.store) {
|
||||||
Leads::Inside | Leads::Dangling => None,
|
Leads::Inside | Leads::Dangling => None,
|
||||||
Leads::Outside(end) => Some(Exposed {
|
Leads::Outside(end) => Some(Exposed {
|
||||||
mark,
|
mark,
|
||||||
|
|||||||
@@ -109,24 +109,7 @@ 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);
|
||||||
@@ -289,19 +272,6 @@ 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
|
||||||
@@ -332,13 +302,11 @@ pub(super) fn leads(path: &Path, root: &Path) -> Leads {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{components_from_remote, fingerprint, normalized, sanitize, sanitize_name};
|
||||||
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::{Path, PathBuf};
|
use std::path::PathBuf;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_every_spelling_of_a_remote() {
|
fn parses_every_spelling_of_a_remote() {
|
||||||
@@ -439,17 +407,6 @@ 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,15 +32,12 @@ 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(summary) => {
|
Ok((store, linked, other)) => {
|
||||||
line!("store: {}", summary.store.display());
|
line!("store: {}", store.display());
|
||||||
line!("\tlinked: {}", summary.linked);
|
line!("\tlinked: {linked}");
|
||||||
|
|
||||||
if summary.other > 0 {
|
if other > 0 {
|
||||||
line!("\tnot linked: {} (see `ahab link list`)", summary.other);
|
line!("\tnot linked: {other} (see `ahab link list`)");
|
||||||
}
|
|
||||||
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,7 +94,6 @@ 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,90 +260,3 @@ 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,7 +7,6 @@
|
|||||||
#![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};
|
||||||
@@ -16,12 +15,8 @@ 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,
|
||||||
@@ -37,41 +32,29 @@ impl Case {
|
|||||||
));
|
));
|
||||||
let _ = fs::remove_dir_all(&root);
|
let _ = fs::remove_dir_all(&root);
|
||||||
|
|
||||||
Self::checkout(root.clone(), root.join("xdg"))
|
let case = Self {
|
||||||
}
|
repo: root.join("repo"),
|
||||||
|
store: root
|
||||||
|
.join("xdg/ahab/git.example.org/acme/proj")
|
||||||
|
.to_path_buf(),
|
||||||
|
outside: root.join("outside"),
|
||||||
|
root,
|
||||||
|
};
|
||||||
|
|
||||||
// a second clone of the same remote, sharing the first one's data home
|
for dir in [&case.repo, &case.outside] {
|
||||||
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(&["remote", "add", "origin", REMOTE]);
|
case.git(&[
|
||||||
|
"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"]);
|
||||||
@@ -101,7 +84,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.xdg)
|
.env("XDG_DATA_HOME", self.root.join("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")
|
||||||
@@ -222,15 +205,3 @@ 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