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:
5
.changeset/fn-4309-gitignored-path-guard.md
Normal file
5
.changeset/fn-4309-gitignored-path-guard.md
Normal 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.
|
||||||
@@ -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.
|
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
|
### 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.
|
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.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
### Patch Changes
|
### 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]
|
- Updated dependencies [681770f]
|
||||||
- @fusion/core@0.28.1
|
- @fusion/core@0.28.1
|
||||||
- @fusion/pi-claude-cli@0.28.1
|
- @fusion/pi-claude-cli@0.28.1
|
||||||
|
|||||||
@@ -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("");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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(
|
export async function commitOrAmendMergeWithFixes(
|
||||||
rootDir: string,
|
rootDir: string,
|
||||||
taskId: 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", {
|
const { stdout: finalStaged } = await execAsync("git diff --cached --name-only", {
|
||||||
cwd: rootDir,
|
cwd: rootDir,
|
||||||
encoding: "utf-8",
|
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>"}`);
|
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", {
|
const { stdout: restoredStagedOut } = await execAsync("git diff --cached --name-only", {
|
||||||
cwd: rootDir,
|
cwd: rootDir,
|
||||||
encoding: "utf-8",
|
encoding: "utf-8",
|
||||||
|
|||||||
Reference in New Issue
Block a user