diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ed3919..a1716c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ The format is based on Keep a Changelog, and this project follows Semantic Versi ## [Unreleased] +### Added + +- `copilot-specs.cacheGitignoreBehavior` setting for controlling `.copilot-specs-cache/` `.gitignore` updates: automatic, prompt first, or disabled. + +### Changed + +- Cache `.gitignore` setup now checks Git before editing `.gitignore`, so paths already ignored through repository, global, or other Git ignore configuration are left alone. +- Prompted cache `.gitignore` choices are remembered per workspace folder. + ## [0.1.12] - 2026-03-05 ### Changed diff --git a/package.json b/package.json index b0a6186..b79ebc4 100644 --- a/package.json +++ b/package.json @@ -444,6 +444,21 @@ ], "default": "feature", "description": "Default spec template to use when creating a new spec." + }, + "copilot-specs.cacheGitignoreBehavior": { + "type": "string", + "enum": [ + "auto", + "prompt", + "disabled" + ], + "enumDescriptions": [ + "Add .copilot-specs-cache/ to workspace .gitignore files automatically when Git does not already ignore it.", + "Ask before adding .copilot-specs-cache/ to each workspace folder's .gitignore, and remember the answer for that folder.", + "Never add .copilot-specs-cache/ to .gitignore files." + ], + "default": "auto", + "description": "Controls whether Copilot Specs adds .copilot-specs-cache/ to workspace .gitignore files." } } } diff --git a/src/extension.ts b/src/extension.ts index 5469c30..1cea3c1 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -71,8 +71,8 @@ import { listSkillFiles, listInstructionRulesFiles, listPromptFiles, - ensureGitignoreEntry, } from "./utils/fileSystem.js"; +import { ensureCacheGitignoreEntries } from "./utils/cacheGitignore.js"; import { Task } from "./models/index.js"; import { HookEventName } from "./models/index.js"; @@ -83,8 +83,8 @@ export function activate(context: vscode.ExtensionContext): void { initTemplates(extensionPath); initHooks(extensionPath); - // Ensure cache directory is gitignored - void ensureGitignoreEntry(".copilot-specs-cache/"); + // Ensure cache directory is gitignored when configured and not already ignored by Git + void ensureCacheGitignoreEntries(context); // ── Providers ─────────────────────────────────────────────────────────────── const specProvider = new SpecProvider(); diff --git a/src/test/suite/cacheGitignore.test.ts b/src/test/suite/cacheGitignore.test.ts new file mode 100644 index 0000000..99d6b32 --- /dev/null +++ b/src/test/suite/cacheGitignore.test.ts @@ -0,0 +1,59 @@ +import { execFile } from "node:child_process"; +import * as assert from "node:assert/strict"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { promisify } from "node:util"; + +import { isCachePathIgnoredByGit } from "../../utils/cacheGitignore.js"; + +const execFileAsync = promisify(execFile); + +suite("cacheGitignore", () => { + test("detects cache path ignored by repository .gitignore", async () => { + const repo = await createGitRepo(); + try { + await fs.writeFile( + path.join(repo, ".gitignore"), + ".copilot-specs-cache/\n", + "utf8", + ); + + assert.equal(await isCachePathIgnoredByGit(repo), true); + } finally { + await fs.rm(repo, { force: true, recursive: true }); + } + }); + + test("detects cache path ignored through core.excludesFile", async () => { + const repo = await createGitRepo(); + const excludesFile = path.join(repo, "global-ignore"); + try { + await fs.writeFile(excludesFile, ".copilot-specs-cache/\n", "utf8"); + await execFileAsync( + "git", + ["config", "core.excludesFile", excludesFile], + { cwd: repo }, + ); + + assert.equal(await isCachePathIgnoredByGit(repo), true); + } finally { + await fs.rm(repo, { force: true, recursive: true }); + } + }); + + test("returns false when Git does not ignore the cache path", async () => { + const repo = await createGitRepo(); + try { + assert.equal(await isCachePathIgnoredByGit(repo), false); + } finally { + await fs.rm(repo, { force: true, recursive: true }); + } + }); +}); + +async function createGitRepo(): Promise { + const repo = await fs.mkdtemp(path.join(os.tmpdir(), "copilot-specs-test-")); + await execFileAsync("git", ["init"], { cwd: repo }); + return repo; +} diff --git a/src/utils/cacheGitignore.ts b/src/utils/cacheGitignore.ts new file mode 100644 index 0000000..7734e10 --- /dev/null +++ b/src/utils/cacheGitignore.ts @@ -0,0 +1,114 @@ +import { execFile } from "node:child_process"; +import * as vscode from "vscode"; + +import { ensureGitignoreEntry } from "./fileSystem.js"; + +export const CACHE_GITIGNORE_ENTRY = ".copilot-specs-cache/"; +export const CACHE_GITIGNORE_PATH = ".copilot-specs-cache"; + +export type CacheGitignoreBehavior = "auto" | "prompt" | "disabled"; + +type RememberedCacheGitignoreChoice = "add" | "skip"; + +const CACHE_GITIGNORE_BEHAVIOR_SETTING = "cacheGitignoreBehavior"; +const CACHE_GITIGNORE_CHOICE_KEY_PREFIX = "cacheGitignoreChoice"; + +export function getCacheGitignoreBehavior(): CacheGitignoreBehavior { + const value = vscode.workspace + .getConfiguration("copilot-specs") + .get(CACHE_GITIGNORE_BEHAVIOR_SETTING, "auto"); + + return isCacheGitignoreBehavior(value) ? value : "auto"; +} + +export async function ensureCacheGitignoreEntries( + context: vscode.ExtensionContext, +): Promise { + const behavior = getCacheGitignoreBehavior(); + if (behavior === "disabled") { + return; + } + + for (const folder of vscode.workspace.workspaceFolders ?? []) { + await ensureCacheGitignoreEntryForFolder(context, folder, behavior); + } +} + +async function ensureCacheGitignoreEntryForFolder( + context: vscode.ExtensionContext, + folder: vscode.WorkspaceFolder, + behavior: CacheGitignoreBehavior, +): Promise { + if (await isCachePathIgnoredByGit(folder.uri.fsPath)) { + return; + } + + if (behavior === "prompt") { + const rememberedChoice = context.workspaceState.get< + RememberedCacheGitignoreChoice + >(getChoiceKey(folder)); + + if (rememberedChoice === "skip") { + return; + } + + if (rememberedChoice !== "add") { + const choice = await promptForCacheGitignoreEntry(folder); + if (choice === "Add") { + await context.workspaceState.update(getChoiceKey(folder), "add"); + } else if (choice === "Don't Add") { + await context.workspaceState.update(getChoiceKey(folder), "skip"); + return; + } else { + return; + } + } + } + + await ensureGitignoreEntry(CACHE_GITIGNORE_ENTRY, folder.uri); +} + +async function promptForCacheGitignoreEntry( + folder: vscode.WorkspaceFolder, +): Promise { + return vscode.window.showInformationMessage( + `Add ${CACHE_GITIGNORE_ENTRY} to ${folder.name}'s .gitignore?`, + "Add", + "Don't Add", + ); +} + +export async function isCachePathIgnoredByGit(cwd: string): Promise { + const pathsToCheck = [CACHE_GITIGNORE_PATH, CACHE_GITIGNORE_ENTRY]; + + for (const pathToCheck of pathsToCheck) { + if (await isPathIgnoredByGit(cwd, pathToCheck)) { + return true; + } + } + + return false; +} + +function isPathIgnoredByGit(cwd: string, pathToCheck: string): Promise { + return new Promise((resolve) => { + execFile( + "git", + ["check-ignore", "-q", "--", pathToCheck], + { cwd }, + (error) => { + resolve(!error); + }, + ); + }); +} + +function getChoiceKey(folder: vscode.WorkspaceFolder): string { + return `${CACHE_GITIGNORE_CHOICE_KEY_PREFIX}:${folder.uri.toString()}`; +} + +function isCacheGitignoreBehavior( + value: string, +): value is CacheGitignoreBehavior { + return value === "auto" || value === "prompt" || value === "disabled"; +} diff --git a/src/utils/fileSystem.ts b/src/utils/fileSystem.ts index e3a6b14..5157697 100644 --- a/src/utils/fileSystem.ts +++ b/src/utils/fileSystem.ts @@ -50,8 +50,13 @@ export async function ensureDir(uri: vscode.Uri): Promise { /** * Adds `entry` to the workspace .gitignore if not already present. */ -export async function ensureGitignoreEntry(entry: string): Promise { - const uri = resolveWorkspacePath(".gitignore"); +export async function ensureGitignoreEntry( + entry: string, + workspaceUri?: vscode.Uri, +): Promise { + const uri = workspaceUri + ? vscode.Uri.joinPath(workspaceUri, ".gitignore") + : resolveWorkspacePath(".gitignore"); if (!uri) { return; }