- TypeScript 100%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| examples | ||
| src | ||
| .gitignore | ||
| package.json | ||
| pnpm-lock.yaml | ||
| README.md | ||
| tsconfig.json | ||
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-agein.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
modifycheck for absence withctx.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
- Bails if no writes/deletes were recorded →
skipped. - If
dryRun: true→dry-run. - Otherwise creates blobs/trees/commit via the git data API, parented on the current default-branch HEAD.
- If the resulting tree matches the default branch's tree →
unchanged(nothing committed). - 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).
- Doesn't exist → create branch → open PR (
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
- Copy one of the examples to
examples/my-campaign.ts. - Choose
searchgroups that express the criterion as positively as possible — narrow the queries withcontent:terms to dodge the 1000-result cap. - Pick a stable, descriptive
branchname — re-runs of the same campaign reuse it; renaming it starts a new PR. - Write
modify. Gate onctx.matches.<group>, read withctx.readFile, write withctx.writeFile/ctx.deleteFile, thenawait ctx.apply({...}). - Run with
dryRun: truefirst. 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 byonExisting: 'replace'. UseonExisting: 'skip'if reviewers may push tweaks. - Default branch is assumed merge target.
pr.baseis always the repo's default branch. - Binary files aren't a special case but writing them via
writeFile(path, content)requirescontent: 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
modifyviactx.readFile(...) === null.