diff --git a/.gitignore b/.gitignore index a95c3d8d3..4ef782ed0 100644 --- a/.gitignore +++ b/.gitignore @@ -303,7 +303,9 @@ rulesync.local.jsonc **/.copilot/mcp-config.json **/.copilot/agents/ **/.copilot/hooks/ +**/.junie/AGENTS.md **/.junie/guidelines.md +**/.junie/memories/ **/.junie/commands/ **/.junie/mcp/mcp.json **/.junie/skills/ @@ -313,7 +315,6 @@ rulesync.local.jsonc **/.kilo/skills/ **/.kilo/commands/ **/.kilo/agents/ -**/.kilo/mcp.json **/.kilo/plugins/ **/.kilocodeignore **/.kiro/steering/ diff --git a/src/cli/commands/gitignore-entries.test.ts b/src/cli/commands/gitignore-entries.test.ts index bbd938343..95a57009d 100644 --- a/src/cli/commands/gitignore-entries.test.ts +++ b/src/cli/commands/gitignore-entries.test.ts @@ -2,8 +2,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { toolCommandFactories } from "../../features/commands/commands-processor.js"; import { toolHooksFactories } from "../../features/hooks/hooks-processor.js"; +import { toolIgnoreFactories } from "../../features/ignore/ignore-processor.js"; import { toolMcpFactories } from "../../features/mcp/mcp-processor.js"; import { toolPermissionsFactories } from "../../features/permissions/permissions-processor.js"; +import { toolRuleFactories } from "../../features/rules/rules-processor.js"; import { toolSkillFactories } from "../../features/skills/skills-processor.js"; import { toolSubagentFactories } from "../../features/subagents/subagents-processor.js"; import { createMockLogger } from "../../test-utils/mock-logger.js"; @@ -92,11 +94,28 @@ const fileToGlob = (relativeDirPath: string | undefined, relativeFilePath: strin return `**/${toPosixPath(hasDir ? `${relativeDirPath}/${relativeFilePath}` : relativeFilePath)}`; }; -const isCoveredByRegistry = (glob: string): boolean => { - const normalized = glob.replace(/\/$/, ""); +const stripTrailingSlash = (glob: string): string => glob.replace(/\/$/, ""); + +// Registry entries that deliberately gitignore a whole tool subtree, so a deeper +// output dir is covered without its own entry. Directories outside these must +// match EXACTLY, so a rename like `.augment/commands/` → `.augment/cmds/` fails +// instead of silently matching a broader parent. +const SUBTREE_COVERAGE_DIRS = ["**/.cursor", "**/.agents", "**/.goose", "**/.rovodev/.rulesync"]; + +const isCoveredDir = (dirGlob: string): boolean => { + const normalized = stripTrailingSlash(dirGlob); + if (SUBTREE_COVERAGE_DIRS.some((root) => normalized.startsWith(`${root}/`))) return true; + return GITIGNORE_ENTRY_REGISTRY.some((tag) => stripTrailingSlash(tag.entry) === normalized); +}; + +// A file output may match an entry exactly, or fall under a registered directory +// entry (trailing slash) that gitignores its whole subtree. +const isCoveredFile = (fileGlob: string): boolean => { + const normalized = stripTrailingSlash(fileGlob); return GITIGNORE_ENTRY_REGISTRY.some((tag) => { - const entry = tag.entry.replace(/\/$/, ""); - return entry === normalized || normalized.startsWith(`${entry}/`); + if (tag.entry === normalized) return true; + if (!tag.entry.endsWith("/")) return false; + return normalized.startsWith(stripTrailingSlash(tag.entry) + "/"); }); }; @@ -128,7 +147,7 @@ describe("getSettablePaths coverage", () => { if (!dir || dir === ".") return; const glob = dirToGlob(dir); if (DERIVED_PATHS_NOT_GITIGNORED.has(glob.replace(/\/$/, ""))) return; - expect(isCoveredByRegistry(glob)).toBe(true); + expect(isCoveredDir(glob)).toBe(true); }); } } @@ -137,12 +156,17 @@ describe("getSettablePaths coverage", () => { ["mcp", toolMcpFactories], ["hooks", toolHooksFactories], ["permissions", toolPermissionsFactories], + // `ignore` outputs a single file per tool (e.g. `.augmentignore`), fitting the + // file-feature shape. + ["ignore", toolIgnoreFactories], ] as const; for (const [feature, factories] of fileFeatures) { for (const [target, factory] of factories) { if (TARGETS_WITHOUT_GITIGNORE_ENTRIES.has(target)) continue; - const meta = factory.meta as { supportsProject?: boolean } | undefined; + // `ToolIgnoreFactory` has no `meta`; guard the lookup so the union stays type-safe. + const meta = + "meta" in factory ? (factory.meta as { supportsProject?: boolean } | undefined) : undefined; if (meta && meta.supportsProject === false) continue; it(`covers ${feature} output for ${target}`, () => { // No try/catch: project-supporting tools must resolve without throwing @@ -153,10 +177,137 @@ describe("getSettablePaths coverage", () => { if (!paths.relativeFilePath) return; const glob = fileToGlob(paths.relativeDirPath, paths.relativeFilePath); if (DERIVED_PATHS_NOT_GITIGNORED.has(glob)) return; - expect(isCoveredByRegistry(glob)).toBe(true); + expect(isCoveredFile(glob)).toBe(true); }); } } + + // `rules` has a composite shape ({ root, alternativeRoots, nonRoot }); roots are + // files, nonRoot is a directory. No supportsProject guard: rules are universally + // project-scoped, so getSettablePaths({ global: false }) never throws here. + for (const [target, factory] of toolRuleFactories) { + if (TARGETS_WITHOUT_GITIGNORE_ENTRIES.has(target)) continue; + it(`covers rules output for ${target}`, () => { + const paths = factory.class.getSettablePaths({ global: false }); + const rootFiles = [paths.root, ...(paths.alternativeRoots ?? [])].filter( + (entry): entry is { relativeDirPath: string; relativeFilePath: string } => + entry !== undefined, + ); + for (const root of rootFiles) { + const glob = fileToGlob(root.relativeDirPath, root.relativeFilePath); + if (DERIVED_PATHS_NOT_GITIGNORED.has(glob)) continue; + expect(isCoveredFile(glob), `root ${glob}`).toBe(true); + } + const nonRootDir = paths.nonRoot?.relativeDirPath; + if (nonRootDir && nonRootDir !== ".") { + const glob = dirToGlob(nonRootDir); + if (!DERIVED_PATHS_NOT_GITIGNORED.has(glob.replace(/\/$/, ""))) { + expect(isCoveredDir(glob), `nonRoot ${glob}`).toBe(true); + } + } + }); + } +}); + +describe("registry reverse coverage", () => { + // Every project-scope output glob some tool actually emits — the inverse of the + // coverage check, used to detect ghost entries no tool writes anymore. + const collectEmittedGlobs = (): Set => { + const globs = new Set(); + const dirFactories = [toolCommandFactories, toolSkillFactories, toolSubagentFactories]; + const fileFactories = [ + toolMcpFactories, + toolHooksFactories, + toolPermissionsFactories, + toolIgnoreFactories, + ]; + for (const factories of dirFactories) { + for (const [target, factory] of factories) { + if (TARGETS_WITHOUT_GITIGNORE_ENTRIES.has(target)) continue; + const meta = + "meta" in factory + ? (factory.meta as { supportsProject?: boolean } | undefined) + : undefined; + if (meta && meta.supportsProject === false) continue; + const dir = factory.class.getSettablePaths({ global: false }).relativeDirPath; + if (dir && dir !== ".") globs.add(stripTrailingSlash(dirToGlob(dir))); + } + } + for (const factories of fileFactories) { + for (const [target, factory] of factories) { + if (TARGETS_WITHOUT_GITIGNORE_ENTRIES.has(target)) continue; + const meta = + "meta" in factory + ? (factory.meta as { supportsProject?: boolean } | undefined) + : undefined; + if (meta && meta.supportsProject === false) continue; + const paths: { relativeDirPath?: string; relativeFilePath?: string } = + factory.class.getSettablePaths({ global: false }); + if (paths.relativeFilePath) + globs.add(fileToGlob(paths.relativeDirPath, paths.relativeFilePath)); + } + } + for (const [target, factory] of toolRuleFactories) { + if (TARGETS_WITHOUT_GITIGNORE_ENTRIES.has(target)) continue; + const paths = factory.class.getSettablePaths({ global: false }); + for (const root of [paths.root, ...(paths.alternativeRoots ?? [])]) { + if (root) globs.add(fileToGlob(root.relativeDirPath, root.relativeFilePath)); + } + const nonRootDir = paths.nonRoot?.relativeDirPath; + if (nonRootDir && nonRootDir !== ".") globs.add(stripTrailingSlash(dirToGlob(nonRootDir))); + } + return globs; + }; + + // Real entries the reverse check can't match to a `{ global: false }` output. + const REVERSE_COVERAGE_EXCEPTIONS = new Set([ + // Aggregate subtree roots and shared trees re-tagged per target. + ...SUBTREE_COVERAGE_DIRS, + "**/AGENTS.md", + "**/.agents/skills", + // Global-scope-only outputs (emitted under the home dir). + "**/.copilot/agents", + "**/.copilot/hooks", + "**/.codeium/windsurf/skills", + // supportsProject:false, so the project-scope collector skips it. + "**/.deepagents/hooks.json", + // Outputs not produced via getSettablePaths (single-file or local/legacy rules). + "**/.roomodes", + "**/.codexignore", + "**/.augment-guidelines", + "**/CLAUDE.local.md", + "**/.claude/CLAUDE.local.md", + ]); + + it("has no ghost entries — every non-general entry maps to an emitted output", () => { + const emitted = collectEmittedGlobs(); + const ghosts: string[] = []; + for (const tag of GITIGNORE_ENTRY_REGISTRY) { + if (tag.feature === "general") continue; + const targets = Array.isArray(tag.target) ? tag.target : [tag.target]; + if (targets.includes("common")) continue; + const normalized = stripTrailingSlash(tag.entry); + if (REVERSE_COVERAGE_EXCEPTIONS.has(normalized)) continue; + const matched = [...emitted].some( + (glob) => glob === normalized || glob.startsWith(`${normalized}/`), + ); + if (!matched) ghosts.push(`${tag.entry} (${targets.join(",")}/${tag.feature})`); + } + expect(ghosts).toEqual([]); + }); + + it("subtree-coverage roots exist as directory entries in the registry", () => { + // Guard that each prefix-coverage root is itself a registered directory entry, + // so removing the root surfaces here instead of silently widening coverage. + const dirEntries = new Set( + GITIGNORE_ENTRY_REGISTRY.filter((tag) => tag.entry.endsWith("/")).map((tag) => + stripTrailingSlash(tag.entry), + ), + ); + for (const root of SUBTREE_COVERAGE_DIRS) { + expect(dirEntries, `missing subtree root ${root}`).toContain(root); + } + }); }); describe("ALL_GITIGNORE_ENTRIES", () => { diff --git a/src/cli/commands/gitignore-entries.ts b/src/cli/commands/gitignore-entries.ts index 7a71cde86..a741df4fa 100644 --- a/src/cli/commands/gitignore-entries.ts +++ b/src/cli/commands/gitignore-entries.ts @@ -293,7 +293,12 @@ export const GITIGNORE_ENTRY_REGISTRY: ReadonlyArray = [ { target: "copilotcli", feature: "hooks", entry: "**/.copilot/hooks/" }, // Junie + // Current root rule is `.junie/AGENTS.md`; `.junie/guidelines.md` is the legacy + // file still accepted on import, so both are gitignored. + { target: "junie", feature: "rules", entry: "**/.junie/AGENTS.md" }, { target: "junie", feature: "rules", entry: "**/.junie/guidelines.md" }, + // Non-root rules are emitted as memory files under `.junie/memories/`. + { target: "junie", feature: "rules", entry: "**/.junie/memories/" }, { target: "junie", feature: "commands", entry: "**/.junie/commands/" }, { target: "junie", feature: "mcp", entry: "**/.junie/mcp/mcp.json" }, { target: "junie", feature: "skills", entry: "**/.junie/skills/" }, @@ -305,13 +310,13 @@ export const GITIGNORE_ENTRY_REGISTRY: ReadonlyArray = [ { target: "kilo", feature: "skills", entry: "**/.kilo/skills/" }, { target: "kilo", feature: "commands", entry: "**/.kilo/commands/" }, { target: "kilo", feature: "subagents", entry: "**/.kilo/agents/" }, - { target: "kilo", feature: "mcp", entry: "**/.kilo/mcp.json" }, { target: "kilo", feature: "hooks", entry: "**/.kilo/plugins/" }, { target: "kilo", feature: "ignore", entry: "**/.kilocodeignore" }, - // No `**/kilo.jsonc` entry: structurally identical to `opencode.jsonc` (no - // entry). The Kilo translator preserves non-permissions Kilo settings on - // round-trip, so the file is intended to be checked in by the user — adding - // `**/kilo.jsonc` would be too aggressive. + // No `**/kilo.json` (MCP) or `**/kilo.jsonc` entry: structurally identical to + // `opencode.jsonc` (no entry). The Kilo translator preserves non-permissions + // Kilo settings on round-trip, so the file is intended to be checked in by the + // user — a gitignore entry would be too aggressive. (The MCP output moved to the + // root `kilo.json`; the old `**/.kilo/mcp.json` entry was a stale ghost.) // Kiro { target: "kiro", feature: "rules", entry: "**/.kiro/steering/" },