Skip to content
Open
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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
}
}
Expand Down
6 changes: 3 additions & 3 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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();
Expand Down
59 changes: 59 additions & 0 deletions src/test/suite/cacheGitignore.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
const repo = await fs.mkdtemp(path.join(os.tmpdir(), "copilot-specs-test-"));
await execFileAsync("git", ["init"], { cwd: repo });
return repo;
}
114 changes: 114 additions & 0 deletions src/utils/cacheGitignore.ts
Original file line number Diff line number Diff line change
@@ -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<string>(CACHE_GITIGNORE_BEHAVIOR_SETTING, "auto");

return isCacheGitignoreBehavior(value) ? value : "auto";
}

export async function ensureCacheGitignoreEntries(
context: vscode.ExtensionContext,
): Promise<void> {
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<void> {
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<string | undefined> {
return vscode.window.showInformationMessage(
`Add ${CACHE_GITIGNORE_ENTRY} to ${folder.name}'s .gitignore?`,
"Add",
"Don't Add",
);
}

export async function isCachePathIgnoredByGit(cwd: string): Promise<boolean> {
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<boolean> {
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";
}
9 changes: 7 additions & 2 deletions src/utils/fileSystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,13 @@ export async function ensureDir(uri: vscode.Uri): Promise<void> {
/**
* Adds `entry` to the workspace .gitignore if not already present.
*/
export async function ensureGitignoreEntry(entry: string): Promise<void> {
const uri = resolveWorkspacePath(".gitignore");
export async function ensureGitignoreEntry(
entry: string,
workspaceUri?: vscode.Uri,
): Promise<void> {
const uri = workspaceUri
? vscode.Uri.joinPath(workspaceUri, ".gitignore")
: resolveWorkspacePath(".gitignore");
if (!uri) {
return;
}
Expand Down