Skip to content

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.x0.9.0
Predominant auto-resolverpackage.json::namebasename(toplevel)remote.origin.url parsed + normalized
package.json::namestep 3 of 5removed entirely
Fallback when no git remotetoplevel basename → cwd basenamebasename(cwd)
Allowed character set[A-Za-z0-9._-][a-z0-9._] (lowercase only, no hyphens)
Length capnonereject > 100 chars
vsync init collision behaviorsilently overwrites existing configaborts: "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 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
https://user:token@github.com/o/r.gito/r (creds stripped)
file:///tmp/upstreamupstream (no owner — bare repo name)
anything unparseablenull (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

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;
}

Example: Best-Practice-Creations/volentis_mono_repobest_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_REPO and --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 or SECRETS_SYNC_REPO value.
  • 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

CaseBehavior
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/webmyorg/web)Resolves to different namespaces (acme_web vs myorg_web) — correct, they're different repos
Monorepo with one git remote, multiple vsync vaultsAll 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 charsThrow with "pass --repo=<short> to override"
git config --get exits non-zero or hangsTreat 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: parseRemoteUrl cases — SSH, HTTPS, no-.git, GitLab subgroup, basic-auth-in-URL, file://, garbage → null (~7 tests)
  • Add: normalize cases — 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 origin and upstream both exist (just use origin).
  • Translating old keychain entries / config files to the new naming scheme (covered by existing error paths).
  • Adding a vsync doctor command to scan for legacy installs (was rejected — collision message is enough).
  • Stripping .github.com/.gitlab.com hostnames into the resolved name (the hostname stays out — owner_repo is enough).
  • Caching the resolved name in the config file (resolver stays pure; one git config spawn per command is cheap).

Released under the MIT License.