Skip to content

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.
  • R2SECRETS_SYNC_REPO is 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.url parsed to <owner>-<repo>. No fallback to cwd basename, no "default" literal.
  • R5 — A committed .vsync file at git toplevel pins the identity team-wide. Written by init / import; read by everyone; refuse-to-clobber if a --repo= flag tries to overwrite a different value.
  • R6vsync status displays the resolved name, the source (flag / file / share / auto), the git toplevel, the cwd, and the parsed origin URL. Shipped in this release, not deferred.

1. Diff from v0.9 / v0.13

v0.9 / v0.13v0.16
--repo=<name> flagstep 1 of 5step 1 of 4, refuses to clobber a present .vsync with a different value
SECRETS_SYNC_REPO env varstep 2 of 5removed
package.json::namealready removed in v0.9still removed
origin URL parsestep 3 of 5step 3 of 4 (renumbered), unchanged algorithm
basename(cwd) fallbackstep 4 of 5removed
"default" literalstep 5 of 5removed
.vsync file at git topleveldid not existnew step 2 of 4
Behaviour outside a git treeresolved to basename(cwd) or "default"hard error
Behaviour with git but no origin and no .vsync and no --reporesolved to basename(cwd) or "default"hard error
vsync status outputname + per-env tablename + 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 init and vsync 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-web

3.2.1 Grammar (intentionally narrow)

  • One key=value per 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 repo makes the value acme-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

KeyRequiredNotes
repoyesThe 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:

SituationBehaviour
.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 nameNo-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 passedStep 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 .env already.
  • 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 formParsed <owner>/<repo>
git@github.com:owner/repo.gitowner/repo
https://github.com/owner/repo.gitowner/repo
https://github.com/owner/repoowner/repo
ssh://git@gitlab.com/group/sub/repo.gitgroup/sub/repo (GitLab subgroup preserved)
https://user:token@github.com/o/r.gito/r (basic-auth stripped)
file:///tmp/upstreamupstream (bare repo, no owner)
anything unparseablenull → 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.

ts
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. ERROR

Import 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 stateBehaviour
AbsentResolve identity from the share file's embedded repo field. Write .vsync with repo=<that-name> + header comment. Print "please commit".
Present, repo= matches share'sRead only. No write. Proceed. (Common case — Bob clones a pinned repo, then imports Alice's share — both agree.)
Present, repo= differs from share'sERROR (§6.3 — share-vs-file mismatch). File untouched.
Present, malformedERROR (VsyncFileMalformedError). File untouched.

6.2 --repo=<X> flag passed

.vsync stateShare's embedded repoBehaviour
AbsentanyResolve 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>anyERROR (§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 .vsync pin. 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's prefix field is what pull and push use 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.

SituationStep that winsResulting identity
Worktree on a branch where .vsync is committedstep 2 (file)same as main worktree
Worktree on a legacy branch predating the .vsync commitstep 3 (origin URL)same as main worktree
Worktree on a branch with a deliberately different .vsyncstep 2 (file)the branch's pinned name (per-branch override is legal but rare)
Fresh worktree, --repo=<X> passedstep 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 valueStepNotes
flag (--repo=<X>)1One-shot override. --repo=<X> was passed on the current invocation.
file (.vsync at <path>)2Committed 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>)3Default. 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:

