Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion plugins/flow/mcp-server/dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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", () => {
Expand All @@ -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).");
Expand All @@ -158,6 +219,7 @@ describe("renderInitWorkspace", () => {
gitPresent: true,
teamPresent: true,
configPath: "/repo/.flow/config.yaml",
claudeIgnored: false,
});

expect(out).toContain("already initialised");
Expand Down
46 changes: 46 additions & 0 deletions plugins/flow/mcp-server/src/tools/init-workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> {
Expand Down Expand Up @@ -129,13 +131,57 @@ 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,
skipped,
gitPresent: await pathExists(path.join(root, ".git")),
teamPresent: await pathExists(path.join(root, "team")),
configPath,
claudeIgnored,
};
}

Expand Down
6 changes: 6 additions & 0 deletions plugins/flow/mcp-server/tests/canonical-fs-guard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,12 @@ const FS_WRITE_WHITELIST = new Set<string>([
// 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 = [
Expand Down