feat(FN-4309): guard squash commits against gitignored files

- Unstage gitignored paths before squash merge commit creation using git check-ignore
- Re-run the guard on the verification-fix squash restore path to keep ignored artifacts out of final merges
- Add merger tests covering forced .fusion artifacts, spaced ignored paths, and ignored-only squash results
- Document the gitignored-path merge guard and add a published package changeset

Fusion-Task-Id: FN-4309
This commit is contained in:
Fusion
2026-05-13 13:18:08 -07:00
committed by gsxdsm
parent 0244861fa0
commit 039e9a2d02
5 changed files with 210 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Prevented merger squash commits from carrying gitignored files by unstaging ignored paths (including `.fusion/` task artifacts) before writing merge commits.

View File

@@ -229,6 +229,12 @@ After any squash that auto-resolved conflicts, the merger runs the post-squash a
Before those auto-resolved squash commits are written, the merger also runs a per-file diff-volume gate: it compares each file's staged squash delta against the branch's net delta vs its merge-base, and blocks the merge in `in-review` when a non-allowlisted file loses too much branch volume. This is the pre-commit guard against FN-3936-style silent drops where fallback resolution kept a branch's commit message but discarded the branch's main file edits.
### Gitignored-path guard on squash merges
The merger now strips gitignored paths from the staged squash set before writing the merge commit (including verification-fix rebuild paths). Any staged path that matches `.gitignore` (for example `.fusion/`, `.worktrees/`, `.pi/`, `.factory/`, `node_modules/`, `dist/`) is explicitly unstaged and logged.
Agents must **never** bypass `.gitignore` with `git add -f .fusion/...` (or force-add any ignored scratch artifact). Findings, diagnosis, and test-plan notes belong in task documents via `fn_task_document_write`, not committed files.
### File-Scope invariant on squash merges
Every squash commit path now enforces a file-scope invariant immediately before writing the commit: the staged file set must overlap the task's declared `## File Scope` from `PROMPT.md`. The invariant runs on the standard squash path, the Attempt 3 `-X ours/theirs` fallback, and the verification-fix rebuild/finalize path. When the staged files have zero overlap with a non-empty declared scope, the merger throws a structured `FileScopeViolationError`, logs the declared scope + staged files to the merger agent log, resets the pre-squash state, and leaves the task in `in-review` for inspection instead of landing the commit.

View File

@@ -4,6 +4,7 @@
### Patch Changes
- Prevented squash finalization from committing gitignored artifacts by stripping staged ignored paths (for example `.fusion/`, `node_modules/`, and other `git check-ignore` matches) before merge commit creation, including the verification-fix squash-restore path.
- Updated dependencies [681770f]
- @fusion/core@0.28.1
- @fusion/pi-claude-cli@0.28.1

View File

@@ -0,0 +1,109 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { execSync } from "node:child_process";
import { commitOrAmendMergeWithFixes, filterStagedGitignoredPaths } from "../merger.js";
import { mergerLog } from "../logger.js";
import { DEFAULT_SETTINGS } from "@fusion/core";
function git(dir: string, cmd: string): string {
return execSync(cmd, { cwd: dir, stdio: "pipe" }).toString().trim();
}
function initRepo(dir: string): void {
git(dir, "git init -b main");
git(dir, 'git config user.email "test@example.com"');
git(dir, 'git config user.name "Test"');
writeFileSync(join(dir, ".gitignore"), ".fusion/\nnode_modules/\n");
writeFileSync(join(dir, "README.md"), "seed\n");
git(dir, "git add .gitignore README.md");
git(dir, 'git commit -m "chore: init"');
}
const created = new Set<string>();
afterEach(() => {
vi.restoreAllMocks();
for (const dir of created) rmSync(dir, { recursive: true, force: true });
created.clear();
});
function mkRepo(): string {
const dir = mkdtempSync(join(tmpdir(), "fusion-test-gitignored-"));
created.add(dir);
initRepo(dir);
return dir;
}
describe("filterStagedGitignoredPaths", () => {
it("unstages forced .fusion task artifacts", async () => {
const dir = mkRepo();
mkdirSync(join(dir, ".fusion/tasks/FN-1"), { recursive: true });
writeFileSync(join(dir, ".fusion/tasks/FN-1/note.md"), "note\n");
git(dir, "git add -f -- .fusion/tasks/FN-1/note.md");
const result = await filterStagedGitignoredPaths(dir, "FN-4309");
expect(result).toEqual({ unstaged: [".fusion/tasks/FN-1/note.md"], remainingStaged: 0 });
});
it("unstages gitignored paths including spaces and keeps clean paths staged", async () => {
const dir = mkRepo();
const warnSpy = vi.spyOn(mergerLog, "warn").mockImplementation(() => undefined);
mkdirSync(join(dir, ".fusion/tasks/FN-1"), { recursive: true });
mkdirSync(join(dir, "node_modules"), { recursive: true });
writeFileSync(join(dir, ".fusion/tasks/FN-1/a b.md"), "note\n");
writeFileSync(join(dir, "node_modules/foo.js"), "module.exports = 1;\n");
writeFileSync(join(dir, "kept.txt"), "keep\n");
git(dir, 'git add -f -- ".fusion/tasks/FN-1/a b.md" node_modules/foo.js kept.txt');
const result = await filterStagedGitignoredPaths(dir, "FN-4309");
expect(result.unstaged.sort()).toEqual([".fusion/tasks/FN-1/a b.md", "node_modules/foo.js"]);
expect(result.remainingStaged).toBe(1);
expect(git(dir, "git diff --cached --name-only").trim()).toBe("kept.txt");
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('refusing to stage gitignored path ".fusion/tasks/FN-1/a b.md"'));
const second = await filterStagedGitignoredPaths(dir, "FN-4309");
expect(second).toEqual({ unstaged: [], remainingStaged: 1 });
});
it("returns empty result with empty index", async () => {
const dir = mkRepo();
await expect(filterStagedGitignoredPaths(dir, "FN-4309")).resolves.toEqual({ unstaged: [], remainingStaged: 0 });
});
});
describe("commitOrAmendMergeWithFixes gitignored guard", () => {
it("strips already-committed .fusion path from squash and returns no-content when all staged files are ignored", async () => {
const dir = mkRepo();
const preAttemptHeadSha = git(dir, "git rev-parse HEAD");
git(dir, "git checkout -b feat/ignored");
mkdirSync(join(dir, ".fusion/tasks/FN-X"), { recursive: true });
writeFileSync(join(dir, ".fusion/tasks/FN-X/findings.md"), "findings\n");
git(dir, 'git add -f -- .fusion/tasks/FN-X/findings.md');
git(dir, 'git commit -m "feat: ignored artifact"');
git(dir, "git checkout main");
git(dir, "git merge --squash feat/ignored");
const result = await commitOrAmendMergeWithFixes(
dir,
"FN-4309",
"feat/ignored",
"feat(FN-4309): test",
true,
preAttemptHeadSha,
"",
undefined,
{ ...DEFAULT_SETTINGS, commitAuthorEnabled: false },
undefined,
null,
null,
new Set(),
);
expect(result).toEqual({ ok: false, reason: "fix-produced-no-content" });
expect(git(dir, "git diff --cached --name-only").trim()).toBe("");
});
});