json
{
  "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_REPO env read.
  • Remove basename(cwd) and "default" fallback steps.
  • Add readVsyncFile(toplevel) helper — JSON parse, schema-version check, normalize() the repo field.
  • Hard-error at the end of the chain if all steps return null.
  • Public API: getRepoName(opts: { override?: string; importMode?: { shareRepo: string } }): string — never returns null; 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 different repo (caller must check first via readVsyncFile).
  • vsyncFilePath(toplevel: string): string — pure path join, used by status for the sourceDetail string.

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, and git config --get remote.origin.url.
  • Detect worktree by comparing git rev-parse --git-common-dir against <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 when git rev-parse --show-toplevel fails.
  • RepoIdentityUnresolvedError — thrown when steps 1–3 all return null.
  • VsyncFileMalformedError — thrown by readVsyncFile on malformed key=value lines or missing required repo key.
  • VsyncFileClobberError — thrown by writeVsyncFile when refusing to overwrite a differing pin (flag-vs-file mismatch on init / import).
  • ShareRepoMismatchError — thrown by import when .vsync pins one identity but the share file's embedded repo declares 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 .vsyncVsyncFileMalformedError (~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 failing normalize()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 .vsync already matches → no file write.
  • Init with --repo=<X> differing from present .vsyncVsyncFileClobberError, 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 embedded repo, writes .vsync with that name, prints commit hint.
  • §6.1 row 2.vsync matches share → no file write, import proceeds, no error.
  • §6.1 row 3.vsync differs from share, no flag → ShareRepoMismatchError, file untouched, no config/keychain side effects.
  • §6.1 row 4.vsync malformed → VsyncFileMalformedError, file untouched.
  • §6.2 row 1 — no .vsync, --repo=<X>, share embeds different repo → imports under <X>, writes .vsync with <X>, ignores share's name silently.
  • §6.2 row 3.vsync matches <X>, share differs → no file write, import proceeds, flag-and-file agree.
  • §6.2 row 4.vsync differs from <X>VsyncFileClobberError, file untouched.

test/status.test.ts extensions (~5 tests):

  • Source = auto when no file present.
  • Source = file when .vsync present.
  • Source = flag when --repo=<X> passed.
  • Rename notice emitted when resolved name ≠ auto-parsed name.
  • Worktree detection populates the worktree field 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:

  1. Spins up MinIO via docker compose up -d --wait (waits on the healthcheck).
  2. Creates a mkdtempSync workspace for the test's git repo.
  3. Runs git init + git remote add origin git@github.com:acme/web.git (the URL is a string — no actual fetch happens).
  4. Invokes the real bin/vsync.ts as a subprocess via Bun.spawn, hitting the local MinIO endpoint.
  5. Asserts on filesystem state, command output, and S3 object listings.
  6. afterAll: removes the tmpdir, clears the keychain entry, runs docker compose down -v to 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)

yaml
# 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: 5s

Key properties:

  • tmpfs: /data — MinIO's data dir lives in RAM. Container restart = blank slate.
  • Port 15230 (not 9000) — avoids stomping on a developer's running MinIO. Console UI on 15231 for 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.

bash
#!/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).
EOF

Key properties:

  • Idempotent. Every mc call uses --ignore-existing or checks before creating. Safe to re-run.
  • Self-contained. Resolves mc either from the host PATH or via docker 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)

bash
#!/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-web

Each test:

  1. beforeAll creates the workspace and (if needed) starts MinIO + runs setup-minio.sh.
  2. 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.
  3. afterAll recursively 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's down step 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.

#ScenarioAsserts
1repo-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"
2Re-run init in same repoNo second file write; no commit hint printed
3--repo=acme-web-staging after step 1VsyncFileClobberError exit 1; .vsync untouched; no config/keychain side effects
4Run any vsync command in workspace root (not in any repo)NotInGitRepoError exit 1
5repo-no-remote (git init only, no origin), vsync init devRepoIdentityUnresolvedError exit 1
6After step 1, vsync statusSource: auto (parsed from origin: git@github.com:acme/web.git); Toplevel = repo-acme-web/; Origin matches
7Three distinct repos (acme-web, acme-api, vendor-lib) each init'dThree distinct identities; three keychain accounts; three config files; status in each shows only that repo's envs
8SSH vs HTTPS form same upstream — two clones of one repo with git@github.com:acme/web.git and https://github.com/acme/web.git respectivelyBoth resolve to identity acme-web; both pull the same bundle (after one pushes)
9GitLab subgroup git@gitlab.com:group/sub/web.gitIdentity resolves to group-sub-web (slash collapsed to dash); push/pull works
10Origin + upstream both setOrigin wins; identity = parsed origin URL
11git remote set-url origin <new-url> after init wrote .vsyncStatus still shows file-pinned identity; rename notice emitted in status (current .vsync ≠ auto-parsed name)
12Two repos colliding via --repo=<X> — init repo-a with --repo=shared then init repo-b with --repo=sharedSecond 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.

