No description
  • TypeScript 100%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-05-12 16:26:03 +02:00
examples update 2026-05-12 16:26:03 +02:00
src update 2026-05-12 16:26:03 +02:00
.gitignore init 2026-05-12 14:44:42 +02:00
package.json update 2026-05-12 16:26:03 +02:00
pnpm-lock.yaml init 2026-05-12 14:44:42 +02:00
README.md update 2026-05-12 16:26:03 +02:00
tsconfig.json init 2026-05-12 14:44:42 +02:00

gitmod

Small TypeScript toolkit for mechanical, codemod-style changes across every repo owned by a GitHub org or user — discover repos via GitHub code-search, read/modify/delete files through the git data API, and have a PR opened automatically.

Built on octokit + picomatch. No local clones — everything happens via REST + GraphQL.

When to use this

  • Every Node repo under the owner should exclude a scope from minimum-release-age in .npmrc.
  • Every CI workflow should bump actions/checkout@v3@v4.
  • Every repo that has both Renovate and Dependabot should drop the redundant Dependabot config.

Anything that's the same change applied to many repos and can be expressed as "find files matching X, read them, write the new version".

Setup

pnpm install
gh auth login          # if you haven't already; the owner you target must be authorised

You need gh on PATH and an authenticated session with repo scope (and write access to the owner's repos). Alternatively, set GITHUB_TOKEN and skip gh. The token is read once on startup via gh auth token.

Quick start

import { createGitmod } from './src/gitmod/gitmod.ts';

const gm = await createGitmod({ owner: '0north', dryRun: true });

const repos = await gm.scan({
  search: {
    npmrcs: ['file:**/.npmrc'], // every .npmrc, root or nested
  },
});

await gm.run(repos, async (ctx) => {
  let touched = 0;
  for (const path of ctx.matches.npmrcs) {
    const current = (await ctx.readFile(path)) ?? '';
    const updated = ensureLine(current, 'minimum-release-age-exclude[]=@0north/*');
    if (updated === current) continue;
    ctx.writeFile(path, updated);
    touched++;
  }
  if (touched === 0) return;
  await ctx.apply({
    branch: 'chore/minimum-release-age-exclude-0north',
    title: 'chore: exclude @0north/* from minimum-release-age',
    body: 'Ensures internal packages bypass the release-age policy.',
  });
});

Flip dryRun: false (or pass --apply to the bundled examples) to actually open PRs.

API

createGitmod(options)

type GitmodOptions = {
  owner: string;        // GitHub login of the org or user whose repos to target
  token?: string;       // overrides `gh auth token` / $GITHUB_TOKEN
  dryRun?: boolean;     // if true, ctx.apply reports 'dry-run' instead of writing
};

Returns { scan, run, octokit }. The underlying octokit is exposed for cases a campaign can't express directly (e.g. labelling, querying issues, listing teams).

scan(options)

type ScanOptions<G extends string> = {
  search: Record<G, string[]>;       // group -> queries (DSL strings)
  includeArchived?: boolean;         // default false
  includeForks?: boolean;            // default false
};

type ScannedRepo<G extends string> = {
  owner: string;
  name: string;
  defaultBranch: string;
  matches: Record<G, string[]>;      // declared keys; access returns string[]
};

scan is generic on G — TypeScript infers the union of group names from the literal keys of search, and ScannedRepo.matches becomes a record whose declared properties are string[]. No ?? [] at call sites; typos become compile errors.

Each value in search is a list of DSL queries; queries inside one group are OR'd, and each query is owner-scoped via owner:<login> (which covers both orgs and personal accounts) + run against GitHub code-search. Files that match (after a client-side glob check) land in repo.matches[groupName].

A repo is only returned if at least one path matched in at least one group. The runtime pre-populates every declared group with [], so repo.matches.<group> is always defined.

Keep search inline. TypeScript infers literal keys when the object is passed directly. Hoisting it into a separate const may widen the keys to plain string:

// Good — keys preserved
gm.scan({ search: { npmrcs: ['file:.npmrc'] } });

// Also good — `satisfies` preserves literals
const opts = { search: { npmrcs: ['file:.npmrc'] } } satisfies ScanOptions<'npmrcs'>;
gm.scan(opts);

// Bad — `search.npmrcs` widens to string; matches becomes Record<string, string[]>
const opts = { search: { npmrcs: ['file:.npmrc'] } };
gm.scan(opts);

Query DSL

A query is whitespace-separated tokens of the form key:value. Quote values with "..." if they contain spaces.

qualifier meaning
file: picomatch glob against the repo-relative path. { dot: true }, brace expansion enabled. One per query.
content: token/phrase passed to GitHub's content search. Zero or more per query (AND'd server-side).

Glob examples (standard picomatch):

file:.npmrc                          # root .npmrc only
file:**/.npmrc                       # .npmrc at any depth
file:.github/workflows/*.{yml,yaml}  # GH Actions workflows (flat dir)
file:apps/*/package.json             # monorepo workspaces, one level deep

Combined examples:

file:**/.npmrc content:legacy-key
file:.github/workflows/*.yml content:"actions/checkout@v3"
content:"some-deprecated-import"          # any file containing the token

Internally gitmod extracts the longest literal segment of the glob and uses it as GitHub's path: qualifier, then re-checks the full glob against every returned path. Search false positives are filtered out client-side.

Search caveats

  • 1000-result hard cap per query. GitHub code-search refuses to paginate past 1000 results; gitmod throws with the query and total_count when this happens. Mitigation: add a narrower content: term, split the group into multiple queries, or shard by language.
  • Default branch only. Hits on other branches won't surface.
  • Index lag. Newly committed files may take seconds-to-minutes to be searchable.
  • Path exclusions. Files in vendor/, node_modules/, .git/, generated dirs, and files >384 KB aren't indexed.
  • Code-search rate-limit is 30 req/min on a separate pool from REST/GraphQL. Each paginated 100-result page is one request.
  • No "missing file" queries. Code-search can only tell you what exists. For "every repo without X", express the criterion positively (e.g. search for the file you want to update; let modify check for absence with ctx.readFile(...) === null).

run(repos, modify)

type ModifyFn<G extends string> = (ctx: RepoContext<G>) => Promise<void> | void;

type RepoContext<G extends string> = {
  owner: string;
  name: string;
  defaultBranch: string;
  matches: Record<G, string[]>;                         // same shape as ScannedRepo.matches
  readFile:   (path: string) => Promise<string | null>; // null if missing
  writeFile:  (path: string, content: string) => void;  // create or overwrite
  deleteFile: (path: string) => void;
  apply:      (pr: PrSpec) => Promise<ApplyOutcome>;    // commit + open/update PR
};

type PrSpec = {
  branch: string;
  title: string;
  body: string;
  commitMessage?: string;          // defaults to title
  draft?: boolean;
  labels?: string[];
  onExisting?: 'replace' | 'skip'; // default 'replace'
};

run returns Promise<RunResult[]>. As each repo finishes, the runner prints a status line to stdout (status owner/name [-> prUrl | ! error]); once all repos are done it prints a Summary: { ... } line with counts per status, then resolves with the collected results. G propagates from repos into the modify callback's ctx.matches.

modify runs once per repo. It accumulates pending writes/deletes in memory, then ctx.apply(spec) triggers the commit-and-PR flow. If modify returns without calling apply, the repo is reported as skipped. apply may be called at most once per repo.

If you need to log progress beyond the built-in format — per-file detail, side-channel telemetry, etc. — emit it from inside modify (it runs once per repo, in sequence).

apply resolves to:

type ApplyOutcome = {
  status: 'skipped' | 'unchanged' | 'pr-created' | 'pr-updated' |
          'pr-noop' | 'pr-skipped' | 'dry-run';
  prUrl?: string;
};

The same statuses surface as RunResult.status. Forgetting to await the apply() call is OK — the runner waits for the in-flight promise before yielding the result.

What apply does, step-by-step

  1. Bails if no writes/deletes were recorded → skipped.
  2. If dryRun: truedry-run.
  3. Otherwise creates blobs/trees/commit via the git data API, parented on the current default-branch HEAD.
  4. If the resulting tree matches the default branch's tree → unchanged (nothing committed).
  5. Otherwise looks at the target branch:
    • Doesn't exist → create branch → open PR (pr-created).
    • Exists with the desired tree → don't push; open PR if missing, else pr-noop.
    • Exists with a different tree, no open PR → force-update branch → open PR (pr-created).
    • Exists with a different tree, open PR present:
      • onExisting: 'replace' (default) → force-update branch, refresh PR title/body (pr-updated).
      • onExisting: 'skip' → leave the branch alone (pr-skipped).

This makes campaigns idempotent — re-run as often as you like; only repos that need work get touched.

Result statuses

status meaning
skipped modify never called apply, or apply saw no changes
unchanged Changes resolved to the same tree as default branch
pr-created New PR opened
pr-updated Existing branch force-updated; PR title/body refreshed
pr-noop Existing PR already had the desired tree
pr-skipped Open PR exists and onExisting: 'skip'
dry-run Would have applied; held back because dryRun: true
error An exception was thrown — see result.error

Examples

script what it demonstrates
example:min-release-age Idempotent line-level edit of an existing file (.npmrc); single-file, single-group query.
example:bump-checkout-action Content-driven discovery; multi-file edits per repo; brace-expansion glob in the DSL.
example:dedupe-deps-config Multi-group AND gating ("has both Renovate and Dependabot"); multiple queries per group; deleteFile.

Run with:

pnpm example:min-release-age            # dry-run; prints what would change
pnpm example:min-release-age -- --apply # actually opens / updates PRs

The examples use top-level await directly — "type": "module" + Node 20 means there's no main() wrapper to thread around.

Authoring your own campaign

  1. Copy one of the examples to examples/my-campaign.ts.
  2. Choose search groups that express the criterion as positively as possible — narrow the queries with content: terms to dodge the 1000-result cap.
  3. Pick a stable, descriptive branch name — re-runs of the same campaign reuse it; renaming it starts a new PR.
  4. Write modify. Gate on ctx.matches.<group>, read with ctx.readFile, write with ctx.writeFile / ctx.deleteFile, then await ctx.apply({...}).
  5. Run with dryRun: true first. Inspect the per-repo log. Then --apply.

Notes & limitations

  • Sequential run. Repos are mutated one at a time to keep rate-limit behaviour predictable. Scan is paginated per query and batched for the GraphQL metadata fetch, but the per-repo modify loop is serial. For 100+ repos in a single run you may want a small concurrency wrapper — deliberately not in v1.
  • Force-updates may overwrite human commits on the branch. Each run rebases on defaultBranch (commit parent = base HEAD), so commits pushed to the campaign branch outside this tool will be discarded by onExisting: 'replace'. Use onExisting: 'skip' if reviewers may push tweaks.
  • Default branch is assumed merge target. pr.base is always the repo's default branch.
  • Binary files aren't a special case but writing them via writeFile(path, content) requires content: string; non-UTF8 files are out of scope for now.
  • No "find every repo missing X" mode. Code-search only surfaces files that exist. If you need to act on the absence of something, express your criterion positively (target an adjacent file you know exists) and check absence in modify via ctx.readFile(...) === null.