abhigyanpatwari/GitNexus · error · Error

Refusing to adopt branch metadata: branch storage target…

Error message

Refusing to adopt branch metadata: branch storage target escapes branches/.

What it means

Branch storage targets must live under .gitnexus/branches/ so `gitnexus clean --branch` can always locate and remove them (containment guard). When the computed branch directory for the branch index falls outside that subtree, adoption is refused rather than creating an unmanageable orphan index.

Solutions

  1. Use a branch name without slashes, '..' or other path-special characters, or sanitize it before invoking the API.
  2. Ensure the storage configuration keeps branches under <repo>/.gitnexus/branches/.
  3. If the branch name legitimately contains '/', adopt with its normalized/sanitized form that the storage layer expects.
  4. Check for symlinked storage directories resolving outside .gitnexus and flatten them.

Example fix

// before
await adoptBranch(repo, "../../evil");
// after
await adoptBranch(repo, "evil"); // sanitized, resolves under .gitnexus/branches/
Defensive patterns

Strategy: validation

Validate before calling

const branchDir = path.resolve(storageRoot, "branches", sanitizeBranchName(branch));
const allowedRoot = path.resolve(storageRoot, "branches") + path.sep;
if (!branchDir.startsWith(allowedRoot)) {
  throw new Error(`branch storage target ${branchDir} escapes branches/`);
}

Try / catch

try {
  await adoptBranchMetadata(repo, branch);
} catch (err) {
  if (/escapes branches\//.test(String(err))) {
    const safe = branch.replace(/[^A-Za-z0-9._-]+/g, "-");
    await adoptBranchMetadata(repo, safe);
  } else throw err;
}

Prevention

When it happens

Trigger: registerRepo branch adoption computes a branchDir from the branch name/storage path that does not resolve to a path under .gitnexus/branches/ — typically due to path-segment injection via a crafted branch name or a misconfigured storage root.

Common situations: Branch names containing path separators or '..' segments reaching the storage-layer naming logic; a custom storage configuration whose branches/ root resolves outside the repo's .gitnexus directory; symlinked storage paths resolving outside the expected subtree.

Understand the failure class

Background: Path traversal blocked: "path escapes the workspace" and "outside site root" errors when a path will not stay inside its allowed directory — this error's family across 26 libraries.

Related errors


AI-assisted analysis of abhigyanpatwari/GitNexus@ac9a4e9abd (2026-09-15). Data as JSON: /api/errors/74b40192f23294c3. Report an issue: GitHub.

Appendix: source

Thrown at gitnexus/src/storage/repo-manager.ts:1310

    } else {
      const probeCode = await fs.access(branchDir).then(
        () => null,
        (e: unknown) => (e as NodeJS.ErrnoException)?.code ?? 'UNKNOWN',
      );
      dirGone = probeCode === 'ENOENT' || probeCode === 'ENOTDIR';
    }
    if (dirGone) {
      // Non-recursive by design: only removes the parent when no other pinned
      // sub-index remains, so an empty branches/ dir doesn't read as "pinned".
      await fs.rmdir(path.join(storagePath, BRANCHES_DIR)).catch(() => {});
    } else {
      logger.warn(
        { path: branchDir, code: rmError?.code },
        'Could not remove the shadowed branch sub-index; keeping its registry summary so `gitnexus clean --branch` can still target it.',
      );
    }
  } else {
    throw new Error('Refusing to adopt branch metadata: branch storage target escapes branches/.');
  }

  // Re-read AFTER the potentially slow recursive rm, and under the lock: the
  // registry is a multi-writer whole-file overwrite, and writing a pre-rm
  // snapshot would silently clobber concurrent registerRepo/removeBranchIndex
  // writers — the #2106 R9 re-read-before-write discipline registerRepo follows.
  await withRegistryLock(async () => {
    const entries = await readRegistry();
    const idx = isRegistered(entries);
    if (idx < 0) return; // unregistered concurrently → still a no-op
    const entry = entries[idx];
    if (!registryPathEquals(canonicalizePath(entry.storagePath), canonicalizePath(storagePath))) {
      return; // a concurrent registration selected a different slot
    }
    const remaining = dirGone ? entry.branches?.filter((b) => b.branch !== branch) : entry.branches;
    const droppedSummary = (entry.branches?.length ?? 0) !== (remaining?.length ?? 0);
    if (entry.branch === branch && !droppedSummary) return; // already coherent
    entry.branch = branch;

View on GitHub (pinned to ac9a4e9abd)