Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/codeowners-pattern-scope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@pull-up/cli': patch
---

Preserve CODEOWNERS pattern scope when merging files. Unanchored patterns such as `*.ts` and `docs/` now match at every depth within their source directory, while anchored patterns and root-level rules retain their meaning.
5 changes: 5 additions & 0 deletions .changeset/output-read-errors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@pull-up/cli': patch
---

Report output read failures with their file path instead of treating unreadable files as missing. Check no longer suggests syncing on read errors, and sync stops before overwriting any outputs.
8 changes: 6 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@
"scripts": {
"build": "tsdown",
"test": "vitest run",
"test:unit": "vitest run --project unit",
"test:integration": "vitest run --project integration",
"dev": "tsdown --watch",
"prepack": "yarn build",
"lint": "eslint .",
Expand Down Expand Up @@ -62,8 +64,10 @@
"clipanion": "^4.0.0-rc.4",
"cosmiconfig": "^9.0.0",
"fast-glob": "^3.3.3",
"find-up": "^8.0.0",
"picocolors": "^1.1.1"
"find-up": "^8.0.0"
},
"engines": {
"node": ">=22.8.0"
},
"packageManager": "yarn@4.12.0"
}
9 changes: 5 additions & 4 deletions src/cli/commands/check.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { styleText } from 'node:util';

import { Command, Option } from 'clipanion';
import pc from 'picocolors';

import { resolveConfig } from '../../core/resolve-config';
import { runJob } from '../../core/run-job';
Expand Down Expand Up @@ -27,19 +28,19 @@ export class CheckCommand extends Command {
const [repoRoot, jobs] = await Promise.all([resolveRepositoryRoot(cwd, this.root), resolveConfig(cwd)]);

if (jobs.length === 0) {
console.log(pc.yellow('✘ No jobs found to check'));
console.log(styleText('yellow', '✘ No jobs found to check'));
return;
}

const results = await Promise.all(jobs.map((job) => runJob(job, repoRoot)));

for (const { isSame, jobInfo } of results) {
if (!isSame) {
console.error(pc.red(`✘ ${jobInfo.name} is outdated. Run 'pullup sync' to update.`));
console.error(styleText('red', `✘ ${jobInfo.name} is outdated. Run 'pullup sync' to update.`));
process.exit(1);
}
}

console.log(pc.green('✔ All files are up to date'));
console.log(styleText('green', '✔ All files are up to date'));
}
}
16 changes: 8 additions & 8 deletions src/cli/commands/sync.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { styleText } from 'node:util';

import { Command, Option } from 'clipanion';
import pc from 'picocolors';

import { resolveConfig } from '../../core/resolve-config';
import { runJob } from '../../core/run-job';
Expand Down Expand Up @@ -36,21 +36,21 @@ export class SyncCommand extends Command {
const jobs = await resolveConfig(cwd);

if (jobs.length === 0) {
console.log(pc.yellow('✘ No jobs found to sync'));
console.log(styleText('yellow', '✘ No jobs found to sync'));
return;
}

const results = await Promise.all(jobs.map((job) => runJob(job, repoRoot)));

for (const { jobInfo, generated, isSame } of results) {
if (this.dryRun === true) {
console.log(pc.cyan(`┌─ [Job] ${jobInfo.name}`));
console.log(`${pc.cyan('│')} ${pc.dim(`Output: ${jobInfo.output}`)}`);
console.log(`${pc.cyan('│')}`);
console.log(styleText('cyan', `┌─ [Job] ${jobInfo.name}`));
console.log(`${styleText('cyan', '│')} ${styleText('dim', `Output: ${jobInfo.output}`)}`);
console.log(`${styleText('cyan', '│')}`);
generated.contents.split('\n').forEach((line) => {
console.log(`${pc.cyan('│')} ${line}`);
console.log(`${styleText('cyan', '│')} ${line}`);
});
console.log(`${pc.cyan('└─')}`);
console.log(`${styleText('cyan', '└─')}`);
continue;
}

Expand All @@ -61,7 +61,7 @@ export class SyncCommand extends Command {
await fs.writeFile(outputPath, generated.contents);
}

console.log(pc.green(`✔ ${jobInfo.name} synced`));
console.log(styleText('green', `✔ ${jobInfo.name} synced`));
}
}
}
39 changes: 37 additions & 2 deletions src/core/jobs/codeowners/codeowners-job.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,41 @@ async function readFixtureFiles(rootDir: string, filePaths: string[]) {
}

