abhigyanpatwari/GitNexus · error

${filename} must not be a hard link

Error message

${filename} must not be a hard link

What it means

The pre-open lstat check in readRepoControlFile rejects files whose link count (nlink) is not exactly 1. Multiple hard links to the same inode mean the file content can be swapped from outside the repository without changing the path, which defeats the integrity assumptions of the control-file reader. The library therefore refuses to read any hard-linked control file.

Source

Thrown at gitnexus/src/config/repo-control-file.ts:23

/** Read a bounded, regular control file owned by the repository root. */
export async function readRepoControlFile(
  repoRoot: string,
  filename: string,
): Promise<string | null> {
  const requestedRoot = path.resolve(repoRoot);
  const requested = path.resolve(requestedRoot, filename);
  const relative = path.relative(requestedRoot, requested);
  if (relative.startsWith('..') || path.isAbsolute(relative)) {
    throw new Error(`${filename} resolves outside the repository root`);
  }

  try {
    const canonicalRoot = fs.realpathSync(requestedRoot);
    const beforeOpen = fs.lstatSync(requested);
    if (beforeOpen.isSymbolicLink()) throw new Error(`${filename} must not be a symbolic link`);
    if (!beforeOpen.isFile()) throw new Error(`${filename} must be a regular file`);
    if (beforeOpen.nlink !== 1) throw new Error(`${filename} must not be a hard link`);
    if (beforeOpen.size > MAX_REPO_CONTROL_FILE_BYTES) {
      throw new Error(`${filename} exceeds ${MAX_REPO_CONTROL_FILE_BYTES} bytes`);
    }
    return await new Promise<string>((resolve, reject) => {
      const stream = fs.createReadStream(requested, {
        flags: 'r',
        start: 0,
        end: MAX_REPO_CONTROL_FILE_BYTES,
        autoClose: true,
      });
      const chunks: Buffer[] = [];
      let totalBytes = 0;
      let validated = false;
      let settled = false;

      const finish = (value: string): void => {
        if (settled) return;
        settled = true;

View on GitHub (pinned to 52924ef12c)

Solutions

  1. Replace the hard-linked file with an independent copy: `cp --remove-destination <file> <file>` or `mv <file> tmp && cp tmp <file> && rm tmp`.
  2. Find the other links with `fs.lstatSync(path).nlink` / `find <dir> -samefile <file>` and delete the extraneous ones.
  3. Avoid hard-link-based backup/sync tools against the repository, or exclude control files from them.
  4. Recreate the file from source control (`git checkout -- <file>`).

Example fix

// before: nlink=2 hard link shared with ~/backup
ln ~/.gitnexusrc ./.gitnexusrc
// after: independent regular file
cp --remove-destination ~/.gitnexusrc ./.gitnexusrc
Defensive patterns

Strategy: validation

Validate before calling

import fs from 'node:fs';
const st = fs.lstatSync(controlFilePath);
if (st.nlink !== 1) {
  console.error(`${controlFilePath} has ${st.nlink} hard links; replace with an independent copy`);
}

Type guard

function hasSingleLink(p: string): boolean {
  try { return fs.lstatSync(p).nlink === 1; } catch { return false; }
}

Try / catch

try {
  await readRepoControlFile(root, filename);
} catch (err) {
  if ((err as Error).message.includes('must not be a hard link')) {
    const tmp = controlFilePath + '.copy';
    fs.copyFileSync(controlFilePath, tmp);
    fs.renameSync(tmp, controlFilePath); // new inode, nlink=1
  } else throw err;
}

Prevention

When it happens

Trigger: The control file at the requested path has been hard-linked elsewhere (nlink > 1) when readRepoControlFile is invoked via loadAnalyzeConfigStrict or content.

Common situations: Backup tools (rsync --link-dest, cp -al, Time Machine-style dedup) hard-link config files; a developer ran `ln` to share a config between repos; a container layer deduplicated files via hard links.

Related errors


AI-assisted analysis of abhigyanpatwari/GitNexus@52924ef12c (2026-09-01). Data as JSON: /api/errors/a395cfad29051bd8. Report an issue: GitHub.