Skip to content

/sync-gbrain: cross-machine source-id collision when two machines use the same absolute repo path #1414

@johnybradshaw

Description

@johnybradshaw

Repro

Two machines, identical home-dir layout (common with chezmoi / synced dotfiles).

Machine A:

cd /home/cloud-user/github/remodspace
/sync-gbrain
# Registers source gstack-code-pace-a753493f-b33352
# with local_path=/home/cloud-user/github/remodspace

Machine B (same shared brain DB, e.g. Aiven Postgres / federated Supabase):

cd /home/cloud-user/github/remodspace   # same path, may not be a git repo here
gbrain sync
# Error: Not a git repository: /home/cloud-user/github/remodspace.
#        GBrain sync requires a git-initialized repo.

Even when the path is a git repo on machine B, the second machine's /sync-gbrain overwrites the source row's local_path to its value — and machine A then errors symmetrically. Last-writer-wins.

Root cause

bin/gstack-gbrain-sync.ts:176-186 (deriveCodeSourceId):

function deriveCodeSourceId(repoPath: string): string {
  const pathHash = createHash(\"sha1\").update(repoPath).digest(\"hex\").slice(0, 8);
  const remote = canonicalizeRemote(originUrl());
  if (remote) {
    const segs = remote.split(\"/\").filter(Boolean);
    const slugSource = segs.slice(-2).join(\"-\");
    return constrainSourceId(\"gstack-code\", \`\${slugSource}-\${pathHash}\`);
  }
  const base = repoPath.split(\"/\").pop() || \"repo\";
  return constrainSourceId(\"gstack-code\", \`\${base}-\${pathHash}\`);
}

pathHash = sha1(repoPath).slice(0, 8) is a function of the absolute filesystem path only. No machine/host identifier. Two machines with identical layouts produce identical source IDs.

The federation model in the v1.29.0.0 changelog ("Conductor worktrees of the same repo coexist as separate sources in the same gbrain DB instead of stomping on each other") works correctly within one machine because Conductor worktrees live at different paths on the same host. It breaks across machines because the path is the same on both.

How the error surfaces

gbrain's source resolver (gbrain/src/core/source-resolver.ts:101-115, step 4) does longest-prefix match against sources.local_path to pick a source for bare gbrain sync. On machine B inside the path-collision directory:

  1. .gbrain-source pin doesn't exist (it's gitignored, so it doesn't travel with the repo)
  2. Step 4 fires: cwd /home/cloud-user/github/remodspace exactly matches the source's local_path
  3. gbrain reads that source's local_path (same path), validates .git exists there
  4. If machine B doesn't have it as a git repo → `Not a git repository` error
  5. Auto-recovery via re-clone (gbrain/src/commands/sync.ts:340-360) is gated on config.remote_url, which /sync-gbrain doesn't store (it registers with --path, not --url)

Impact

  • Identical-layout home dirs (common with chezmoi, ansible, single-user multi-host fleets) silently collide
  • Bare gbrain sync errors out cryptically — users don't know why their working clone isn't recognized
  • Even when it doesn't error, last-machine-to-sync wins for local_path, so the other machine starts pointing at the wrong path
  • Cross-source search results are correct (federation reads pages from the brain by source_id), but only one machine can author against the source at a time

Fix shape

Make the source ID include a host identifier so two machines can't collide on the same path:

import { hostname } from \"os\";

function deriveCodeSourceId(repoPath: string): string {
  // Hash both host and absolute path. Truncated to fit gbrain's 32-char id cap.
  const hostPathKey = \`\${hostname()}::\${repoPath}\`;
  const hostPathHash = createHash(\"sha1\").update(hostPathKey).digest(\"hex\").slice(0, 8);
  // …rest unchanged, with hostPathHash replacing pathHash
}

Open questions for the design:

  • Migration: existing brains have path-only-hashed IDs. New format would create parallel sources rather than reusing the existing one. Either (a) leave legacy ones alone and let them age out, (b) add a one-shot migration that re-keys with \hostname()`-derived` hashes, or (c) make legacy IDs continue to resolve via a fallback rule.
  • Hostname stability: machines do get renamed. A machine ID file (/etc/machine-id on Linux, equivalent elsewhere) is stabler than hostname() but more platform-specific. Worth picking deliberately.
  • Conductor worktrees: still need to coexist on a single host. Including hostname doesn't break this because the per-worktree path differs within a host.
  • Stomping the existing field: the source row's local_path is still single-valued; this fix prevents collisions but doesn't change that semantics. Two machines using different paths for the same logical repo will still get two sources, which is the right behavior.

Alternative path: store remote_url on registration so gbrain's auto-clone-on-missing kicks in. That doesn't fix the collision but masks the most common error path. Doesn't help when machines have legitimately different content at the same path (e.g. one is a feature branch checkout, one is main).

Environment

Happy to test a fix against this setup.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions