vsync v0.9.0 — Spec
Status: design · target package @muthuishere/vsync · wire-format compatible with 0.8.x.
One theme: worktrees of the same repo share one config + keychain namespace. Today, getRepoName() resolves to basename(getRepoRoot()) for non-Node repos, which means every git worktree (or every checkout) of the same repo gets its own ~/.config/vsync/<basename>/ and its own keychain entry. The user has to pass --repo=<custom> on every command to share state. v0.9 makes the canonical name derived from git config --get remote.origin.url so all checkouts of the same repo resolve to the same namespace automatically.
1. Diff from 0.8.x
| 0.8.x | 0.9.0 | |
|---|---|---|
| Predominant auto-resolver | package.json::name → basename(toplevel) | remote.origin.url parsed + normalized |
package.json::name | step 3 of 5 | removed entirely |
| Fallback when no git remote | toplevel basename → cwd basename | basename(cwd) |
| Allowed character set | [A-Za-z0-9._-] | [a-z0-9._] (lowercase only, no hyphens) |
| Length cap | none | reject > 100 chars |
vsync init collision behavior | silently overwrites existing config | aborts: "already initialised — pass --repo=<name> to use a different namespace" |
Migration: handled by existing error paths. Users initialised under an old name keep that name in their config file — they hit no resolver code path until they re-init. On re-init they get the new collision message; on pull/push without re-init their ConfigFileMissingError already names the recovery (--repo=<old-name> or vsync import). No silent rerouting.
2. New precedence chain
1. opts.override (--repo=<name> flag)
2. process.env.SECRETS_SYNC_REPO (env override)
3. parseRemoteUrl(git config --get remote.origin.url) ← NEW PREDOMINANT
4. basename(process.cwd())
5. "default"Every step's output flows through normalize() before return. Step 3 is the only step that does URL parsing; the others pass user-supplied or path-derived strings straight to normalize().
package.json::name is gone. It wasn't load-bearing for the worktree case (Go/Rust/infra repos don't have one) and kept around it added a fourth thing for users to keep in sync.
3. parseRemoteUrl — accepted forms
| 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 |
https://user:token@github.com/o/r.git | o/r (creds stripped) |
file:///tmp/upstream | upstream (no owner — bare repo name) |
| anything unparseable | null (caller falls through to step 4) |
Strip .git suffix when present. Strip leading git@host: or scheme://[creds@]host/. Strip basic-auth in URL. Multiple / separators between owner and repo are preserved (GitLab subgroups), then collapsed to _ by normalize.
4. normalize — the one true sanitizer
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;
}Example: Best-Practice-Creations/volentis_mono_repo → best_practice_creations_volentis_mono_repo (43 chars).
Case-insensitive collision: Acme/Web and acme/web lowercase to the same name. This is by design (matches case-insensitive macOS filesystems).
5. Collision detection in vsync init
Before saveConfigFile, bin/init.ts checks existsSync(configFilePath(repo, env)). If a config already exists at the resolved path:
✗ Config already exists at:
~/.config/vsync/<repo>/env_<env>
If this is a different repo that happens to resolve to the same name,
re-run with --repo=<custom-name>:
vsync init <env> --repo=my-custom-name
If this is the repo you initialised before, you don't need to init
again — your existing keychain entry is intact. Run `vsync pull <env>`
to fetch the latest vault.Then process.exit(1). No prompt. No --force. (A user who genuinely wants to re-init can delete the file manually.)
6. What does NOT change
- Wire format, share files, audit log, parser policy — untouched.
- 0.8.x ↔ 0.9.0 bundles are mutually readable.
SECRETS_SYNC_REPOand--repo=overrides keep their existing precedence and behavior.- Existing config files keep their existing
<repo>directory name. The resolver only runs when looking up a config; if an old install has~/.config/vsync/mono/...from a v0.8.x init, that name is still valid as a--repo=override orSECRETS_SYNC_REPOvalue. getRepoRoot()is unchanged — still returns the git toplevel (relevant for worktrees: each has its own toplevel, but that's correct for vault-folder resolution).
7. Edge cases & resolutions
| Case | Behavior |
|---|---|
No git remote (fresh git init, no origin) | Step 3 returns null → fall through to basename(cwd) |
Multiple remotes (origin + upstream) | Use origin always; user can override with --repo= if their canonical is upstream |
Fork-and-rename (acme/web → myorg/web) | Resolves to different namespaces (acme_web vs myorg_web) — correct, they're different repos |
| Monorepo with one git remote, multiple vsync vaults | All worktrees of that repo share one namespace. Use --repo=<sub-product> per vault if separate keychain entries are needed |
GitLab subgroup (group/sub/repo.git) | Resolves to group_sub_repo |
https://github.com/user:token@… (basic-auth in URL) | Credentials stripped before parse |
| Resolved name > 100 chars | Throw with "pass --repo=<short> to override" |
git config --get exits non-zero or hangs | Treat as "no remote" — fall through. Spawn has the same stderr: pipe, stdout: pipe pattern as getRepoRoot() |
8. Tests
Update test/repo.test.ts (currently 7 tests):
- Drop: "package.json::name strips scope" (no longer in the chain)
- Drop: "malformed package.json falls back to git basename" (no longer in the chain)
- Add:
parseRemoteUrlcases — SSH, HTTPS, no-.git, GitLab subgroup, basic-auth-in-URL, file://, garbage → null (~7 tests) - Add:
normalizecases — lowercase, slash→_, hyphen→_, strip-non-alphanumeric, empty input, > 100 chars throws (~6 tests) - Add: precedence — git remote beats cwd; cwd kicks in when no remote (~3 tests)
- Add: end-to-end — feed a mocked git remote via temp git dir +
git remote add, verify resolved name (~2 tests)
New bin/init.ts collision test: pre-create a config file, invoke init, expect non-zero exit and the recovery message.
Target: ~12-14 net tests added; v0.8 count was ~225, v0.9 lands around ~235.
9. Non-goals
- Auto-detecting which remote is canonical when
originandupstreamboth exist (just useorigin). - Translating old keychain entries / config files to the new naming scheme (covered by existing error paths).
- Adding a
vsync doctorcommand to scan for legacy installs (was rejected — collision message is enough). - Stripping
.github.com/.gitlab.comhostnames into the resolved name (the hostname stays out —owner_repois enough). - Caching the resolved name in the config file (resolver stays pure; one
git configspawn per command is cheap).