View File

@@ -3071,6 +3071,91 @@ async function persistFinalizeResetLeftovers(rootDir: string, taskId: string, st
}
}
const NUL = "\0";
function splitNulDelimited(output: string | Buffer): string[] {
const text = typeof output === "string" ? output : output.toString("utf-8");
return text.split(NUL).filter((entry) => entry.length > 0);
}
async function countStagedPaths(rootDir: string): Promise<number> {
const { stdout } = await execFileAsync("git", ["diff", "--cached", "--name-only", "-z"], {
cwd: rootDir,
encoding: "buffer",
maxBuffer: 10 * 1024 * 1024,
});
return splitNulDelimited(stdout).length;
}
export async function filterStagedGitignoredPaths(
rootDir: string,
taskId: string,
): Promise<{ unstaged: string[]; remainingStaged: number }> {
try {
const { stdout: stagedOut } = await execFileAsync("git", ["diff", "--cached", "--name-only", "-z"], {
cwd: rootDir,
encoding: "buffer",
maxBuffer: 10 * 1024 * 1024,
});
const stagedPaths = splitNulDelimited(stagedOut);
if (stagedPaths.length === 0) {
return { unstaged: [], remainingStaged: 0 };
}
const ignoredPaths: string[] = [];
const batchSize = 200;
for (let i = 0; i < stagedPaths.length; i += batchSize) {
const batch = stagedPaths.slice(i, i + batchSize);
try {
const { stdout } = await execFileAsync("git", ["check-ignore", "--no-index", "--", ...batch], {
cwd: rootDir,
encoding: "utf-8",
maxBuffer: 10 * 1024 * 1024,
});
ignoredPaths.push(
...stdout
.split("\n")
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0),
);
} catch (err: unknown) {
const code = typeof err === "object" && err !== null && "code" in err ? (err as { code?: number }).code : undefined;
if (code === 1) {
continue;
}
const msg = err instanceof Error ? err.message : String(err);
mergerLog.warn(`${taskId}: failed to detect gitignored staged paths in batch: ${msg}`);
}
}
const unstaged: string[] = [];
for (const path of ignoredPaths) {
mergerLog.warn(
`${taskId}: refusing to stage gitignored path "${path}" — unstaging (agents must not bypass .gitignore via \`git add -f\`)`,
);
try {
await execFileAsync("git", ["reset", "HEAD", "--", path], {
cwd: rootDir,
encoding: "utf-8",
maxBuffer: 10 * 1024 * 1024,
});
unstaged.push(path);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
mergerLog.warn(`${taskId}: failed to unstage gitignored path "${path}": ${msg}`);
}
}
const remainingStaged = await countStagedPaths(rootDir).catch(() => stagedPaths.length - unstaged.length);
return { unstaged, remainingStaged };
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
mergerLog.warn(`${taskId}: gitignored-path staging guard failed: ${msg}`);
const remainingStaged = await countStagedPaths(rootDir).catch(() => 0);
return { unstaged: [], remainingStaged };
}
}
export async function commitOrAmendMergeWithFixes(
rootDir: string,
taskId: string,
@@ -3176,6 +3261,8 @@ export async function commitOrAmendMergeWithFixes(
}
}
await filterStagedGitignoredPaths(rootDir, taskId);
const { stdout: finalStaged } = await execAsync("git diff --cached --name-only", {
cwd: rootDir,
encoding: "utf-8",
@@ -3367,6 +3454,8 @@ export async function commitOrAmendMergeWithFixes(
mergerLog.warn(`${taskId}: failed to restore squash state before finalize: ${msg}; stderr=${stderr.trim() || "<empty>"}`);
}
await filterStagedGitignoredPaths(rootDir, taskId);
const { stdout: restoredStagedOut } = await execAsync("git diff --cached --name-only", {
cwd: rootDir,
encoding: "utf-8",