diff --git a/plugins/flow/mcp-server/dist/index.js b/plugins/flow/mcp-server/dist/index.js index e1d837bf..60ae97ba 100644 --- a/plugins/flow/mcp-server/dist/index.js +++ b/plugins/flow/mcp-server/dist/index.js @@ -53475,13 +53475,38 @@ adapter_config: {} }); created.push("docs/standards.md"); } + let claudeIgnored = false; + const gitignorePath = path54.join(root, ".gitignore"); + const CLAUDE_IGNORE_RULE = ".claude/"; + const existingGitignore = await pathExists(gitignorePath); + const currentContent = existingGitignore ? await readFile2(gitignorePath, "utf8") : ""; + const alreadyIgnored = currentContent.split("\n").some((line) => line.trim() === CLAUDE_IGNORE_RULE); + if (alreadyIgnored) { + skipped.push(".gitignore (.claude/ already present)"); + } else { + const separator = currentContent.length > 0 && !currentContent.endsWith("\n") ? "\n" : ""; + const newContent = currentContent + separator + CLAUDE_IGNORE_RULE + "\n"; + await writeManagedFile({ + absPath: gitignorePath, + contents: newContent, + targetRepoRoot: root, + mcpToolContext: args.mcpToolContext + }); + if (existingGitignore) { + created.push(".gitignore (appended .claude/)"); + } else { + created.push(".gitignore"); + } + claudeIgnored = true; + } return { adapter, created, skipped, gitPresent: await pathExists(path54.join(root, ".git")), teamPresent: await pathExists(path54.join(root, "team")), - configPath + configPath, + claudeIgnored }; } function renderInitWorkspace(result) { diff --git a/plugins/flow/mcp-server/src/tools/__tests__/init-workspace.test.ts b/plugins/flow/mcp-server/src/tools/__tests__/init-workspace.test.ts index 0fe9dbfa..b450c447 100644 --- a/plugins/flow/mcp-server/src/tools/__tests__/init-workspace.test.ts +++ b/plugins/flow/mcp-server/src/tools/__tests__/init-workspace.test.ts @@ -7,7 +7,7 @@ * the bmad variant omits the native-stories dir. */ import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { mkdtemp, rm, readFile, access, mkdir } from "node:fs/promises"; +import { mkdtemp, rm, readFile, access, mkdir, writeFile } from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; @@ -130,6 +130,66 @@ describe("initWorkspace", () => { expect(result.gitPresent).toBe(true); expect(result.teamPresent).toBe(true); }); + + // AC1 — git-ignore .claude/ (append-safe, no clobber) + it("creates .gitignore with .claude/ rule when no .gitignore exists", async () => { + const result = await initWorkspace({ + targetRepoRoot: root, + pluginRoot: PLUGIN_ROOT, + mcpToolContext: CTX, + }); + + const gitignoreContent = await readFile(path.join(root, ".gitignore"), "utf8"); + expect(gitignoreContent).toContain(".claude/"); + expect(result.claudeIgnored).toBe(true); + expect(result.created.some((c) => c.includes(".gitignore"))).toBe(true); + }); + + it("appends .claude/ rule to an existing .gitignore without clobbering other rules", async () => { + // Pre-seed a .gitignore with existing rules + await writeFile( + path.join(root, ".gitignore"), + "node_modules/\ndist/\n", + "utf8", + ); + + const result = await initWorkspace({ + targetRepoRoot: root, + pluginRoot: PLUGIN_ROOT, + mcpToolContext: CTX, + }); + + const gitignoreContent = await readFile(path.join(root, ".gitignore"), "utf8"); + expect(gitignoreContent).toContain("node_modules/"); + expect(gitignoreContent).toContain("dist/"); + expect(gitignoreContent).toContain(".claude/"); + expect(result.claudeIgnored).toBe(true); + }); + + // AC2 — idempotent: no duplicate when .claude/ already present + it("does not duplicate .claude/ rule when .gitignore already contains it", async () => { + // Pre-seed a .gitignore that already has the .claude/ rule + await writeFile( + path.join(root, ".gitignore"), + "node_modules/\n.claude/\n", + "utf8", + ); + + const result = await initWorkspace({ + targetRepoRoot: root, + pluginRoot: PLUGIN_ROOT, + mcpToolContext: CTX, + }); + + const gitignoreContent = await readFile(path.join(root, ".gitignore"), "utf8"); + // Count occurrences — exactly one + const occurrences = gitignoreContent.split("\n").filter((l) => l.trim() === ".claude/").length; + expect(occurrences).toBe(1); + // Original content still present + expect(gitignoreContent).toContain("node_modules/"); + expect(result.claudeIgnored).toBe(false); + expect(result.skipped.some((s) => s.includes(".gitignore"))).toBe(true); + }); }); describe("renderInitWorkspace", () => { @@ -141,6 +201,7 @@ describe("renderInitWorkspace", () => { gitPresent: false, teamPresent: false, configPath: "/repo/.flow/config.yaml", + claudeIgnored: true, }); expect(out).toContain("Flow workspace initialised (adapter: native)."); @@ -158,6 +219,7 @@ describe("renderInitWorkspace", () => { gitPresent: true, teamPresent: true, configPath: "/repo/.flow/config.yaml", + claudeIgnored: false, }); expect(out).toContain("already initialised"); diff --git a/plugins/flow/mcp-server/src/tools/init-workspace.ts b/plugins/flow/mcp-server/src/tools/init-workspace.ts index b663c177..6563cba8 100644 --- a/plugins/flow/mcp-server/src/tools/init-workspace.ts +++ b/plugins/flow/mcp-server/src/tools/init-workspace.ts @@ -47,6 +47,8 @@ export interface InitWorkspaceResult { gitPresent: boolean; teamPresent: boolean; configPath: string; + /** True when .gitignore was created or updated to include .claude/. */ + claudeIgnored: boolean; } async function pathExists(p: string): Promise { @@ -129,6 +131,49 @@ export async function initWorkspace( created.push("docs/standards.md"); } + // 5. .gitignore — ensure .claude/ is ignored so the runtime's per-story + // worktrees (.claude/worktrees/) and agent scratch never show up as + // untracked noise in `git status`. Append-safe: read first, only write + // when the rule is absent, preserve existing rules. Idempotent: a + // second init is a no-op when the rule is already present. + let claudeIgnored = false; + const gitignorePath = path.join(root, ".gitignore"); + const CLAUDE_IGNORE_RULE = ".claude/"; + const existingGitignore = await pathExists(gitignorePath); + const currentContent = existingGitignore + ? await readFile(gitignorePath, "utf8") + : ""; + + // Check whether any line in .gitignore already ignores .claude/ — exact + // match on the rule (with or without leading/trailing whitespace on that + // line, but the canonical form is `.claude/`). + const alreadyIgnored = currentContent + .split("\n") + .some((line) => line.trim() === CLAUDE_IGNORE_RULE); + + if (alreadyIgnored) { + skipped.push(".gitignore (.claude/ already present)"); + } else { + // Append: ensure the file ends with a newline before appending the rule, + // then add a trailing newline after it. + const separator = + currentContent.length > 0 && !currentContent.endsWith("\n") ? "\n" : ""; + const newContent = + currentContent + separator + CLAUDE_IGNORE_RULE + "\n"; + await writeManagedFile({ + absPath: gitignorePath, + contents: newContent, + targetRepoRoot: root, + mcpToolContext: args.mcpToolContext, + }); + if (existingGitignore) { + created.push(".gitignore (appended .claude/)"); + } else { + created.push(".gitignore"); + } + claudeIgnored = true; + } + return { adapter, created, @@ -136,6 +181,7 @@ export async function initWorkspace( gitPresent: await pathExists(path.join(root, ".git")), teamPresent: await pathExists(path.join(root, "team")), configPath, + claudeIgnored, }; } diff --git a/plugins/flow/mcp-server/tests/canonical-fs-guard.test.ts b/plugins/flow/mcp-server/tests/canonical-fs-guard.test.ts index ed7c46d5..6ed6eb24 100644 --- a/plugins/flow/mcp-server/tests/canonical-fs-guard.test.ts +++ b/plugins/flow/mcp-server/tests/canonical-fs-guard.test.ts @@ -162,6 +162,12 @@ const FS_WRITE_WHITELIST = new Set([ // production guardCleanRoot tool orchestrates purely over lib/git.ts helpers and // performs no direct fs writes. path.join(SRC_DIR, "tools", "__tests__", "guard-clean-root.test.ts"), + // Story native:01KVS11W: init-workspace tests for AC1 (append-safe gitignore) and + // AC2 (idempotent no-dup) seed a pre-existing .gitignore fixture directly to tmpdir + // via raw fs.writeFile so initWorkspace can be driven against a workspace that + // already has .gitignore rules. Test file only; the production initWorkspace routes + // all file writes through writeManagedFile and performs no raw fs writes itself. + path.join(SRC_DIR, "tools", "__tests__", "init-workspace.test.ts"), ]); const BANNED_WRITE_BINDINGS = [