Files

203 lines
5.7 KiB
JavaScript

import { Buffer } from 'node:buffer';
import { execFileSync } from 'node:child_process';
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { TaskInputError, assertSafeChangedPaths } from './factory-lib.mjs';
const args = parseArgs(process.argv.slice(2));
const workspace = path.resolve(args.workspace);
const patchPath = path.resolve(args.patch);
const summaryPath = path.resolve(args.summary);
const protectedPaths = [
'.github',
'.npmrc',
'.gitmodules',
'config.yml',
'factory-source.json',
];
// Opt-in, for build tasks only (code-agent-task.yml): an application's root
// AGENTS.md and CLAUDE.md belong to @nocobase/app-template-default. A refresh
// replaces them and template-guidance.test.mjs requires them byte-identical, so
// a build's edit is reset here rather than merged and then broken. Other callers
// (framework-fix on nocobase/nocobase, whose root AGENTS.md is its own
// repository rules) keep every edit. Only the root files: client/AGENTS.md and
// other nested guidance stay the application's.
//
// --guidance-source names the commit whose text they are reset to (default
// HEAD). A follow-up build on an existing work branch passes the target branch,
// so a branch that already diverged (an earlier build edited them) carries the
// template text again in its patch instead of keeping the divergence forever.
// They are not added to assertSafeChangedPaths, so patches and work branches
// made before this change still apply.
const TEMPLATE_GUIDANCE = ['AGENTS.md', 'CLAUDE.md'];
const protectGuidance = parseBoolean(args['protect-template-guidance']);
const guidanceSource = args['guidance-source'] || 'HEAD';
restoreProtectedPaths();
if (protectGuidance) restoreTemplateGuidance();
git(['add', '--intent-to-add', '--all']);
const names = splitNull(git(['diff', '--name-only', '-z', 'HEAD']));
if (names.length === 0) {
if (!parseBoolean(args['allow-empty'])) {
throw new TaskInputError('Code Agent 没有产生可提交的文件修改。');
}
writeEmptyPatch();
console.log(
'Created an empty patch after revalidating the existing work branch.',
);
process.exit(0);
}
assertSafeChangedPaths(names);
const patch = execFileSync(
'git',
['diff', '--binary', '--full-index', '--no-ext-diff', 'HEAD', '--'],
{ cwd: workspace, maxBuffer: 100 * 1024 * 1024 },
);
const nameStatus = git(['diff', '--name-status', 'HEAD'])
.trim()
.split('\n')
.filter(Boolean);
const counts = {
added: 0,
modified: 0,
deleted: 0,
renamed: 0,
files: names.length,
};
for (const line of nameStatus) {
const status = line.split('\t')[0];
if (status.startsWith('A')) counts.added += 1;
else if (status.startsWith('D')) counts.deleted += 1;
else if (status.startsWith('R')) counts.renamed += 1;
else counts.modified += 1;
}
mkdirSync(path.dirname(patchPath), { recursive: true });
writeFileSync(patchPath, patch, { mode: 0o600 });
writeFileSync(
summaryPath,
`${JSON.stringify({ counts, files: names }, null, 2)}\n`,
{ mode: 0o600 },
);
console.log(`Created patch with ${names.length} changed file(s).`);
function restoreProtectedPaths() {
const tracked = [
...new Set([
...splitNull(
git([
'ls-tree',
'-r',
'--name-only',
'-z',
'HEAD',
'--',
...protectedPaths,
]),
),
...splitNull(git(['ls-files', '-z', '--', ...protectedPaths])),
]),
];
if (tracked.length > 0) {
execFileSync(
'git',
['restore', '--source=HEAD', '--staged', '--worktree', '--', ...tracked],
{ cwd: workspace, stdio: 'pipe' },
);
}
execFileSync('git', ['clean', '-fdx', '--', ...protectedPaths], {
cwd: workspace,
stdio: 'pipe',
});
}
function restoreTemplateGuidance() {
const source = git([
'rev-parse',
'--verify',
`${guidanceSource}^{commit}`,
]).trim();
for (const file of TEMPLATE_GUIDANCE) {
const spec = `:(top,literal)${file}`;
let present = true;
try {
git(['cat-file', '-e', `${source}:${file}`]);
} catch {
present = false;
}
if (present) {
execFileSync(
'git',
['restore', `--source=${source}`, '--staged', '--worktree', '--', spec],
{ cwd: workspace, stdio: 'pipe' },
);
} else {
// Not part of the source: an agent-created copy is dropped as well.
execFileSync(
'git',
['rm', '-q', '-f', '--ignore-unmatch', '--cached', '--', spec],
{
cwd: workspace,
stdio: 'pipe',
},
);
rmSync(path.join(workspace, file), { force: true });
}
}
}
function writeEmptyPatch() {
mkdirSync(path.dirname(patchPath), { recursive: true });
writeFileSync(patchPath, Buffer.alloc(0), { mode: 0o600 });
writeFileSync(
summaryPath,
`${JSON.stringify(
{
counts: {
added: 0,
modified: 0,
deleted: 0,
renamed: 0,
files: 0,
},
files: [],
reusedExistingWorkBranch: true,
},
null,
2,
)}\n`,
{ mode: 0o600 },
);
}
function git(arguments_) {
return execFileSync('git', arguments_, {
cwd: workspace,
encoding: 'utf8',
maxBuffer: 20 * 1024 * 1024,
});
}
function splitNull(value) {
return value.split('\0').filter(Boolean);
}
function parseArgs(argv) {
const parsed = {};
for (let index = 0; index < argv.length; index += 2) {
parsed[argv[index]?.replace(/^--/, '')] = argv[index + 1];
}
for (const name of ['workspace', 'patch', 'summary']) {
if (!parsed[name]) throw new Error(`Missing --${name}`);
}
return parsed;
}
function parseBoolean(value) {
return ['1', 'true', 'yes', 'on'].includes(String(value ?? '').toLowerCase());
}