#ScenarioAsserts
1Init dev + write .env.dev + vsync push devManifest + bundle present on MinIO at <prefix>manifest and <prefix><gen>.bundle; status shows gen >= 1
2After step 1, rm -rf infra/vault/ + vsync pull dev.env.dev contents restored byte-for-byte
3Two pushes with different content + vsync versions devLists both generations; manifest points at gen 2; gen 1 bundle still present
4After step 3, vsync audit devAudit log has 2 append rows in correct chronological order
5Multiple envs in one repo — init dev + staging + prod + push each with distinct contentThree distinct prefixes on MinIO; pulls retrieve correct env's data; status table shows three rows
6Two repos sharing the bucketrepo-acme-web and repo-acme-api both push to vsync-test bucketTwo distinct prefixes; no key collision; each repo's pull only sees its own data
7Large 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
8Manual bundle delete on bucket — push, then mc rm <prefix><gen>.bundle, then pullBundleCorruptError (manifest exists, bundle missing) — exit 1, clear error message
9Wrong passphrase on pull — rotate the keychain entry to a different AES key, then pullWrongPassphraseError exit 1; no partial write to infra/vault/
10Concurrent push from two processes — race two vsync push dev against same prefixOne 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/.

#ScenarioAsserts
1Init dev + commit .vsync to main + git worktree add ../worktrees/feature feature-branchcd ../worktrees/feature && vsync status shows Repo: acme-web; Source: file (.vsync at .../worktrees/feature/.vsync)
2From feature worktree, vsync push dev then vsync pull devRound-trips correctly; vault folder written to worktrees/feature/infra/vault/dev/ (not main's)
3Legacy branch simgit checkout -b old-branch from a commit predating .vsync, git worktree add ../worktrees/old old-branchFrom worktrees/old/: status shows Source: auto; identity unchanged from main
4Per-branch override — commit a different .vsync (repo=feature-staging) on feature-branch, then worktree-addworktrees/feature/ resolves to feature-staging; pushes hit a different S3 prefix than main's pushes
5Three worktrees — main + 2 linked, all on branches with the original .vsyncAll three resolve to the same identity; all three see the same env list in status
6Uncommitted .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)
7Branch switch in main worktree — main worktree on a branch with .vsync, git checkout to a branch without itStatus now shows Source: auto; identity unchanged (origin URL fallback works)
8Cross-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.

#ScenarioAsserts
1Init repo-a and repo-b (different origins) in same workspaceEach has its own config/keychain; no cross-talk in either repo's status
2Push from both repo-a and repo-b to same MinIO bucketTwo distinct prefixes; bucket lists both; neither repo's pull retrieves the other's data
3Name collision via --repo= — init repo-a with --repo=shared, then init repo-b with --repo=sharedSecond init succeeds (overwrites the <shared>/dev config/keychain); status from repo-b shows shared's state. Documents the "operator's responsibility" boundary.
4Status from inside repo-a doesn't list repo-b's envsStatus output scope is per-(repo-identity), not workspace-wide
5Import 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.

ts
// ── 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:

ts
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:

ts
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:

yaml
# 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:15231

Root Taskfile.yml gets one delegating target only:

yaml
# 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:

bash
# 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:integration

Lifecycle guarantees:

  • default uses defer: { task: down } — MinIO is torn down even when tests fail. No dangling containers.
  • up has a status: clause that short-circuits if MinIO is already running, so iterative task up invocations are instant.
  • setup depends on up, so task setup on a clean machine still works (it starts MinIO first).
  • test doesn't run setup automatically — that's intentional. Tests assume buckets exist; running setup per test would slow the iterative loop by ~3s. Run task setup once after up; rerun only after reset.
  • down uses -v to wipe named volumes too (defensive — there shouldn't be any, but cheap insurance).
  • reset is 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. Default bun test skips test/integration/ (added to srcExclude analog in bunfig.toml or via glob in the bun test invocation).
  • 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 config spawn per command is cheap).
  • A vsync identity subcommand to print just the resolved name (use vsync status --json | jq .repo).
  • Auto-detecting upstream as canonical when origin is unset (operator can set origin or write .vsync).
  • Generating .gitignore entries for .vsync (the file is meant to be committed — adding it to .gitignore defeats the purpose).
  • Reading any non-.vsync config from the repo (e.g., a package.json field, a pyproject.toml field). 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 first init after 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 .vsync beyond v and repo. Future fields require bumping v.

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 origin parsing continue to resolve to the same name in v0.16 (algorithm unchanged); their first init after upgrade writes .vsync with that name.
  • Operators on v0.15 whose name came from SECRETS_SYNC_REPO, basename(cwd), or "default" hit RepoIdentityUnresolvedError on 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 the parseRemoteUrl + normalize algorithm; v0.16 reuses both verbatim.
  • v0.10 (runtime-token-cli.md) — vsync runtime-token now 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 read VSYNC_CONFIG + VSYNC_PASSPHRASE and never touch the filesystem or git. The .vsync file 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.

Released under the MIT License.