diff --git a/app/git.js b/app/git.js index 54a48ce..243b67c 100644 --- a/app/git.js +++ b/app/git.js @@ -20,6 +20,12 @@ export const SYNC_PATHS = [ 'CLAUDE.md', 'GEMINI.md', ]; +const SYNC_TREE_PATHS = [ + '.codex/skills/triage-findings', + '.claude/skills/triage-findings', + '.gemini/skills/triage-findings', + '.github/skills/triage-findings', +]; function makeRunner(spawn) { return function run(args, cwd, env) { @@ -51,6 +57,26 @@ function readGitOutput(run, args, cwd, env) { } } +function copyTree(sourceRoot, repoDir, relDir) { + const srcDir = path.join(sourceRoot, relDir); + if (!fs.existsSync(srcDir)) return []; + + const copied = []; + for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) { + const relPath = path.join(relDir, entry.name); + const src = path.join(sourceRoot, relPath); + const dest = path.join(repoDir, relPath); + if (entry.isDirectory()) { + copied.push(...copyTree(sourceRoot, repoDir, relPath)); + continue; + } + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.copyFileSync(src, dest); + copied.push(relPath); + } + return copied; +} + export function getRepoState(repoDir, _spawnSync = spawnSync) { const run = makeRunner(_spawnSync); const headSha = readGitOutput(run, ['rev-parse', 'HEAD'], repoDir); @@ -101,21 +127,28 @@ export async function commitAndPush(workspace, repoDir, _spawnSync = spawnSync, run(['reset', '--hard', `origin/${PR_HEAD_BRANCH}`], repoDir); } - const existingSyncPaths = []; + const existingSyncPaths = new Set(); - // Copy action skill files into the target repo. Existing files are overwritten; + // Copy action skill trees into the target repo. Existing files are overwritten; // missing source files are ignored so we do not delete target repo content. + for (const relDir of SYNC_TREE_PATHS) { + for (const relPath of copyTree(sourceRoot, repoDir, relDir)) { + existingSyncPaths.add(relPath); + } + } + + // Copy standalone action files into the target repo. Existing files are overwritten. for (const relPath of SYNC_PATHS) { const src = path.join(sourceRoot, relPath); const dest = path.join(repoDir, relPath); if (fs.existsSync(src)) { fs.mkdirSync(path.dirname(dest), { recursive: true }); fs.copyFileSync(src, dest); - existingSyncPaths.push(relPath); + existingSyncPaths.add(relPath); } } - if (existingSyncPaths.length > 0) { + if (existingSyncPaths.size > 0) { run(['add', ...existingSyncPaths], repoDir); } const generatedSyncPaths = GENERATED_SYNC_PATHS.filter(relPath => fs.existsSync(path.join(workspace, relPath))); diff --git a/app/git.test.js b/app/git.test.js index fd6543d..f4bafee 100644 --- a/app/git.test.js +++ b/app/git.test.js @@ -166,6 +166,21 @@ describe('commitAndPush', () => { assert.equal(fs.readFileSync(path.join(repoDir, 'CLAUDE.md'), 'utf8'), 'CLAUDE.md'); }); + it('recursively overwrites skill tree files from the action source', async () => { + const repoDir = path.join(workspace, 'repo'); + const nestedRelPath = '.codex/skills/triage-findings/assets/example.txt'; + const sourceNestedPath = path.join(sourceRoot, nestedRelPath); + const repoNestedPath = path.join(repoDir, nestedRelPath); + fs.mkdirSync(path.dirname(sourceNestedPath), { recursive: true }); + fs.writeFileSync(sourceNestedPath, 'fresh'); + fs.mkdirSync(path.dirname(repoNestedPath), { recursive: true }); + fs.writeFileSync(repoNestedPath, 'stale'); + + await commitAndPush(workspace, repoDir, makeSpawn(), sourceRoot); + + assert.equal(fs.readFileSync(repoNestedPath, 'utf8'), 'fresh'); + }); + it('does not throw when git command fails', async () => { const failSpawn = () => ({ status: 1, stdout: '', stderr: 'fatal: error', error: null }); await assert.doesNotReject(() => commitAndPush(workspace, path.join(workspace, 'repo'), failSpawn, sourceRoot));