From bd64198379845de47c0a0ba19bdd9bf0972ba88d Mon Sep 17 00:00:00 2001 From: TacoYakii <1227peter@yonsei.ac.kr> Date: Fri, 3 Apr 2026 13:49:14 +0900 Subject: [PATCH] fix: guard against EISDIR when formatting untracked directories `git ls-files --others` can return directory paths. Without a directory check, `fs.readFileSync` throws EISDIR and crashes the review flow. Add `stat.isDirectory()` guard that delegates back to git to recursively list files inside untracked directories to avoid the EISDIR error, honoring .gitignore rules and avoiding symlink loops. For nested git repos, detect self-referential results and re-run git ls-files from inside the nested repo to include its files. Use lstatSync to detect symlinks. Symlinked directories are skipped to prevent traversal outside the repository boundary. For symlink files, resolve via statSync to enforce the MAX_UNTRACKED_BYTES limit against the actual target size. --- plugins/codex/scripts/lib/git.mjs | 18 +++++++++++++++++- tests/git.test.mjs | 21 +++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/plugins/codex/scripts/lib/git.mjs b/plugins/codex/scripts/lib/git.mjs index 1c0529a..dbf5f38 100644 --- a/plugins/codex/scripts/lib/git.mjs +++ b/plugins/codex/scripts/lib/git.mjs @@ -133,9 +133,25 @@ function formatSection(title, body) { return [`## ${title}`, "", body.trim() ? body.trim() : "(none)", ""].join("\n"); } +function listUntrackedFiles(cwd, relativePath) { + const files = gitChecked(cwd, ["ls-files", "--others", "--exclude-standard", relativePath]).stdout.trim().split("\n").filter(Boolean); + if (files.length === 1 && files[0] === relativePath) { + const absolutePath = path.join(cwd, relativePath); + return gitChecked(absolutePath, ["ls-files", "--others", "--exclude-standard"]).stdout.trim().split("\n").filter(Boolean).map((file) => path.join(relativePath, file)); + } + return files; +} + function formatUntrackedFile(cwd, relativePath) { const absolutePath = path.join(cwd, relativePath); - const stat = fs.statSync(absolutePath); + const lstat = fs.lstatSync(absolutePath); + if (lstat.isSymbolicLink() && fs.statSync(absolutePath).isDirectory()) { + return `### ${relativePath}\n(skipped: symlinked directory)`; + } + if (lstat.isDirectory()) { + return listUntrackedFiles(cwd, relativePath).map((file) => formatUntrackedFile(cwd, file)).join("\n\n"); + } + const stat = lstat.isSymbolicLink() ? fs.statSync(absolutePath) : lstat; if (stat.size > MAX_UNTRACKED_BYTES) { return `### ${relativePath}\n(skipped: ${stat.size} bytes exceeds ${MAX_UNTRACKED_BYTES} byte limit)`; } diff --git a/tests/git.test.mjs b/tests/git.test.mjs index 7ea1a04..372bcb3 100644 --- a/tests/git.test.mjs +++ b/tests/git.test.mjs @@ -55,6 +55,27 @@ test("resolveReviewTarget honors explicit base overrides", () => { assert.equal(target.baseRef, "main"); }); +test("collectReviewContext includes files inside untracked directories", () => { + const cwd = makeTempDir(); + initGitRepo(cwd); + fs.writeFileSync(path.join(cwd, "app.js"), "console.log(1);\n"); + run("git", ["add", "app.js"], { cwd }); + run("git", ["commit", "-m", "init"], { cwd }); + + fs.mkdirSync(path.join(cwd, "newdir", "sub"), { recursive: true }); + fs.writeFileSync(path.join(cwd, "newdir", "a.txt"), "hello a"); + fs.writeFileSync(path.join(cwd, "newdir", "sub", "b.txt"), "hello b"); + + const target = resolveReviewTarget(cwd, {}); + const ctx = collectReviewContext(cwd, target); + + assert.equal(ctx.mode, "working-tree"); + assert.match(ctx.content, /newdir\/a\.txt/); + assert.match(ctx.content, /newdir\/sub\/b\.txt/); + assert.match(ctx.content, /hello a/); + assert.match(ctx.content, /hello b/); +}); + test("resolveReviewTarget requires an explicit base when no default branch can be inferred", () => { const cwd = makeTempDir(); initGitRepo(cwd);