Every repository has a README, so every README target matched

Press · No. 094

Shipped

0.7.0 added a generated version line to each consumer repository’s README, so that a reader landing on the repo can see which release of the shared brand it is on without opening a CI config. Adding those targets exposed a flaw in how targets were selected: selection was by file presence alone, and README.md exists in every repository ever created. The new targets would have selected inside any checkout, including the generator’s own, and been compared against the wrong file.

That is a small bug with a general shape. Any tool that owns files inside repositories it does not control needs an answer to “which of my targets apply here”, and the cheap answer works right up until a target’s path is not distinctive. This guide builds both versions and shows the difference in one command.

Setup: a registry of files you own elsewhere

The registry is a flat list. Each entry names the repository it belongs to, the path inside that repository, and (in a real tool) the emitter that produces the content:

[
  { "id": "site-readme",   "repo": "example.io", "path": "README.md" },
  { "id": "budget-readme", "repo": "budget",     "path": "README.md" },
  { "id": "site-theme",    "repo": "example.io", "path": "src/theme.css" }
]

The tool runs inside one checkout at a time and needs the subset of entries that this checkout is responsible for. The obvious implementation asks the filesystem:

export const targetPath = (target, root) =>
  isAbsolute(target.path) ? target.path : join(root, target.path);

// v1: whatever is on disk is mine.
export const selectByPresence = (targets, root) =>
  targets.filter((t) => existsSync(targetPath(t, root)));

This is fine while every path is distinctive: src/theme.css, astro.config.mjs, pyproject.toml. It stops being fine the first time you add README.md.

Ask the checkout what it is

A git checkout already knows its own name; it is in the remote URL. Read it with git config --get remote.origin.url and reduce it to the repository name:

// select.mjs — which targets this checkout is responsible for.
import { execFileSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import { isAbsolute, join } from 'node:path';

export const targetPath = (target, root) =>
  isAbsolute(target.path) ? target.path : join(root, target.path);

/**
 * The repository a checkout actually is, read from its origin remote.
 *
 * Returns null when there is no remote (a temp dir, an unpacked tarball), in
 * which case callers fall back to file presence.
 */
export function repoIdentity(root) {
  try {
    const url = execFileSync(
      'git',
      ['-C', root, 'config', '--get', 'remote.origin.url'],
      { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] },
    ).trim();
    const m = /([^/:]+?)(?:\.git)?$/.exec(url);
    return m ? m[1] : null;
  } catch {
    return null;
  }
}

export function selectTargets(targets, root) {
  const identity = repoIdentity(root);
  return targets.filter((t) => {
    if (!existsSync(targetPath(t, root))) return false;
    // When the checkout names itself, trust that over a path that happens to
    // exist, otherwise every repo's README matches every README target.
    if (identity) return identity === t.repo;
    return true;
  });
}

Three decisions in there are worth stating.

execFileSync, not exec. Node’s documentation is blunt about the difference: execFile “does not spawn a shell by default”, while for exec it warns “Never pass unsanitized user input to this function. Any input containing shell metacharacters may be used to trigger arbitrary command execution” (child_process). root here is a path from config, which is exactly the kind of value you do not want a shell to interpret.

The regex handles both URL shapes GitHub documents, “An HTTPS URL like https://github.com/user/repo.git” and “An SSH URL, like git@github.com:user/repo.git” (about remote repositories), by taking the last segment after either a slash or a colon and dropping an optional .git.

The catch is required, not defensive padding. Git’s config documentation states that git config --get “Returns error code 1 if the key is not present”, and execFileSync turns a non-zero exit into a thrown error (git-config). A directory with no remote is a normal situation, not an exception.

Use it, then verify it

A driver that prints both answers, so the difference is visible rather than argued:

// run.mjs — print what presence would select, and what identity selects.
import { readFileSync, existsSync } from 'node:fs';
import { selectTargets, repoIdentity, targetPath } from './select.mjs';

const targets = JSON.parse(readFileSync(new URL('./targets.json', import.meta.url), 'utf8'));
const root = process.argv[2];

console.log(`checkout ${root}`);
console.log(`  identity        : ${repoIdentity(root) ?? '(no remote)'}`);
console.log(`  present on disk : ${targets.filter((t) => existsSync(targetPath(t, root))).map((t) => t.id).join(', ') || '(none)'}`);
console.log(`  selected        : ${selectTargets(targets, root).map((t) => t.id).join(', ') || '(none)'}`);

Build three checkouts: two real repositories with remotes, and one directory that is nobody:

mkdir -p repos/example.io repos/budget repos/nowhere
for r in example.io budget; do
  (cd "repos/$r" && git init -q \
    && git remote add origin "git@github.com:demo/$r.git" \
    && echo "# $r" > README.md)
done
mkdir -p repos/example.io/src && echo ":root{}" > repos/example.io/src/theme.css
echo "# scratch" > repos/nowhere/README.md

for r in example.io budget nowhere; do node run.mjs "repos/$r"; done
checkout repos/example.io
  identity        : example.io
  present on disk : site-readme, budget-readme, site-theme
  selected        : site-readme, site-theme
checkout repos/budget
  identity        : budget
  present on disk : site-readme, budget-readme
  selected        : budget-readme
checkout repos/nowhere
  identity        : (no remote)
  present on disk : site-readme, budget-readme
  selected        : site-readme, budget-readme

The present on disk line is the bug, printed. Inside budget, presence claims the other site’s README target belongs there, and a generator acting on that would overwrite budget’s README with the other repository’s content, with a perfectly clean exit code. The selected line is the fix. And nowhere shows the fallback: no remote, so presence decides, which keeps tests in temp directories working.

Gotchas

A selection bug is silent by construction. Selecting the wrong target does not throw; it produces a confident write or a confident “all clean” against a file that was never yours. Symptom: none, until content appears in a repository that never asked for it. Escape: make the tool print what it selected on every run, and diff that list when the registry grows.

A resolver that matches nothing reports success. The opposite failure is worse and more common: tighten the matching, select zero targets, and every check passes over an empty set. Symptom: a green run that takes no time and mentions no files. Escape: assert a floor. If the registry declares nine targets that should resolve in this checkout and the resolver returns two, fail rather than pass.

Repository name alone is not a globally unique identity. A fork, a rename, or two organizations with the same repo name will all satisfy a last-segment match. Symptom: correct behaviour everywhere you tested, wrong behaviour in someone’s fork. Escape: match owner/name when you have it, and keep the bare name only as the fallback.

Shelling out with string interpolation is how a path becomes a command. exec("git -C " + root + " config ...") works fine until a path contains a space or a semicolon. Escape: execFileSync with an argument array, and stdio: ['ignore','pipe','ignore'] so a missing key does not print noise to the user’s terminal.

Do not freeze the release version into a golden file. This release also moved the generator’s golden fixtures to a fixed placeholder version, because a golden that contains the real release number is rewritten by every release, and a drift detector that changes on every release is noise. Symptom: your “nothing changed” test changes on every version bump. Escape: pin the shape with a constant placeholder, and test version substitution separately.

Sources

  • Node.js child_processexecFile does not spawn a shell, and the shell injection warning that applies to exec.
  • git-configremote.<name>.url, and --get returning exit code 1 when the key is absent.
  • About remote repositories — the HTTPS and SSH remote URL forms the parser has to handle.

Changelog

  • feat(press): README version line + propagate to dev AND main (0.7.0) (#133) (45bf2d9)