describe('codeownersJob', () => {
it.each([
['*.ts', '/packages/web/**/*.ts'],
['/*.ts', '/packages/web/*.ts'],
['docs/', '/packages/web/**/docs/'],
['docs', '/packages/web/**/docs'],
['/docs/', '/packages/web/docs/'],
['src/*.ts', '/packages/web/src/*.ts'],
['/src/*.ts', '/packages/web/src/*.ts'],
['src/docs/', '/packages/web/src/docs/'],
['**/docs/', '/packages/web/**/docs/'],
['src/**/index.ts', '/packages/web/src/**/index.ts'],
['*', '/packages/web/'],
])('preserves the scope of %s when relocating a nested CODEOWNERS file', async (pattern, expected) => {
const rootDir = process.cwd();
const result = await codeownersJob().transform(
[{ path: 'packages/web/CODEOWNERS', contents: `${pattern} @frontend-team\n` }],
{ rootDir, outputPath: path.join(rootDir, '.github/CODEOWNERS') },
);

expect(result).toBe(`${expected} @frontend-team\n`);
});

it.each(['*', '*.ts', '/*.ts', 'docs/', '/docs/', 'src/*.ts', '**/docs/'])(
'preserves the root-level pattern %s verbatim',
async (pattern) => {
const rootDir = process.cwd();
const result = await codeownersJob().transform([{ path: 'CODEOWNERS', contents: `${pattern} @root-team\n` }], {
rootDir,
outputPath: path.join(rootDir, '.github/CODEOWNERS'),
});

expect(result).toBe(`${pattern} @root-team\n`);
},
);

it('merges parent CODEOWNERS before nested files', async () => {
await using fixture = await Fixture.fromDirectory(fixtureDirectory);
const rootDir = fixture.root;
Expand Down Expand Up @@ -57,7 +92,7 @@ describe('codeownersJob', () => {
});

expect(result).toBe(
'/ @root-team\n' +
'* @root-team\n' +
'/docs/ @docs-team\n' +
'/services/auth/ @auth-team\n' +
'/services/auth/login/ @login-team\n' +
Expand Down Expand Up @@ -106,7 +141,7 @@ describe('codeownersJob', () => {
});

expect(result).toBe(
'/ @root-team\n' +
'* @root-team\n' +
'/docs/ @docs-team\n' +
'/services/auth/ @auth-team\n' +
'/services/auth/login/ @login-team\n' +
Expand Down
10 changes: 8 additions & 2 deletions src/core/jobs/codeowners/codeowners-job.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,14 @@ const sortByDirectory = (inputFiles: Source[], rootDir: string) =>
});

const toAbsolutePattern = (pattern: string, baseDir: string) => {
const base = baseDir !== '' ? `/${baseDir}` : '';
return pattern === '*' ? `${base}/` : `${base}/${stripLeadingSlash(pattern)}`;
if (baseDir === '') return pattern;

const base = `/${baseDir}`;
Comment thread
ho991217 marked this conversation as resolved.
if (pattern === '*') return `${base}/`;

// Only a leading or interior slash anchors a pattern; a trailing slash marks a directory.
const isAnchored = pattern.startsWith('/') || pattern.replace(/\/$/, '').includes('/');
return isAnchored ? `${base}/${stripLeadingSlash(pattern)}` : `${base}/**/${pattern}`;
};

const stripLeadingSlash = (text: string) => text.replace(/^\//, '');
97 changes: 97 additions & 0 deletions src/core/jobs/codeowners/pattern-scope.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { spawnSync } from 'node:child_process';
import { mkdir, rm, writeFile } from 'node:fs/promises';
import { devNull } from 'node:os';
import path from 'node:path';

import { Fixture } from '@fixture-kit/core';

import { codeownersJob } from './codeowners-job';

const sourceDir = 'packages/web';
const sourceFiles = [
'index.ts',
'index.js',
'src/index.ts',
'src/nested/index.ts',
'lib/src/index.ts',
'docs/readme.md',
'docs/deep/readme.md',
'src/docs/readme.md',
'src/docs/deep/readme.md',
'file-only/docs',
];
const outsideFiles = [
'index.ts',
'docs/readme.md',
'packages/mobile/src/index.ts',
'packages/web-other/docs/readme.md',
];
const allFiles = [...sourceFiles.map((file) => `${sourceDir}/${file}`), ...outsideFiles];

function matchedFiles(rootDir: string) {
const result = spawnSync(
'git',
[
'-c',
`core.excludesFile=${devNull}`,
'-c',
'core.ignoreCase=false',
'check-ignore',
'--no-index',
'--',
...allFiles,
],
{ cwd: rootDir, encoding: 'utf8' },
);
expect(result.error).toBeUndefined();
expect([0, 1]).toContain(result.status);
return result.stdout.trim().split('\n').filter(Boolean).sort();
}

// CODEOWNERS follows gitignore's pattern scoping for these supported patterns.
// Compare relocation against Git itself rather than a copy of the rewrite logic.
// Negation, character ranges, and Git's excluded-directory precedence are not tested here.
describe('CODEOWNERS pattern scope', () => {
it.each([
'*.ts',
'/*.ts',
'docs/',
'/docs/',
'docs',
'src/*.ts',
'/src/*.ts',
'src/docs/',
'**/docs/',
'src/**/index.ts',
'*',
])('preserves files matched by %s without escaping the source subtree', async (pattern) => {
await using fixture = await Fixture.create({});
const rootDir = fixture.root;
const init = spawnSync('git', ['init', '--quiet', '--template='], { cwd: rootDir, encoding: 'utf8' });
expect(init.error).toBeUndefined();
expect(init.status).toBe(0);
await Promise.all(
allFiles.map(async (file) => {
const filePath = path.join(rootDir, file);
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(filePath, '');
}),
);

const originalIgnore = path.join(rootDir, sourceDir, '.gitignore');
await writeFile(originalIgnore, `${pattern}\n`);
const originalMatches = matchedFiles(rootDir);
expect(originalMatches.length).toBeGreaterThan(0);
await rm(originalIgnore);

const result = await codeownersJob().transform(
[{ path: `${sourceDir}/CODEOWNERS`, contents: `${pattern} @frontend-team\n` }],
{ rootDir, outputPath: path.join(rootDir, '.github/CODEOWNERS') },
);
await writeFile(path.join(rootDir, '.gitignore'), `${result.trim().split(' ')[0]}\n`);
const relocatedMatches = matchedFiles(rootDir);

expect(relocatedMatches).toEqual(originalMatches);
expect(relocatedMatches.every((file) => file.startsWith(`${sourceDir}/`))).toBe(true);
});
});
41 changes: 41 additions & 0 deletions src/core/utils.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import fs from 'node:fs/promises';

import { readFileOrNull } from './utils';

describe('readFileOrNull', () => {
const outputPath = '/repository/.github/CODEOWNERS';

afterEach(() => {
vi.restoreAllMocks();
});

it('returns existing file contents, including an empty file', async () => {
const readFile = vi.spyOn(fs, 'readFile').mockResolvedValueOnce('contents').mockResolvedValueOnce('');

expect(await readFileOrNull(outputPath)).toBe('contents');
expect(await readFileOrNull(outputPath)).toBe('');
expect(readFile).toHaveBeenCalledWith(outputPath, 'utf-8');
});

it('returns null for a missing file', async () => {
vi.spyOn(fs, 'readFile').mockRejectedValue(Object.assign(new Error('No such file'), { code: 'ENOENT' }));

expect(await readFileOrNull(outputPath)).toBeNull();
});

it.each(['EISDIR', 'EACCES', 'EIO'])('propagates %s with the output path', async (code) => {
const error = Object.assign(new Error(`${code}: read failed`), { code, syscall: 'read' });
vi.spyOn(fs, 'readFile').mockRejectedValue(error);

await expect(readFileOrNull(outputPath)).rejects.toMatchObject({
cause: error,
message: expect.stringContaining(outputPath),
});
});

it('does not hide unexpected read failures', async () => {
vi.spyOn(fs, 'readFile').mockRejectedValue(new Error('Unexpected read failure'));

await expect(readFileOrNull(outputPath)).rejects.toThrow('Unexpected read failure');
});
});
9 changes: 7 additions & 2 deletions src/core/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@ import fs from 'node:fs/promises';
export async function readFileOrNull(absPath: string): Promise<string | null> {
try {
return await fs.readFile(absPath, 'utf-8');
} catch {
return null;
} catch (error) {
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
return null;
}

const message = error instanceof Error ? error.message : String(error);
throw new Error(`Failed to read file "${absPath}": ${message}`, { cause: error });
}
}
9 changes: 9 additions & 0 deletions tests/build-cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { execFile } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { promisify } from 'node:util';

export default async function buildCli() {
await promisify(execFile)('yarn', ['build'], {
cwd: fileURLToPath(new URL('../', import.meta.url)),
});
}
Loading
Loading