Skip to content

Commit 26d63fa

Browse files
committed
refactor(lint): use git-changed for --changed detection
1 parent fa4ea31 commit 26d63fa

6 files changed

Lines changed: 43 additions & 100 deletions

File tree

.agents/skills/pgsql-lint/SKILL.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,9 @@ targets, so a CI step is just `pgsql-lint --changed`.
5252
`--changed` diffs against `git merge-base HEAD <base>` (base: explicit →
5353
`$GITHUB_BASE_REF` → the repository's default branch), unions in working-tree and
5454
untracked changes, drops paths that no longer exist, and falls back to
55-
`git diff HEAD` on a shallow/detached checkout. Modelled on pgpm's bundle-drift
56-
check. Nothing changed → exit 0.
55+
working-tree changes only on a shallow/detached checkout. All of that is the
56+
`git-changed` package — `src/changed.ts` is just the `.sql` filter over it, and
57+
pgpm's bundle-drift check uses the same package. Nothing changed → exit 0.
5758

5859
Programmatic entry points (all pure, DB-free):
5960

packages/lint/README.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,11 @@ request's base branch (`$GITHUB_BASE_REF`) and otherwise to the repository's
6262
default branch; the diff is taken against `git merge-base HEAD <base>`, so
6363
commits landed on the base branch afterwards don't widen the set. Uncommitted and
6464
untracked changes are included, deleted/renamed-away paths are dropped, and a
65-
shallow clone or detached checkout (no resolvable merge base) falls back to the
66-
working-tree diff against `HEAD`. Nothing changed is an exit-0 pass.
65+
shallow clone or detached checkout (no resolvable base) falls back to working-tree
66+
changes only. Nothing changed is an exit-0 pass.
67+
68+
Detection is [`git-changed`](https://npmjs.com/package/git-changed), shared with
69+
`pgpm package --check`, so the two agree on what "changed" means.
6770

6871
### Config file
6972

packages/lint/__tests__/changed.test.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -124,9 +124,13 @@ describe('resolveChangedBase', () => {
124124
expect(resolveChangedBase('release/1.0', repo())).toBe('release/1.0');
125125
});
126126

127-
it('uses the PR base branch in CI, unprefixed when no remote has it', () => {
127+
it('ignores a PR base branch that names no ref, rather than returning a broken one', () => {
128+
// Neither `origin/develop` nor `develop` exists in this fixture. Handing back
129+
// `develop` anyway would make every later git call fail and quietly reduce the
130+
// gate to working-tree changes — nothing at all in CI, where the work is
131+
// already committed. The default branch is a real ref, so the gate still runs.
128132
process.env.GITHUB_BASE_REF = 'develop';
129-
expect(resolveChangedBase(undefined, repo())).toBe('develop');
133+
expect(resolveChangedBase(undefined, repo())).toBe('main');
130134
});
131135

132136
it('falls back to the repository default branch', () => {

packages/lint/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
"dependencies": {
3737
"@pgsql/traverse": "workspace:*",
3838
"chalk": "^4.1.0",
39+
"git-changed": "^0.3.0",
3940
"libpg-query": "18.1.4",
4041
"minimist": "1.2.8",
4142
"pgsql-parser": "workspace:*"

packages/lint/src/changed.ts

Lines changed: 16 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -2,44 +2,15 @@
22
* Changed-file detection for `--changed`, so a CI gate (or a human before a
33
* commit) pays only for the SQL that a branch actually touched.
44
*
5-
* Modelled on pgpm's bundle-drift check (`pgpm/core/src/packaging/check.ts` in
6-
* constructive): resolve a base ref (explicit → `origin/$GITHUB_BASE_REF` in a
7-
* PR → the repository's default branch), diff `HEAD` against the **merge base**
8-
* so unrelated commits on the base branch don't widen the set, and union that
9-
* with uncommitted/untracked working-tree changes. Deleted paths are dropped —
10-
* there is nothing left on disk to lint.
5+
* The git plumbing — base resolution, merge-base diff, working-tree union,
6+
* rename targets, dropping paths that no longer exist — lives in `git-changed`,
7+
* which is shared with pgpm's bundle-drift check. This module is the `.sql`
8+
* filter over it, and the place where "no base" stays non-fatal: a lint gate
9+
* that refuses to run on a shallow clone lints nothing, which is worse than
10+
* linting the working tree.
1111
*/
1212

13-
import { execFileSync } from 'child_process';
14-
import { existsSync, statSync } from 'fs';
15-
import * as path from 'path';
16-
17-
function git(args: string[], cwd: string): string {
18-
return execFileSync('git', args, {
19-
cwd,
20-
encoding: 'utf-8',
21-
stdio: ['ignore', 'pipe', 'ignore'],
22-
maxBuffer: 64 * 1024 * 1024
23-
});
24-
}
25-
26-
function tryGit(args: string[], cwd: string): string | null {
27-
try {
28-
return git(args, cwd);
29-
} catch {
30-
return null;
31-
}
32-
}
33-
34-
/** The repository's default branch as a remote-tracking ref, when discoverable. */
35-
function defaultBranch(cwd: string): string | undefined {
36-
const head = tryGit(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], cwd);
37-
if (head && head.trim()) return head.trim();
38-
for (const candidate of ['origin/main', 'origin/master', 'main', 'master']) {
39-
if (tryGit(['rev-parse', '--verify', '--quiet', candidate], cwd)) return candidate;
40-
}
41-
return undefined;
42-
}
13+
import { changedFiles as gitChangedFiles, isRepo, resolveBase } from 'git-changed';
4314

4415
/**
4516
* Resolve the ref to diff against. An explicit `base` wins; otherwise the PR
@@ -48,14 +19,7 @@ function defaultBranch(cwd: string): string | undefined {
4819
* the caller falls back to working-tree changes only.
4920
*/
5021
export function resolveChangedBase(base?: string, cwd: string = process.cwd()): string | undefined {
51-
if (base && base.trim()) return base.trim();
52-
const prBase = process.env.GITHUB_BASE_REF;
53-
if (prBase && prBase.trim()) {
54-
const ref = `origin/${prBase.trim()}`;
55-
if (tryGit(['rev-parse', '--verify', '--quiet', ref], cwd)) return ref;
56-
return prBase.trim();
57-
}
58-
return defaultBranch(cwd);
22+
return resolveBase(base, cwd);
5923
}
6024

6125
export interface ChangedFilesResult {
@@ -67,65 +31,23 @@ export interface ChangedFilesResult {
6731
mergeBase?: string;
6832
}
6933

70-
/** Parse `git status --porcelain` into paths (rename target wins). */
71-
function workingTreePaths(cwd: string): string[] {
72-
const out: string[] = [];
73-
// `-uall` lists untracked *files*; the default collapses a new directory to
74-
// the directory name, which would hide every file a new module adds.
75-
const status = tryGit(['status', '--porcelain', '-uall'], cwd) ?? '';
76-
for (const rawLine of status.split('\n')) {
77-
const line = rawLine.trimEnd();
78-
if (!line) continue;
79-
let p = line.slice(3);
80-
const arrow = p.indexOf(' -> ');
81-
if (arrow !== -1) p = p.slice(arrow + 4);
82-
p = p.replace(/^"|"$/g, '');
83-
if (p) out.push(p);
34+
function collect(cwd: string, base: string | undefined, ext?: string): ChangedFilesResult {
35+
if (!isRepo(cwd)) {
36+
throw new Error(`--changed needs a git repository; ${cwd} is not inside one`);
8437
}
85-
return out;
38+
const result = gitChangedFiles({ cwd, base, ext });
39+
return { files: result.paths, base: result.base, mergeBase: result.mergeBase };
8640
}
8741

8842
/**
8943
* Collect the files that differ from `base` (via `git merge-base`) plus any
90-
* uncommitted/untracked working-tree changes. Falls back to `git diff HEAD`
91-
* when no base is resolvable or no merge base exists — a shallow clone or a
92-
* detached CI checkout — rather than failing the run.
44+
* uncommitted/untracked working-tree changes.
9345
*/
9446
export function changedFiles(options: { cwd?: string; base?: string } = {}): ChangedFilesResult {
95-
const cwd = options.cwd ?? process.cwd();
96-
if (!tryGit(['rev-parse', '--git-dir'], cwd)) {
97-
throw new Error(`--changed needs a git repository; ${cwd} is not inside one`);
98-
}
99-
100-
const files = new Set<string>(workingTreePaths(cwd));
101-
const base = resolveChangedBase(options.base, cwd);
102-
let mergeBase: string | undefined;
103-
104-
if (base) {
105-
const found = tryGit(['merge-base', 'HEAD', base], cwd);
106-
mergeBase = found?.trim() || undefined;
107-
}
108-
// No base, or no common ancestor (shallow clone / detached checkout): the
109-
// uncommitted diff against HEAD is all the history we can see.
110-
const diffArgs = mergeBase
111-
? ['diff', '--name-only', '--diff-filter=ACMR', mergeBase, 'HEAD']
112-
: ['diff', '--name-only', '--diff-filter=ACMR', 'HEAD'];
113-
for (const rawLine of (tryGit(diffArgs, cwd) ?? '').split('\n')) {
114-
const p = rawLine.trim();
115-
if (p) files.add(p);
116-
}
117-
118-
const abs: string[] = [];
119-
for (const rel of files) {
120-
const full = path.resolve(cwd, rel);
121-
// Deleted or renamed-away paths have nothing left to lint.
122-
if (existsSync(full) && statSync(full).isFile()) abs.push(full);
123-
}
124-
return { files: abs.sort(), base, mergeBase };
47+
return collect(options.cwd ?? process.cwd(), options.base);
12548
}
12649

12750
/** {@link changedFiles}, narrowed to `.sql`. */
12851
export function changedSqlFiles(options: { cwd?: string; base?: string } = {}): ChangedFilesResult {
129-
const result = changedFiles(options);
130-
return { ...result, files: result.files.filter((f) => f.toLowerCase().endsWith('.sql')) };
52+
return collect(options.cwd ?? process.cwd(), options.base, '.sql');
13153
}

pnpm-lock.yaml

Lines changed: 12 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)