Skip to content

feat(git-changed): merge-base changed-file detection as a package - #101

Merged
pyramation merged 2 commits into
mainfrom
feat/git-changed
Aug 3, 2026
Merged

feat(git-changed): merge-base changed-file detection as a package#101
pyramation merged 2 commits into
mainfrom
feat/git-changed

Conversation

@pyramation

Copy link
Copy Markdown
Contributor

Summary

New unscoped package git-changed: the git plumbing for "what did I change" — merge-base diff ∪ working tree ∪ untracked — as a library plus a bin, so tools stop re-implementing it. Zero dependencies (deliberate: @pgsql/lint is a consumer and currently has none).

We have three copies of this today, and they disagree in ways that are bugs, not style:

pgpm packaging/check.ts @pgsql/lint changed.ts this
untracked status --porcelain -uall -uall
base resolution origin/$GITHUB_BASE_REF or nothing + default-branch discovery + verifies the ref exists
diff base...HEAD explicit merge-base explicit merge-base, -M
deleted paths kept dropped dropped (opt back in)
no base working tree working tree + diff HEAD working tree, reported as source
renames add + delete destination destination, with from

The -uall row is the one that bites: without it git status reports a brand-new directory as a single entry, so every file in a new pgpm module is invisible to the drift check locally. And the base-existence check matters because $GITHUB_BASE_REF is set on every PR, but origin/<ref> only exists if it was fetched — the other two hand back a ref that every later git call rejects.

Shape

changedFiles({ ext: '.sql', exclude: ['**/generated/**'] })
// → { files, paths, base, mergeBase, source, repoRoot }

Three things make it usable as tooling glue rather than a git wrapper:

Status is preserved, not flattened. { path, relative, status, from, committed, worktree, exists } — a caller can tell an added file from a rename destination, and committed/worktree are independent flags because one file can be both.

Degradation is reported, not hidden. source is 'merge-base' | 'base' | 'worktree', so a gate can decide for itself:

if (process.env.CI && source === 'worktree') throw new Error('no base ref — is fetch-depth: 0 set?');

Nothing throws for a missing base — a gate that fails open on a detached checkout checks nothing, which is worse than checking the working tree. Only unusable git or a non-repo cwd raises GitChangedError.

Filtering is built in, since every consumer wants it: ext ('sql', '.SQL', '.ts,.tsx', ['.ts'] all normalize), include/exclude globs, within dirs, existingOnly. Plus GitChanged, for tools asking several questions of one repo, resolving the root and base once:

const changed = new GitChanged({ cwd, exclude: ['dist/'] });
changed.paths({ ext: '.sql' });          // constructor opts are defaults,
changed.paths({ ext: ['.ts', '.tsx'] }); // per-call opts override key by key

CLI

git-changed --ext .sql --status
git-changed --ext .ts --null | xargs -0 -r prettier --check
git-changed --base origin/develop --json

--null writes no trailing newline (it would become part of the last filename under xargs -0), and the exit code is 0 whether or not anything changed — an empty list is an answer, documented because git-changed && … reads like it should mean something else.

Two deviations from repo convention worth calling out:

  • No inquirerer, and no separate -cli package. This bin is a non-interactive printer in the git diff --name-only mould — it has no prompts, and its whole point is being pipeable. Adding a prompt library would put dependencies into a package meant to be depended on by libraries. Argument parsing is ~80 lines and handles --flag value, --flag=value, and repeatable/comma-separated lists.
  • git is invoked with an argv array, never a shell string (spawnSync('git', ['merge-base', 'HEAD', base])), unlike the git(\diff ${base}...HEAD`)string interpolation in the implementations this replaces. A branch namedfeat/it's-fine` is then just a string, not a quoting bug.

Tests

37 tests, each against a real repo built in a tmpdir — merge base vs base tip (a commit landing on main after the fork must not be attributed to the branch), -uall on a new directory, rename destination + from, deleted paths in and out, $GITHUB_BASE_REF both fetched and not, the genuinely baseless repo, git-quoted paths (we ird/na"me.sql), subdirectory cwd (git speaks repo-root paths; results are relative to cwd, ../ and all), each filter, and the CLI's output modes and exit codes.

Follow-ups (not here)

@pgsql/lint keeps changedSqlFiles as a 3-line filter over this; pgpm's check.ts drops ~35 lines and picks up both fixes. Both are separate PRs in their own repos, after this publishes — nothing depends on it yet.

Link to Devin session: https://app.devin.ai/sessions/e84444b40007481e9de0285d8b5340b6
Requested by: @pyramation

@pyramation pyramation self-assigned this Aug 3, 2026
@devin-ai-integration

Copy link
Copy Markdown

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@pyramation
pyramation merged commit 34f7aa0 into main Aug 3, 2026
55 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant