vsync v0.16.0 — Spec
Status: design · target package @muthuishere/vsync · breaking — drops SECRETS_SYNC_REPO, drops cwd/"default" fallbacks, adds a committed .vsync pin file. Wire format unchanged.
One theme: vsync is a git-anchored team primitive. Identity is derived from the git remote, not the local directory name, not package.json, not the cwd, not an env var. Outside a git tree, vsync refuses to run. Inside a git tree, the resolved name is either explicitly pinned in a committed .vsync file (the team contract) or auto-parsed from origin. vsync status makes the resolution visible so operators can see exactly which mechanism chose the name and why.
For prior context, see v0.9-repo-name-resolution.md (the previous resolver — v0.16 tightens it), v0.2-secret-lib.md (config file layout + keychain split — unchanged), and v0.13-profiles-init-status.md (status command — v0.16 extends its output).
Decisions locked from the design huddle (referenced where applied):
- R1 — No git, no vsync. Git tree is a precondition for every subcommand. No git → hard error.
- R2 —
SECRETS_SYNC_REPOis removed entirely.--repo=<name>is the only override. - R3 —
--repo=<name>is a rename, not an escape hatch. It still requires a git tree. - R4 — Identity auto-derives from
git config --get remote.origin.urlparsed to<owner>-<repo>. No fallback to cwd basename, no"default"literal. - R5 — A committed
.vsyncfile at git toplevel pins the identity team-wide. Written byinit/import; read by everyone; refuse-to-clobber if a--repo=flag tries to overwrite a different value. - R6 —
vsync statusdisplays the resolved name, the source (flag/file/share/auto), the git toplevel, the cwd, and the parsedoriginURL. Shipped in this release, not deferred.
1. Diff from v0.9 / v0.13
| v0.9 / v0.13 | v0.16 | |
|---|---|---|
--repo=<name> flag | step 1 of 5 | step 1 of 4, refuses to clobber a present .vsync with a different value |
SECRETS_SYNC_REPO env var | step 2 of 5 | removed |
package.json::name | already removed in v0.9 | still removed |
origin URL parse | step 3 of 5 | step 3 of 4 (renumbered), unchanged algorithm |
basename(cwd) fallback | step 4 of 5 | removed |
"default" literal | step 5 of 5 | removed |
.vsync file at git toplevel | did not exist | new step 2 of 4 |
| Behaviour outside a git tree | resolved to basename(cwd) or "default" | hard error |
Behaviour with git but no origin and no .vsync and no --repo | resolved to basename(cwd) or "default" | hard error |
vsync status output | name + per-env table | name + source + toplevel + cwd + origin URL + per-env table |
Migration impact: any operator currently relying on SECRETS_SYNC_REPO, basename(cwd), or "default" to namespace a non-git workspace will hit the new error on first run. The error message names the recovery: git init && git remote add origin <url>, or --repo=<name> to rename a git-anchored identity. No silent rerouting. (Existing keychain entries and config files keep their stored names — the resolver only chooses names on init.)
2. New precedence chain
Precondition: must be inside a git repository.
`git rev-parse --show-toplevel` must succeed.
Otherwise: ERROR (see §8).
Name resolution:
1. opts.override (--repo=<name> flag)
2. readVsyncFile(toplevel) (committed `.vsync` at git toplevel — §3)
3. parseRemoteUrl(origin) (parsed `git config --get remote.origin.url` — §4)
4. ERROR (no .vsync, no origin set, no --repo flag — §8)Every non-ERROR step's output flows through normalize() (§5) before return. The chain is strict-stop: the first non-null step wins; later steps are not consulted.
vsync import swaps step 3 for a step 3' that reads the identity baked into the share file (since the operator is importing someone else's vault into a possibly-unrelated checkout). See §6.
3. The .vsync file — team-pinned identity
3.1 Location and lifecycle
- Always at git toplevel:
<git rev-parse --show-toplevel>/.vsync. - Tracked file (operator commits it). Not in
.gitignore. - Written by
vsync initandvsync import(only) when absent. - Read by every subcommand that resolves a repo name (
init,import,pull,push,use,sync,status,audit,runtime-token,rotate-passphrase,versions). - Never mutated after first write except by deliberate deletion + re-init, or by an explicit
--repo=<X>mismatch which errors out rather than overwriting (§3.4).
3.2 Format
.env-style — one key=value per line, mode 0644 (it's a committed config file, no secrets in it). Filename is exactly .vsync (no extension; matches .nvmrc / .tool-versions precedent).
Auto-written by init looks like this:
# .vsync — vsync identity pin (commit this file)
# Docs: https://muthuishere.github.io/vsync/architecture/repo-identity
repo=acme-web3.2.1 Grammar (intentionally narrow)
- One
key=valueper line. - Keys:
[A-Za-z_][A-Za-z0-9_]*(standard env-var grammar). - Values: literal — everything after
=to end-of-line, trimmed of trailing whitespace. No quotes, no escapes, no variable interpolation. Newlines in values are not representable (no field needs them). - Lines starting with
#(column 0 or after leading whitespace) are comments. - No inline comments.
repo=acme-web # the team's repomakes the valueacme-web # the team's repo— the only way to avoid the bash-vs-python-dotenv quoting wars. - Blank lines: ignored.
- Duplicate keys: last wins (matches dotenv convention).
- Unknown keys: ignored silently (forward-compat — older CLIs can read files written by newer ones).
- Missing required key: error (
VsyncFileMalformedError, §9).
3.2.2 Keys
| Key | Required | Notes |
|---|---|---|
repo | yes | The identity used for keychain account, config path, and S3 prefix. Must pass normalize() (§5): [a-z0-9._]+, ≤100 chars after normalisation. |
That's the entire surface today. The file can grow more keys later without any parser change — the unknown-key tolerance is the forward-compat mechanism, replacing the explicit v: envelope that JSON would need.
3.3 Write rules
init and import are the only commands that write .vsync. Behaviour:
| Situation | Behaviour |
|---|---|
.vsync absent, init resolves a name (via flag or origin) | Write .vsync with the header comments + repo=<name> line. Print: written: .vsync (identity pin — please commit). |
.vsync present, repo= matches the resolved name | No-op write. Print nothing about the file. (Even if other keys are present — only repo is checked for clobber-refusal.) |
.vsync present, repo= differs from --repo=<X> | ERROR. Refuse to silently overwrite a committed pin. Operator must drop the flag or delete/edit .vsync deliberately. See §8 for the message. |
.vsync present, no flag passed | Step 2 of the chain returns the pinned name. Step 3 is not consulted (even if origin parses to a different name — the file wins). |
.vsync malformed (no repo= line, or repo= value fails normalize()) | ERROR (VsyncFileMalformedError, §9). Operator must fix or delete. |
Writes preserve any unknown keys already present in the file. If a future CLI version reads an old file and writes it back, foreign keys round-trip untouched. (Strictly: future writers should not rewrite existing files at all once repo= matches — but the preservation rule is the safety net.)
3.4 Why a flag-vs-file mismatch is an error, not an overwrite
A committed .vsync is a team-wide contract. Silently overwriting it means one operator's --repo=<X> invocation rewrites everyone else's resolution on next clone. The error message tells the operator the exact recovery:
✗ .vsync pins identity to `acme-web`, but --repo=acme-web-staging was passed.
This file is committed and shared with the team. To rename the identity
team-wide, edit .vsync in a commit (or delete it and re-run `vsync init`).
To use a different identity locally for one command, do not pass --repo
on a checkout that has a .vsync — the two are incompatible by design.3.5 Why .env-style, not JSON
.vsync is operator-facing config, not wire format. JSON would require a v: envelope to add fields safely, force quoting on every value, and discourage hand-edits in a code review. .env-style:
- Familiar — every operator has edited a
.envalready. - Hand-editable in a code review without a JSON formatter.
- Forward-compatible by convention (unknown keys ignored) instead of by version bump — newer fields don't break older CLIs reading the file.
- Trivially
grep-able and diffable.
The cost is one more parser in the codebase (the rest of vsync's wire format is JSON). The trade is worth it because .vsync is the only operator-edited file vsync produces — the wire format never reaches the operator's keyboard.
4. parseRemoteUrl — algorithm
Unchanged from v0.9 §3. Reproduced here for spec self-containment.
| URL form | Parsed <owner>/<repo> |
|---|---|
git@github.com:owner/repo.git | owner/repo |
https://github.com/owner/repo.git | owner/repo |
https://github.com/owner/repo | owner/repo |
ssh://git@gitlab.com/group/sub/repo.git | group/sub/repo (GitLab subgroup preserved) |
https://user:token@github.com/o/r.git | o/r (basic-auth stripped) |
file:///tmp/upstream | upstream (bare repo, no owner) |
| anything unparseable | null → step 3 returns null → step 4 ERROR (no fallback) |
Strip .git suffix when present. Strip scheme://[creds@]host/ or git@host: prefix. Multiple / separators between owner and repo are preserved (GitLab subgroups), then collapsed to _ by normalize() (§5).
5. normalize — the one true sanitizer
Unchanged from v0.9 §4.
function normalize(s: string | null | undefined): string | null {
if (!s) return null;
const lowered = s.trim().toLowerCase();
const joined = lowered.replace(/[/-]+/g, "_"); // / and - → _
const stripped = joined.replace(/[^a-z0-9._]/g, ""); // anything else: drop
if (!stripped) return null;
if (stripped.length > 100) {
throw new Error(
`resolved repo name "${stripped.slice(0, 40)}…" exceeds 100 chars. ` +
`Pass --repo=<short-name> to override.`
);
}
return stripped;
}Grammar: [a-z0-9._]+, max 100 chars. Case-insensitive (matches macOS filesystem semantics).
A .vsync file whose repo value fails normalize() is treated as malformed and errors out at read time (§3.3 last row).
6. vsync import — swap step 3
vsync import <env> <share-file> is the one subcommand where parsing origin is the wrong default — the operator is importing someone else's vault, possibly into a checkout with an unrelated origin. So:
import's chain:
1. opts.override (--repo=<name>)
2. readVsyncFile(toplevel) (still honors local .vsync if present)
3. shareFile.metadata.repo ← differs from pull/push/etc.
4. ERRORImport also runs two cross-checks beyond plain precedence: file-vs-share and flag-vs-file mismatches. Both surface mistakes that precedence alone would silently swallow.
6.1 No flag passed
.vsync state | Behaviour |
|---|---|
| Absent | Resolve identity from the share file's embedded repo field. Write .vsync with repo=<that-name> + header comment. Print "please commit". |
Present, repo= matches share's | Read only. No write. Proceed. (Common case — Bob clones a pinned repo, then imports Alice's share — both agree.) |
Present, repo= differs from share's | ERROR (§6.3 — share-vs-file mismatch). File untouched. |
| Present, malformed | ERROR (VsyncFileMalformedError). File untouched. |
6.2 --repo=<X> flag passed
.vsync state | Share's embedded repo | Behaviour |
|---|---|---|
| Absent | any | Resolve to <X>. Write .vsync with repo=<X>. (Flag overrides share; legitimate use case is importing the same share under a different local namespace.) |
Present, repo= matches <X> | matches <X> | No write. Proceed. |
Present, repo= matches <X> | differs from <X> | No write. Proceed. (Flag silently wins over share; file already agrees with flag.) |
Present, repo= differs from <X> | any | ERROR (§3.4 — flag-vs-file clobber refusal). File untouched. |
The flag silently winning over the share (row 3) is a niche but legitimate workflow: an operator deliberately importing one team's share under a renamed local identity for testing or migration. They typed the flag; they meant it.
6.3 Error — share file is for a different repo than .vsync pins
✗ .vsync pins identity to `acme-web`, but the share file declares `unrelated-project`.
share: ./unrelated-project-dev.share
.vsync: /Users/muthu/work/acme-web/.vsync
This share was exported from a different repo. Importing it here would
mix unrelated vaults under one identity.
Either:
- check you have the right share file, OR
- delete .vsync and re-run if you really mean to take over this checkout, OR
- pass --repo=<X> if you want to import this share under a renamed
local identity (the flag overrides both file and share).Exit 1. Hit on row 3 of §6.1.
6.4 What flows where
Worth pinning explicitly because it confuses operators on the first read:
- Repo identity affects only local paths: keychain account name, config file path under
~/.config/vsync/<repo>/, and the local.vsyncpin. It does not affect the S3 prefix or the bundle's contents. - The share file's embedded config (S3 endpoint, bucket, IAM key, S3 prefix, salt) is decrypted with the share's passphrase and written verbatim into
~/.config/vsync/<resolved-repo>/env_<env>. That config'sprefixfield is whatpullandpushuse to address S3 — the resolved repo identity never appears in the S3 path. - The AES key unwrapped from the share goes into the keychain under
<resolved-repo>/<env>.
So --repo=<X> on import really does mean "store this share's bundle under a different local name." The data still pulls from whatever prefix the original exporter set; only your machine sees the rename.
7. Worktree behaviour
The precedence chain holds without special casing for worktrees. git rev-parse --show-toplevel from inside a worktree returns the worktree's own checkout dir, not the main worktree's. git config --get remote.origin.url reads from the shared .git/config, so it returns the same URL from any worktree.
| Situation | Step that wins | Resulting identity |
|---|---|---|
Worktree on a branch where .vsync is committed | step 2 (file) | same as main worktree |
Worktree on a legacy branch predating the .vsync commit | step 3 (origin URL) | same as main worktree |
Worktree on a branch with a deliberately different .vsync | step 2 (file) | the branch's pinned name (per-branch override is legal but rare) |
Fresh worktree, --repo=<X> passed | step 1 (flag) | <X>. If .vsync is present and differs, ERROR per §3.4. |
Worktree caveat for vsync init: running init from a feature-branch worktree writes .vsync to the worktree's tree, which lands on that branch's working copy. Either run init from the main worktree (so the file lands on the integration branch and propagates via normal merge flow), or pass --repo=<existing-name> to skip the file write when the existing checkout's .vsync already pins the identity correctly.
8. Error messages
The four hard-error paths and their exact messages:
8.1 Not in a git tree
✗ vsync requires a git repository.
cwd: /tmp/somewhere
Run `git init` and `git remote add origin <url>` to set up,
or `cd` into an existing git tree before running vsync.Exit 1. Hit when git rev-parse --show-toplevel fails or returns non-zero.
8.2 Git tree, but no .vsync and no origin and no --repo
✗ Cannot resolve repo identity.
toplevel: /Users/muthu/work/new-project
origin remote: not set
.vsync file: not present
--repo flag: not passed
Either:
- run `git remote add origin <url>` to derive identity from the remote, then re-run, OR
- run `vsync init <env> --repo=<name>` to pin the identity explicitly, OR
- pass `--repo=<name>` on this command for a one-shot rename.Exit 1.
8.3 .vsync present but --repo=<X> differs
(See §3.4 for the verbatim message.)
Exit 1.
8.4 .vsync malformed
✗ .vsync at /Users/muthu/work/acme-web/.vsync is malformed.
expected: one `key=value` per line, with at least
repo=<name>
got: <truncated 80-char preview of the file>
Fix the file or delete it and re-run `vsync init`.Exit 1.
9. vsync status extension
bin/status.ts and src/status.ts grow new top-of-report fields. The per-env table is unchanged from v0.13.
9.1 Text report (new prefix lines)
Repo: acme-web
Source: file (.vsync at /Users/muthu/work/acme-web/.vsync)
Toplevel: /Users/muthu/work/acme-web
CWD: /Users/muthu/work/acme-web
Origin: git@github.com:acme/web.git
Envs:
env profile prefix gen last push status
─── ───────── ────── ─── ───────── ──────
dev hetzner-personal acme-web/dev/ 12 2 hrs ago ok
prod hetzner-personal acme-web/prod/ 45 yesterday ok
Profiles:
hetzner-personal hel1.your-objectstorage.com personal-secrets
(no notices)Source values, in order of which step won:
| Source value | Step | Notes |
|---|---|---|
flag (--repo=<X>) | 1 | One-shot override. --repo=<X> was passed on the current invocation. |
file (.vsync at <path>) | 2 | Committed pin won. Path is to the actual file. |
share (from <share-file>) | 3' | Only on import. The share file's embedded identity won. |
auto (parsed from origin: <url>) | 3 | Default. The origin URL parser won. |
If status is run from a worktree, the prefix block also shows the worktree info:
Worktree: feature-branch (linked from /Users/muthu/work/acme-web)Detected via git rev-parse --git-common-dir differing from <toplevel>/.git.
9.2 JSON report (new keys)
StatusReport gains four new top-level keys:
{
"repo": "acme-web",
"source": "file", // NEW: flag | file | share | auto
"sourceDetail": ".vsync at /Users/muthu/work/acme-web/.vsync", // NEW: human-readable detail string
"toplevel": "/Users/muthu/work/acme-web", // NEW
"cwd": "/Users/muthu/work/acme-web", // NEW
"originUrl": "git@github.com:acme/web.git", // NEW: null if no origin set
"worktree": null, // NEW: { "branch": "...", "mainToplevel": "..." } | null
"envs": [ /* unchanged */ ],
"profiles": [ /* unchanged */ ],
"notices": [ /* unchanged */ ]
}9.3 Notices
If the resolved name differs from what origin would auto-parse to (i.e., Source = flag or Source = file and the resolved name ≠ the auto-parsed name), status emits one notice:
ℹ identity `acme-web-staging` is a rename — origin would auto-resolve to `acme-web`.Informational only; not a failure. Helps the operator notice when a rename is in play.
10. Implementation slices
10.1 src/repo.ts — resolver rewrite
- Remove
SECRETS_SYNC_REPOenv read. - Remove
basename(cwd)and"default"fallback steps. - Add
readVsyncFile(toplevel)helper — JSON parse, schema-version check,normalize()therepofield. - Hard-error at the end of the chain if all steps return
null. - Public API:
getRepoName(opts: { override?: string; importMode?: { shareRepo: string } }): string— never returnsnull; throws a typed error instead.
10.2 src/vsyncfile.ts — new module
readVsyncFile(toplevel: string): { repo: string } | null— returns null if file absent; throws on malformed.writeVsyncFile(toplevel: string, repo: string): void— writes JSON at mode 0644; refuses to clobber a present file with a differentrepo(caller must check first viareadVsyncFile).vsyncFilePath(toplevel: string): string— pure path join, used by status for thesourceDetailstring.
10.3 bin/init.ts — write .vsync after successful init
After the existing config + keychain writes, call writeVsyncFile if the resolved name came from step 1 (flag) or step 3 (auto). If it came from step 2 (file already present), skip — no-op. Print the "please commit" line on first write only.
10.4 bin/import.ts — same as init, plus share-file precedence
Add the step-3' swap in the resolver invocation. Write .vsync on first import.
10.5 bin/status.ts + src/status.ts — new prefix block
- Compute resolved name + source + sourceDetail by re-running the resolver with the same precedence used by other subcommands (no shortcut — status must match what other commands would resolve to).
- Read
git rev-parse --show-toplevel,cwd, andgit config --get remote.origin.url. - Detect worktree by comparing
git rev-parse --git-common-diragainst<toplevel>/.git. - Render text + JSON per §9.
10.6 Error class taxonomy
Two new typed errors in src/errors.ts:
NotInGitRepoError— thrown by the resolver whengit rev-parse --show-toplevelfails.RepoIdentityUnresolvedError— thrown when steps 1–3 all return null.VsyncFileMalformedError— thrown byreadVsyncFileon malformedkey=valuelines or missing requiredrepokey.VsyncFileClobberError— thrown bywriteVsyncFilewhen refusing to overwrite a differing pin (flag-vs-file mismatch oninit/import).ShareRepoMismatchError— thrown byimportwhen.vsyncpins one identity but the share file's embeddedrepodeclares a different one and no overriding--repo=flag was passed (§6.3).
All five extend the existing top-level CLI error base. None are part of the runtime-lib's error taxonomy (the lib reads env-vars or explicit strings, never the filesystem — see v0.12-vsync-s3-client.md).
11. Tests
Update test/repo.test.ts (currently has ~16 tests after v0.9 additions):
- Drop any test that exercises
SECRETS_SYNC_REPO. - Drop any test that exercises the
basename(cwd)or"default"fallback. - Add
.vsync-precedence tests: file present overrides origin parse; flag overrides file (when matching); flag mismatching file errors (~4 tests). - Add error-path tests: no git tree →
NotInGitRepoError; git but no origin and no file →RepoIdentityUnresolvedError; malformed.vsync→VsyncFileMalformedError(~3 tests). - Add worktree tests: same identity across main + linked worktree via file; same identity via origin when file absent; per-branch override when worktree has a different file (~3 tests).
New test/vsyncfile.test.ts (~10 tests):
- Round-trip read/write (header comment +
repo=line). - Refuse-to-clobber semantics when
repo=value differs. - Idempotent re-write when
repo=value matches (no-op). - Missing
repo=key →VsyncFileMalformedError. repo=value failingnormalize()→VsyncFileMalformedError.- Comment lines (
#-prefixed) are ignored. - Inline
#after the=is part of the value (no inline comments). - Duplicate
repo=lines → last wins. - Unknown keys are ignored (forward-compat).
- Unknown keys round-trip preserved on rewrite.
- Path computation under custom toplevel.
test/init.test.ts extensions (~3 tests):
- Init from clean repo writes
.vsync+ prints commit hint. - Init when
.vsyncalready matches → no file write. - Init with
--repo=<X>differing from present.vsync→VsyncFileClobberError, file untouched, no config/keychain side effects.
test/import.test.ts extensions (~7 tests) — full matrix from §6.1 + §6.2:
- §6.1 row 1 — no
.vsync, no flag → imports under share's embeddedrepo, writes.vsyncwith that name, prints commit hint. - §6.1 row 2 —
.vsyncmatches share → no file write, import proceeds, no error. - §6.1 row 3 —
.vsyncdiffers from share, no flag →ShareRepoMismatchError, file untouched, no config/keychain side effects. - §6.1 row 4 —
.vsyncmalformed →VsyncFileMalformedError, file untouched. - §6.2 row 1 — no
.vsync,--repo=<X>, share embeds different repo → imports under<X>, writes.vsyncwith<X>, ignores share's name silently. - §6.2 row 3 —
.vsyncmatches<X>, share differs → no file write, import proceeds, flag-and-file agree. - §6.2 row 4 —
.vsyncdiffers from<X>→VsyncFileClobberError, file untouched.
test/status.test.ts extensions (~5 tests):
- Source =
autowhen no file present. - Source =
filewhen.vsyncpresent. - Source =
flagwhen--repo=<X>passed. - Rename notice emitted when resolved name ≠ auto-parsed name.
- Worktree detection populates the
worktreefield in JSON output.
Target: ~33 net tests added; v0.15 count is around ~260, v0.16 lands around ~293.
11.A Integration test harness — ephemeral MinIO + ephemeral git
The unit tests above pin the resolver, file parser, and CLI surfaces against in-memory fixtures. They are fast (whole suite under 10s) and don't need network or Docker. But they don't prove that init → push → pull → status cooperates end-to-end against a real S3 endpoint, and they can't exercise worktree behaviour against actual git worktree add plumbing.
v0.16 introduces a separate integration suite to close that gap. The whole harness is ephemeral by design — every run starts and ends with zero state on disk and zero state in any backing store.
11.A.1 Architecture
┌─ test/integration/ ── new dir
│ ├── docker-compose.yml MinIO only (single service)
│ ├── setup-minio.sh bucket + IAM setup (called by Taskfile)
│ ├── teardown-minio.sh graceful clean (called by Taskfile)
│ ├── harness.ts spawn / cleanup helpers
│ ├── repo-identity.test.ts v0.16 surface E2E
│ ├── push-pull.test.ts round-trip data via real S3
│ └── worktree.test.ts git worktree resolution proof
│
├─ Taskfile.yml new target: `task test:integration`
└─ package.json new script: "test:integration"The split between docker-compose.yml (container lifecycle) and setup-minio.sh (bucket / IAM provisioning) is deliberate. Compose stays minimal — it just runs MinIO. The shell script handles everything bucket-side, which means devs can re-run it against an already-running MinIO to add buckets, reset state, or seed test data without bouncing the container. It also makes the setup readable as a single linear script instead of buried in compose entrypoints.
Each test file:
- Spins up MinIO via
docker compose up -d --wait(waits on the healthcheck). - Creates a
mkdtempSyncworkspace for the test's git repo. - Runs
git init+git remote add origin git@github.com:acme/web.git(the URL is a string — no actual fetch happens). - Invokes the real
bin/vsync.tsas a subprocess viaBun.spawn, hitting the local MinIO endpoint. - Asserts on filesystem state, command output, and S3 object listings.
afterAll: removes the tmpdir, clears the keychain entry, runsdocker compose down -vto wipe MinIO.
The keychain cleanup is per-test (each test uses a uniquely-named <repo> identity, so cleanup is bun-secrets-delete <repo>/<env> for every account written).
11.A.2 docker-compose.yml (minimal — MinIO only)
# test/integration/docker-compose.yml
version: "3.8"
services:
minio:
image: minio/minio:latest
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin123
ports:
- "15230:9000" # API port (avoids collision with a dev MinIO on 9000)
- "15231:9001" # Console port
tmpfs:
- /data # zero persistence — data dies with the container
healthcheck:
test: ["CMD-SHELL", "mc ready local || exit 1"]
interval: 2s
timeout: 2s
retries: 30
start_period: 5sKey properties:
tmpfs: /data— MinIO's data dir lives in RAM. Container restart = blank slate.- Port
15230(not9000) — avoids stomping on a developer's running MinIO. Console UI on15231for ad-hoc debugging. - No volumes, no named volumes, no host mounts. Nothing survives
docker compose down. - Bucket creation is not baked into compose — it lives in
setup-minio.sh(§11.A.3) so devs can re-run it independently.
11.A.3 setup-minio.sh — bucket + IAM provisioning
POSIX shell, idempotent, runs from the host (uses host mc if installed, falls back to docker run --rm minio/mc otherwise). Called by the Taskfile after compose up, and re-runnable any time a dev wants to reset bucket state without bouncing the container.
#!/usr/bin/env bash
# test/integration/setup-minio.sh
# Provision buckets + scoped IAM for the vsync integration suite.
# Idempotent — safe to re-run against a live MinIO at any time.
set -euo pipefail
# ── Config ────────────────────────────────────────────────────────────────
ALIAS="vsync-test"
ENDPOINT="http://localhost:15230"
ROOT_USER="minioadmin"
ROOT_PASS="minioadmin123"
# Buckets used by the integration suite.
# Each test picks one; multi-bucket tests use both.
BUCKETS=(
"vsync-test" # primary — most tests
"vsync-test-alt" # secondary — for cross-bucket / rename scenarios
)
# Scoped IAM user that the CLI actually authenticates as in tests.
# Separates the "admin who created the bucket" from the "app key that uses it",
# mirroring what an operator would do in production.
TEST_USER="vsync-app"
TEST_PASS="vsync-app-secret-456"
# ── Resolve `mc` (host binary or dockerized) ─────────────────────────────
if command -v mc >/dev/null 2>&1; then
MC="mc"
else
MC="docker run --rm --network=host minio/mc"
fi
echo "→ Using mc: $MC"
echo "→ Endpoint: $ENDPOINT"
# ── Wait for MinIO to be ready ────────────────────────────────────────────
for i in {1..30}; do
if $MC alias set "$ALIAS" "$ENDPOINT" "$ROOT_USER" "$ROOT_PASS" >/dev/null 2>&1; then
echo "✔ MinIO reachable"
break
fi
if [ "$i" -eq 30 ]; then
echo "✗ MinIO did not become reachable within 60s" >&2
exit 1
fi
sleep 2
done
# ── Create buckets ────────────────────────────────────────────────────────
for bucket in "${BUCKETS[@]}"; do
$MC mb --ignore-existing "$ALIAS/$bucket"
# vsync uses ETag-conditional manifest swaps; versioning helps `vsync versions`.
$MC version enable "$ALIAS/$bucket"
echo "✔ bucket ready: $bucket (versioning enabled)"
done
# ── Create scoped app user ────────────────────────────────────────────────
if ! $MC admin user info "$ALIAS" "$TEST_USER" >/dev/null 2>&1; then
$MC admin user add "$ALIAS" "$TEST_USER" "$TEST_PASS"
echo "✔ user created: $TEST_USER"
else
echo "✔ user exists: $TEST_USER"
fi
# Policy: read/write on the test buckets, nothing else.
POLICY_DIR="$(mktemp -d)"
trap 'rm -rf "$POLICY_DIR"' EXIT
cat > "$POLICY_DIR/vsync-test-rw.json" <<EOF
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:*"],
"Resource": [
"arn:aws:s3:::vsync-test",
"arn:aws:s3:::vsync-test/*",
"arn:aws:s3:::vsync-test-alt",
"arn:aws:s3:::vsync-test-alt/*"
]
}
]
}
EOF
$MC admin policy create "$ALIAS" vsync-test-rw "$POLICY_DIR/vsync-test-rw.json" 2>/dev/null || \
$MC admin policy add "$ALIAS" vsync-test-rw "$POLICY_DIR/vsync-test-rw.json"
$MC admin policy attach "$ALIAS" vsync-test-rw --user "$TEST_USER" 2>/dev/null || true
echo "✔ policy attached: vsync-test-rw → $TEST_USER"
# ── Summary ───────────────────────────────────────────────────────────────
cat <<EOF
MinIO ready for vsync integration tests.
Endpoint: $ENDPOINT
Console: http://localhost:15231 (login: $ROOT_USER / $ROOT_PASS)
Buckets: ${BUCKETS[*]}
Test user: $TEST_USER / $TEST_PASS
Tests should use the TEST_USER credentials (not the root user).
EOFKey properties:
- Idempotent. Every
mccall uses--ignore-existingor checks before creating. Safe to re-run. - Self-contained. Resolves
mceither from the host PATH or viadocker run --rm minio/mc— no other build dependency. - Scoped IAM. Tests authenticate as
vsync-app, not the root user. Catches accidental permission bugs in vsync itself (e.g., the CLI assuming admin perms it doesn't have). - Versioning enabled.
vsync versions <env>lists historical bundles; the bucket needs S3 versioning on for that to work. - Two buckets. Most tests use
vsync-test; cross-bucket scenarios (rename, alt-profile) use both.
11.A.4 teardown-minio.sh — graceful clean (rarely needed)
#!/usr/bin/env bash
# test/integration/teardown-minio.sh
# Wipes bucket contents and removes the IAM user. Use this when you want a
# clean state without bouncing the container (e.g., during interactive debug).
# Normal `task test:integration` does NOT call this — `docker compose down`
# already wipes everything via tmpfs.
set -euo pipefail
ALIAS="vsync-test"
BUCKETS=("vsync-test" "vsync-test-alt")
TEST_USER="vsync-app"
if command -v mc >/dev/null 2>&1; then MC="mc"; else MC="docker run --rm --network=host minio/mc"; fi
for bucket in "${BUCKETS[@]}"; do
$MC rb --force "$ALIAS/$bucket" 2>/dev/null || true
done
$MC admin user remove "$ALIAS" "$TEST_USER" 2>/dev/null || true
$MC admin policy remove "$ALIAS" vsync-test-rw 2>/dev/null || true
echo "✔ teardown complete"Optional. The default integration-suite flow relies on compose down -v + tmpfs to wipe everything in one go — teardown-minio.sh is for the rare case of cleaning state on a long-running MinIO without bouncing it.
11.A.5 Workspace lifecycle (load-bearing pattern)
Every integration test follows the same pattern. One shared tmp workspace per test file, with many ephemeral git repos created inside it, and a single recursive cleanup at the end.
tmp/vsync-integration-<random>/ ← mkdtempSync, the workspace root
├── repo-acme-web/ git init + remote origin=acme/web
│ └── .vsync written by vsync init
├── repo-acme-api/ git init + remote origin=acme/api
├── repo-vendor-lib/ git init + remote origin=other/vendor
├── repo-gitlab-subgroup/ git init + remote origin=group/sub/repo
├── repo-no-remote/ git init, no remote — used for error paths
├── repo-with-upstream/ origin + upstream both set
└── worktrees/
├── acme-web-feature/ git worktree add of repo-acme-web
└── acme-web-hotfix/ second worktree of repo-acme-webEach test:
beforeAllcreates the workspace and (if needed) starts MinIO + runssetup-minio.sh.- Each
test()creates only the repos it needs under the workspace root, runs the CLI subprocess against them, and asserts. No teardown per test — repos live for the file's duration. afterAllrecursively removes the workspace tmpdir, clears keychain entries created during the run, and (if this is the last file) leaves MinIO running for the next file. The Taskfile'sdownstep at the end of the cycle wipes MinIO entirely.
The workspace name is randomised (vsync-integration-1716647823-a8f2) so parallel test runs (e.g., two engineers running locally at once on a shared CI box) don't collide.
11.A.6 Test scenarios — repo identity (test/integration/repo-identity.test.ts)
Twelve scenarios. Each creates its own repo(s) under the shared workspace.
| # | Scenario | Asserts |
|---|---|---|
| 1 | repo-acme-web (origin = git@github.com:acme/web.git), vsync init dev --profile=e2e | .vsync exists with repo=acme-web; config at ~/.config/vsync/acme-web/env_dev exists; keychain account acme-web/dev exists; init prints "please commit" |
| 2 | Re-run init in same repo | No second file write; no commit hint printed |
| 3 | --repo=acme-web-staging after step 1 | VsyncFileClobberError exit 1; .vsync untouched; no config/keychain side effects |
| 4 | Run any vsync command in workspace root (not in any repo) | NotInGitRepoError exit 1 |
| 5 | repo-no-remote (git init only, no origin), vsync init dev | RepoIdentityUnresolvedError exit 1 |
| 6 | After step 1, vsync status | Source: auto (parsed from origin: git@github.com:acme/web.git); Toplevel = repo-acme-web/; Origin matches |
| 7 | Three distinct repos (acme-web, acme-api, vendor-lib) each init'd | Three distinct identities; three keychain accounts; three config files; status in each shows only that repo's envs |
| 8 | SSH vs HTTPS form same upstream — two clones of one repo with git@github.com:acme/web.git and https://github.com/acme/web.git respectively | Both resolve to identity acme-web; both pull the same bundle (after one pushes) |
| 9 | GitLab subgroup git@gitlab.com:group/sub/web.git | Identity resolves to group-sub-web (slash collapsed to dash); push/pull works |
| 10 | Origin + upstream both set | Origin wins; identity = parsed origin URL |
| 11 | git remote set-url origin <new-url> after init wrote .vsync | Status still shows file-pinned identity; rename notice emitted in status (current .vsync ≠ auto-parsed name) |
| 12 | Two repos colliding via --repo=<X> — init repo-a with --repo=shared then init repo-b with --repo=shared | Second init succeeds writes (config/keychain), but vsync status from both shows the same identity — keychain entries collide. Verify the warning/notice the CLI emits about cross-repo name collision (if any), or document the no-warning behavior as the operator's responsibility. |
11.A.7 Test scenarios — push/pull (test/integration/push-pull.test.ts)
Ten scenarios. Most use repo-acme-web as the base; cross-repo and multi-env scenarios spin up additional repos.
| # | Scenario | Asserts |
|---|---|---|
| 1 | Init dev + write .env.dev + vsync push dev | Manifest + bundle present on MinIO at <prefix>manifest and <prefix><gen>.bundle; status shows gen >= 1 |
| 2 | After step 1, rm -rf infra/vault/ + vsync pull dev | .env.dev contents restored byte-for-byte |
| 3 | Two pushes with different content + vsync versions dev | Lists both generations; manifest points at gen 2; gen 1 bundle still present |
| 4 | After step 3, vsync audit dev | Audit log has 2 append rows in correct chronological order |
| 5 | Multiple envs in one repo — init dev + staging + prod + push each with distinct content | Three distinct prefixes on MinIO; pulls retrieve correct env's data; status table shows three rows |
| 6 | Two repos sharing the bucket — repo-acme-web and repo-acme-api both push to vsync-test bucket | Two distinct prefixes; no key collision; each repo's pull only sees its own data |
| 7 | Large vault — 100 env keys + 3 binary assets (10KB, 100KB, 1MB) | Push + pull round-trips all keys byte-equal; bytes-equal MD5 on each binary; total round-trip < 5s |
| 8 | Manual bundle delete on bucket — push, then mc rm <prefix><gen>.bundle, then pull | BundleCorruptError (manifest exists, bundle missing) — exit 1, clear error message |
| 9 | Wrong passphrase on pull — rotate the keychain entry to a different AES key, then pull | WrongPassphraseError exit 1; no partial write to infra/vault/ |
| 10 | Concurrent push from two processes — race two vsync push dev against same prefix | One wins (gen+1), one fails with retryable error or succeeds at gen+2; audit log has both rows in arrival order; no torn manifest |
11.A.8 Test scenarios — worktrees (test/integration/worktree.test.ts)
Eight scenarios. All use a single base repo (repo-acme-web) with multiple linked worktrees under worktrees/.
| # | Scenario | Asserts |
|---|---|---|
| 1 | Init dev + commit .vsync to main + git worktree add ../worktrees/feature feature-branch | cd ../worktrees/feature && vsync status shows Repo: acme-web; Source: file (.vsync at .../worktrees/feature/.vsync) |
| 2 | From feature worktree, vsync push dev then vsync pull dev | Round-trips correctly; vault folder written to worktrees/feature/infra/vault/dev/ (not main's) |
| 3 | Legacy branch sim — git checkout -b old-branch from a commit predating .vsync, git worktree add ../worktrees/old old-branch | From worktrees/old/: status shows Source: auto; identity unchanged from main |
| 4 | Per-branch override — commit a different .vsync (repo=feature-staging) on feature-branch, then worktree-add | worktrees/feature/ resolves to feature-staging; pushes hit a different S3 prefix than main's pushes |
| 5 | Three worktrees — main + 2 linked, all on branches with the original .vsync | All three resolve to the same identity; all three see the same env list in status |
| 6 | Uncommitted .vsync delete in worktree — from a linked worktree, rm .vsync (not committed) | vsync status from that worktree falls back to origin parse; identity unchanged (origin URL is the safety net) |
| 7 | Branch switch in main worktree — main worktree on a branch with .vsync, git checkout to a branch without it | Status now shows Source: auto; identity unchanged (origin URL fallback works) |
| 8 | Cross-worktree data sharing — push from worktree A, pull from worktree B (both on branches with same .vsync) | Worktree B sees worktree A's pushed data; proves shared S3 prefix |
11.A.9 Test scenarios — cross-repo (test/integration/cross-repo.test.ts)
NEW file. Five scenarios that exercise interactions between distinct repos in the workspace.
| # | Scenario | Asserts |
|---|---|---|
| 1 | Init repo-a and repo-b (different origins) in same workspace | Each has its own config/keychain; no cross-talk in either repo's status |
| 2 | Push from both repo-a and repo-b to same MinIO bucket | Two distinct prefixes; bucket lists both; neither repo's pull retrieves the other's data |
| 3 | Name collision via --repo= — init repo-a with --repo=shared, then init repo-b with --repo=shared | Second init succeeds (overwrites the <shared>/dev config/keychain); status from repo-b shows shared's state. Documents the "operator's responsibility" boundary. |
| 4 | Status from inside repo-a doesn't list repo-b's envs | Status output scope is per-(repo-identity), not workspace-wide |
| 5 | Import share into wrong repo — export from repo-a, attempt vsync import in repo-b (which has its own .vsync) | ShareRepoMismatchError exit 1; repo-b's .vsync untouched; no keychain mutation |
Total integration count: 35 scenarios across 4 files (12 + 10 + 8 + 5). Plus the harness module is itself covered indirectly by every test.
11.A.10 harness.ts — workspace-aware helpers
No test framework, no DSL. The harness exposes a workspace primitive (the shared tmpdir) plus repo/worktree/subprocess builders. All cleanup flows through Workspace.cleanup() — tests never call rm -rf directly.
// ── Workspace ─────────────────────────────────────────────────────────────
/**
* Create a shared tmp workspace for all tests in this file.
* Tracks every repo, worktree, and keychain entry created within it.
* Calling cleanup() removes the entire workspace dir and clears every
* tracked keychain entry. Safe to call multiple times (idempotent).
*/
export function makeWorkspace(filename: string): Workspace;
export interface Workspace {
/** The tmpdir root, e.g. /tmp/vsync-integration-1716647823-a8f2/ */
readonly root: string;
/** Create a new git repo under the workspace. */
makeRepo(opts: MakeRepoOpts): Promise<Repo>;
/** Create a linked worktree from an existing repo. */
makeWorktree(repo: Repo, opts: MakeWorktreeOpts): Promise<Worktree>;
/** Track a keychain account so cleanup() removes it. */
trackKeychain(repoIdentity: string, env: string): void;
/** Recursive cleanup: rm -rf workspace + delete tracked keychain accounts. */
cleanup(): Promise<void>;
}
// ── Git repos ─────────────────────────────────────────────────────────────
export interface MakeRepoOpts {
/** Subdirectory name under the workspace root. Must be filesystem-safe. */
name: string;
/** Optional remote URL. If unset, repo has no origin. */
remoteUrl?: string;
/** Initial branch name. Defaults to "main". */
branch?: string;
/**
* If true, write + commit a `.vsync` file at the initial commit so worktrees
* branched from HEAD inherit it. Use with `vsyncRepo` to control the value.
*/
commitVsync?: boolean;
/** Value for the .vsync's `repo=` field. Required if commitVsync is true. */
vsyncRepo?: string;
/** Additional remotes to add (e.g. "upstream" → url). */
extraRemotes?: Record<string, string>;
}
export interface Repo {
readonly dir: string;
/** Run a git command in this repo. Returns stdout. */
git(...args: string[]): Promise<string>;
}
// ── Worktrees ─────────────────────────────────────────────────────────────
export interface MakeWorktreeOpts {
/** Subdirectory name (created under workspace.root, sibling of the repo). */
name: string;
/** Branch to check out in the worktree. Created if absent. */
branch: string;
/** Base ref to branch from. Defaults to current HEAD. */
fromRef?: string;
}
export interface Worktree {
readonly dir: string;
/** Run a git command in this worktree. */
git(...args: string[]): Promise<string>;
}
// ── vsync CLI subprocess ──────────────────────────────────────────────────
export interface RunVsyncOpts {
/** Working directory for the subprocess (a Repo.dir or Worktree.dir). */
cwd: string;
/** Argv after the `vsync` program name, e.g. ["init", "dev", "--profile=e2e"]. */
args: string[];
/** Extra env vars merged on top of process.env. */
env?: Record<string, string>;
/**
* Expected exit code. Defaults to 0. If the actual exit differs,
* runVsync throws with the captured stdout+stderr in the error message.
*/
expectExit?: number;
}
export interface VsyncResult {
stdout: string;
stderr: string;
exitCode: number;
}
/**
* Spawn `bun bin/vsync.ts <args>` from `cwd` and return captured output.
* Always streams stderr to the test runner's stderr (so failures are debuggable).
*/
export async function runVsync(opts: RunVsyncOpts): Promise<VsyncResult>;
// ── MinIO lifecycle (host-side; only called once per file in beforeAll) ───
/**
* Confirm MinIO is reachable on $VSYNC_TEST_ENDPOINT. Does NOT start docker —
* the Taskfile is responsible for that. Returns null on unreachable so the
* test file can skip cleanly (see §11.A.11).
*/
export async function ensureMinioReachable(): Promise<MinioConn | null>;
export interface MinioConn {
endpoint: string; // http://localhost:15230
bucket: string; // "vsync-test"
accessKey: string; // "vsync-app"
secretKey: string; // "vsync-app-secret-456"
}
/**
* Inspect a bucket: list keys under a prefix, fetch one, delete one.
* Used for asserts ("manifest should exist after push") and for setup
* ("delete this bundle to simulate corruption").
*/
export async function s3List(conn: MinioConn, prefix: string): Promise<string[]>;
export async function s3Get(conn: MinioConn, key: string): Promise<Uint8Array>;
export async function s3Delete(conn: MinioConn, key: string): Promise<void>;Example usage in a test:
import { test, expect, beforeAll, afterAll } from "bun:test";
import { makeWorkspace, ensureMinioReachable, runVsync, s3List } from "./harness";
const ws = makeWorkspace(import.meta.path);
let minio: Awaited<ReturnType<typeof ensureMinioReachable>>;
beforeAll(async () => {
minio = await ensureMinioReachable();
if (!minio) process.exit(0); // skip — Docker not available
});
afterAll(() => ws.cleanup());
test("scenario 7 — three distinct repos resolve independently", async () => {
const a = await ws.makeRepo({ name: "repo-acme-web", remoteUrl: "git@github.com:acme/web.git" });
const b = await ws.makeRepo({ name: "repo-acme-api", remoteUrl: "git@github.com:acme/api.git" });
const c = await ws.makeRepo({ name: "repo-vendor", remoteUrl: "git@github.com:vendor/lib.git" });
for (const repo of [a, b, c]) {
const r = await runVsync({ cwd: repo.dir, args: ["init", "dev", "--profile=e2e"] });
expect(r.exitCode).toBe(0);
}
// Three distinct .vsync files
expect(await Bun.file(`${a.dir}/.vsync`).text()).toMatch(/repo=acme-web/);
expect(await Bun.file(`${b.dir}/.vsync`).text()).toMatch(/repo=acme-api/);
expect(await Bun.file(`${c.dir}/.vsync`).text()).toMatch(/repo=vendor-lib/);
// Three distinct S3 prefixes after push
await runVsync({ cwd: a.dir, args: ["push", "dev"] });
const aKeys = await s3List(minio!, "acme-web/dev/");
const bKeys = await s3List(minio!, "acme-api/dev/");
expect(aKeys.length).toBeGreaterThan(0);
expect(bKeys.length).toBe(0); // b never pushed
});11.A.11 Skip logic
Each test file's beforeAll does:
const minio = await startMinio().catch(() => null);
if (!minio) {
console.warn("Skipping integration suite: MinIO unreachable (Docker not running?)");
process.exit(0); // skip, do not fail
}bun test exit-0-on-skip means a developer without Docker still gets a green local suite. CI sets DOCKER_AVAILABLE=1 to convert the skip into a fail (so a misconfigured CI doesn't silently pass).
11.A.12 test/integration/Taskfile.yml — dedicated lifecycle
A separate Taskfile lives inside test/integration/ so the integration lifecycle is self-contained. The root Taskfile.yml adds one delegating target that descends here; everything substantive lives in this file:
# test/integration/Taskfile.yml
version: "3"
vars:
COMPOSE: "docker compose -f docker-compose.yml"
ENDPOINT: "http://localhost:15230"
tasks:
default:
desc: "Full cycle: up → setup → test → down (always tears down, even on failure)"
cmds:
- task: up
- defer: { task: down }
- task: setup
- task: test
up:
desc: "Start MinIO in the background and wait for healthcheck"
cmds:
- "{{.COMPOSE}} up -d --wait"
status:
- "{{.COMPOSE}} ps --services --filter status=running | grep -q minio"
setup:
desc: "Provision buckets + IAM (idempotent — safe to re-run anytime)"
deps: [up]
cmds:
- ./setup-minio.sh
test:
desc: "Run integration tests against the running MinIO (assumes `up`+`setup` already done)"
deps: [up]
cmds:
- bun test {{.CLI_ARGS}}
env:
VSYNC_TEST_ENDPOINT: "{{.ENDPOINT}}"
VSYNC_TEST_USER: "vsync-app"
VSYNC_TEST_PASS: "vsync-app-secret-456"
test:one:
desc: "Run a single integration test file (usage: task test:one -- repo-identity.test.ts)"
deps: [up]
cmds:
- bun test {{.CLI_ARGS}}
env:
VSYNC_TEST_ENDPOINT: "{{.ENDPOINT}}"
VSYNC_TEST_USER: "vsync-app"
VSYNC_TEST_PASS: "vsync-app-secret-456"
down:
desc: "Stop MinIO and wipe all state (tmpfs + named volumes)"
cmds:
- "{{.COMPOSE}} down -v"
reset:
desc: "Bounce MinIO and re-provision (useful when bucket state gets weird)"
cmds:
- task: down
- task: up
- task: setup
logs:
desc: "Tail MinIO logs (for debugging hung tests)"
cmds:
- "{{.COMPOSE}} logs -f minio"
console:
desc: "Open the MinIO web console (login: minioadmin / minioadmin123)"
cmds:
- open http://localhost:15231 || xdg-open http://localhost:15231Root Taskfile.yml gets one delegating target only:
# Taskfile.yml (root) — one-line delegate
tasks:
test:integration:
desc: "Run the E2E integration suite (delegates to test/integration/Taskfile.yml)"
dir: test/integration
cmds:
- task --Invocation patterns:
# Full cycle from anywhere
task test:integration
# Iterative dev — start once, run tests many times
cd test/integration
task up # start MinIO (idempotent — no-op if already up)
task setup # provision buckets
task test # run all integration tests
task test -- repo-identity.test.ts # rerun one
task reset # bounce + re-provision when state goes sideways
task down # stop when done
# CI
DOCKER_AVAILABLE=1 task test:integrationLifecycle guarantees:
defaultusesdefer: { task: down }— MinIO is torn down even when tests fail. No dangling containers.uphas astatus:clause that short-circuits if MinIO is already running, so iterativetask upinvocations are instant.setupdepends onup, sotask setupon a clean machine still works (it starts MinIO first).testdoesn't runsetupautomatically — that's intentional. Tests assume buckets exist; runningsetupper test would slow the iterative loop by ~3s. Runtask setuponce afterup; rerun only afterreset.downuses-vto wipe named volumes too (defensive — there shouldn't be any, but cheap insurance).resetis the "I broke the bucket, fix it" button: down + up + setup in one command.
11.A.13 What the integration suite does NOT do
- Doesn't replace unit tests. Unit tests still pin the resolver, parser, and CLI surfaces. Integration only proves cooperation.
- Doesn't run on every
bun test. Defaultbun testskipstest/integration/(added tosrcExcludeanalog inbunfig.tomlor via glob in thebun testinvocation). - Doesn't test runtime libraries. The Python/TS/Go/Java libs have their own per-language test harnesses (and the conformance corpus). The CLI integration suite is CLI-only.
- Doesn't test against real AWS / Hetzner. MinIO is API-compatible; provider-specific quirks (S3 versioning differences, presigned URL edge cases) are out of scope here.
- Doesn't auto-install Docker. A developer without Docker sees a skip, not a failure.
12. Non-goals
- Caching the resolved name across invocations (resolver stays pure; one
git configspawn per command is cheap). - A
vsync identitysubcommand to print just the resolved name (usevsync status --json | jq .repo). - Auto-detecting
upstreamas canonical whenoriginis unset (operator can setoriginor write.vsync). - Generating
.gitignoreentries for.vsync(the file is meant to be committed — adding it to.gitignoredefeats the purpose). - Reading any non-
.vsyncconfig from the repo (e.g., apackage.jsonfield, apyproject.tomlfield). The pin file is the only repo-local config vsync reads. - A migration tool for v0.13 installs whose name came from
basename(cwd)or"default". The firstinitafter upgrade lands them on a.vsync-pinned identity; old keychain entries remain accessible via--repo=<old-name>for as long as the operator wants to keep them. - Schema fields in
.vsyncbeyondvandrepo. Future fields require bumpingv.
13. Compatibility & migration
- Wire format (RQE1, RQEM0001, SLS1, share files, audit log): unchanged.
- 0.15.x ↔ 0.16.0 bundles are mutually readable.
- Operators on v0.15 whose name came from
originparsing continue to resolve to the same name in v0.16 (algorithm unchanged); their firstinitafter upgrade writes.vsyncwith that name. - Operators on v0.15 whose name came from
SECRETS_SYNC_REPO,basename(cwd), or"default"hitRepoIdentityUnresolvedErroron first v0.16 run. The error message names the recovery (git remote add origin, or--repo=<name>on a one-shot init). - Existing config files at
~/.config/vsync/<repo>/env_<env>keep their stored names — the resolver only chooses names on init, not on pull/push.
14. Relationship to other specs
- v0.2 (
secret-lib.md) — keychain + config split. Unchanged. Keychain account = resolved name; v0.16 only changes how that name is resolved, not what it's used for. - v0.4 (
audit-log.md) — append protocol. Unchanged. - v0.9 (
repo-name-resolution.md) — superseded by this spec. Keep v0.9 as historical context for theparseRemoteUrl+normalizealgorithm; v0.16 reuses both verbatim. - v0.10 (
runtime-token-cli.md) —vsync runtime-tokennow also enforces the git-tree precondition (it resolves a repo name to look up the per-(repo, env) config). No flag changes. - v0.12 (
vsync-s3-client.md) — runtime libraries are unaffected. They readVSYNC_CONFIG+VSYNC_PASSPHRASEand never touch the filesystem or git. The.vsyncfile is a CLI-side concept. - v0.13 (
profiles-init-status.md) — status command extended (§9). Profile system unchanged. - v0.15 (
vsync-s3-client.md— Firebase-style runtime redesign, pending) — independent; both specs can ship in the same release or separately.