clank-cli 0.1.67 → 0.1.74

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -24,6 +24,7 @@ Clank stores your AI agent files (CLAUDE.md, commands, notes) in a separate git
24
24
  - **Multi-Agent Support**: Single source file, multiple symlinks (AGENTS.md, CLAUDE.md, GEMINI.md).
25
25
  - **Worktree-Aware**: Works seamlessly with git worktrees.
26
26
  - **Git Ignored**: Agent files are ignored in the main repo, tracked in the overlay repo.
27
+ - **Rules Consolidation**: Use `.claude/rules/` for modular instructions; clank auto-generates AGENTS.md/GEMINI.md with consolidated content for cross-agent compatibility.
27
28
  - **Three Scopes**: Global (all projects), Project (all branches), Worktree (this branch only).
28
29
 
29
30
  ## Installation
@@ -300,6 +301,12 @@ Available placeholders:
300
301
  - `{{project_name}}` - Project name from git
301
302
  - `{{branch_name}}` - Current branch/worktree name
302
303
 
304
+ ## Rules Consolidation
305
+
306
+ If you use Claude Code's `.claude/rules/` for modular instructions, clank automatically generates AGENTS.md and GEMINI.md by consolidating `agents.md` with all your rules. Claude reads rules natively via symlinks; other agents get everything in one file.
307
+
308
+ Store rules in `claude/rules/` in your overlay, then run `clank link`. Consolidation activates automatically when rules files exist -- when there are no rules, all agent files remain symlinks as before.
309
+
303
310
  ## Design Principles
304
311
 
305
312
  1. **Everything is linked, nothing is copied** - Single source of truth in overlay
@@ -336,7 +343,8 @@ Available placeholders:
336
343
  ├── claude/ # Claude Code specific
337
344
  │ ├── settings.json # -> .claude/settings.json
338
345
  │ ├── commands/ # -> .claude/commands/
339
- └── agents/ # -> .claude/agents/
346
+ ├── agents/ # -> .claude/agents/
347
+ │ └── rules/ # -> .claude/rules/ (also consolidated into AGENTS.md/GEMINI.md)
340
348
  ├── gemini/ # Gemini specific
341
349
  │ └── commands/ # -> .gemini/commands/
342
350
  └── worktrees/
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "clank-cli",
3
3
  "description": "Keep AI files in a separate overlay repository",
4
- "version": "0.1.67",
4
+ "version": "0.1.74",
5
5
  "author": "",
6
6
  "type": "module",
7
7
  "bin": {
package/src/AgentFiles.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { join } from "node:path";
2
2
 
3
3
  /** Base agent directory names (without leading dot) */
4
- export const agentDirNames = ["claude", "gemini"];
4
+ export const agentDirNames = ["claude", "gemini", "codex"];
5
5
 
6
6
  /** Agent directories as they appear in target (with leading dot) */
7
7
  export const managedAgentDirs = agentDirNames.map((d) => `.${d}`);
@@ -1,10 +1,12 @@
1
1
  import { lstat } from "node:fs/promises";
2
2
  import { basename, dirname, join } from "node:path";
3
3
  import { agentFiles } from "./AgentFiles.ts";
4
+ import { isGeneratedByClank } from "./Consolidate.ts";
4
5
  import {
5
6
  isTrackedByGit,
6
7
  relativePath,
7
8
  resolveSymlinkTarget,
9
+ toSlash,
8
10
  walkDirectory,
9
11
  } from "./FsUtil.ts";
10
12
  import type { GitContext } from "./Git.ts";
@@ -90,11 +92,14 @@ ${commands.join("\n")}`);
90
92
  }
91
93
 
92
94
  if (classified.untracked.length > 0) {
93
- const commands = classified.untracked.map((p) => ` clank add ${rel(p)}`);
95
+ const files = classified.untracked.map((p) => ` ${rel(p)}`);
94
96
  sections.push(`Found untracked agent files.
95
97
 
96
98
  Add them to clank:
97
- ${commands.join("\n")}`);
99
+ ${files.join("\n")}
100
+
101
+ clank add -i # add interactively
102
+ clank add <file> [<file>...] # add specific files`);
98
103
  }
99
104
 
100
105
  if (classified.staleSymlinks.length > 0) {
@@ -153,6 +158,7 @@ async function classifySingleAgentFile(
153
158
  return classifyAgentSymlink(filePath, overlayRoot, mapperCtx);
154
159
  }
155
160
  if (stat.isFile()) {
161
+ if (await isGeneratedByClank(filePath)) return {};
156
162
  const isTracked = await isTrackedByGit(filePath, targetRoot);
157
163
  return isTracked ? { tracked: [filePath] } : { untracked: [filePath] };
158
164
  }
@@ -180,7 +186,7 @@ async function classifyAgentSymlink(
180
186
  const absoluteTarget = await resolveSymlinkTarget(filePath);
181
187
 
182
188
  // Symlink doesn't point to overlay at all
183
- if (!absoluteTarget.startsWith(overlayRoot)) {
189
+ if (!toSlash(absoluteTarget).startsWith(toSlash(overlayRoot))) {
184
190
  return { staleSymlinks: [filePath] };
185
191
  }
186
192
 
package/src/Cli.ts CHANGED
@@ -36,6 +36,8 @@ Clank Overlay Directory Structure
36
36
  │ ├── settings.json # -> .claude/settings.json
37
37
  │ ├── commands/ # -> .claude/commands/
38
38
  │ └── agents/ # -> .claude/agents/
39
+ ├── codex/ # Codex specific
40
+ │ └── config.toml # -> .codex/config.toml
39
41
  ├── <subdir>/clank/ # Subdirectory files (monorepo support)
40
42
  │ └── notes.md # -> <subdir>/clank/notes.md
41
43
  └── worktrees/
@@ -51,6 +53,7 @@ Mapping Rules
51
53
  global/claude/commands/<file> -> .claude/commands/<file>
52
54
  targets/<proj>/clank/<file> -> clank/<file>
53
55
  targets/<proj>/claude/commands/ -> .claude/commands/
56
+ targets/<proj>/codex/config.toml -> .codex/config.toml
54
57
  targets/<proj>/agents.md -> CLAUDE.md, AGENTS.md, GEMINI.md
55
58
  targets/<proj>/<sub>/clank/<file> -> <sub>/clank/<file>
56
59
  targets/<proj>/worktrees/<br>/clank -> clank/
@@ -84,31 +87,9 @@ export async function runCLI(): Promise<void> {
84
87
  }
85
88
 
86
89
  function registerCommands(program: Command): void {
87
- registerCoreCommands(program);
88
- registerHelpCommands(program);
89
- }
90
-
91
- function registerCoreCommands(program: Command): void {
92
90
  registerOverlayCommands(program);
93
91
  registerUtilityCommands(program);
94
- }
95
-
96
- function registerHelpCommands(program: Command): void {
97
- const help = program
98
- .command("help")
99
- .description("Show help information")
100
- .argument("[command]", "Command to show help for")
101
- .action((commandName?: string) => {
102
- if (!commandName) return program.help();
103
- const subcommand = program.commands.find((c) => c.name() === commandName);
104
- if (subcommand) return subcommand.help();
105
- console.error(`Unknown command: ${commandName}`);
106
- process.exit(1);
107
- });
108
- help
109
- .command("structure")
110
- .description("Show overlay directory structure")
111
- .action(() => console.log(structureHelp));
92
+ registerHelpCommands(program);
112
93
  }
113
94
 
114
95
  function registerOverlayCommands(program: Command): void {
@@ -163,6 +144,7 @@ function registerUtilityCommands(program: Command): void {
163
144
  .command("check")
164
145
  .alias("status")
165
146
  .description("Show overlay status and check for issues")
147
+ .option("--prompt", "Print the agent fix prompt for orphaned paths")
166
148
  .action(withErrorHandling(checkCommand));
167
149
 
168
150
  registerFilesCommand(program);
@@ -175,6 +157,24 @@ function registerUtilityCommands(program: Command): void {
175
157
  .action(withErrorHandling(vscodeCommand));
176
158
  }
177
159
 
160
+ function registerHelpCommands(program: Command): void {
161
+ const help = program
162
+ .command("help")
163
+ .description("Show help information")
164
+ .argument("[command]", "Command to show help for")
165
+ .action((commandName?: string) => {
166
+ if (!commandName) return program.help();
167
+ const subcommand = program.commands.find((c) => c.name() === commandName);
168
+ if (subcommand) return subcommand.help();
169
+ console.error(`Unknown command: ${commandName}`);
170
+ process.exit(1);
171
+ });
172
+ help
173
+ .command("structure")
174
+ .description("Show overlay directory structure")
175
+ .action(() => console.log(structureHelp));
176
+ }
177
+
178
178
  function withErrorHandling<T extends unknown[]>(
179
179
  fn: (...args: T) => Promise<void>,
180
180
  ): (...args: T) => Promise<void> {
@@ -0,0 +1,250 @@
1
+ import { readFile, unlink, writeFile } from "node:fs/promises";
2
+ import { basename, join } from "node:path";
3
+ import { agentFiles, forEachAgentPath } from "./AgentFiles.ts";
4
+ import { fileExists, isSymlink, walkDirectory } from "./FsUtil.ts";
5
+ import type { GitContext } from "./Git.ts";
6
+ import { overlayProjectDir, overlayWorktreeDir } from "./Mapper.ts";
7
+
8
+ /** Params for the consolidation entry point */
9
+ export interface ConsolidateParams {
10
+ overlayRoot: string;
11
+ targetRoot: string;
12
+ gitContext: GitContext;
13
+ agents: string[];
14
+ }
15
+
16
+ /** A rules file with its parsed metadata and content */
17
+ interface RuleFile {
18
+ filename: string;
19
+ content: string;
20
+ description?: string;
21
+ paths?: string;
22
+ }
23
+
24
+ /** First-line marker for generated agent files */
25
+ export const generatedMarker = "<!-- Generated by clank";
26
+
27
+ /**
28
+ * Consolidate agents.md + .claude/rules/ into generated AGENTS.md/GEMINI.md.
29
+ *
30
+ * Only runs when rules files exist in the overlay. Root-level only --
31
+ * subdirectory agents.md files continue as pure symlinks.
32
+ */
33
+ export async function consolidateRulesIntoAgentFiles(
34
+ params: ConsolidateParams,
35
+ ): Promise<string[]> {
36
+ const { overlayRoot, targetRoot, gitContext, agents } = params;
37
+
38
+ // Collect rules from all applicable overlay scopes
39
+ const rulesFiles = await collectRulesFiles(overlayRoot, gitContext);
40
+ if (rulesFiles.length === 0) return [];
41
+
42
+ // Read root-level agents.md from overlay (if exists)
43
+ const agentsMdContent = await readRootAgentsMd(overlayRoot, gitContext);
44
+
45
+ // Build the consolidated content
46
+ const overlayPaths = getOverlayPaths(overlayRoot, gitContext);
47
+ const merged = buildConsolidatedContent(
48
+ agentsMdContent,
49
+ rulesFiles,
50
+ overlayPaths,
51
+ );
52
+
53
+ // For each non-claude agent, replace the symlink with a generated file
54
+ const generated: string[] = [];
55
+ await forEachAgentPath(targetRoot, agents, async (agentPath, agentName) => {
56
+ if (agentName.toLowerCase() === "claude") return;
57
+ // Remove existing symlink if present
58
+ if (await isSymlink(agentPath)) {
59
+ await unlink(agentPath);
60
+ }
61
+ await writeFile(agentPath, merged, "utf-8");
62
+ generated.push(basename(agentPath));
63
+ });
64
+
65
+ return generated;
66
+ }
67
+
68
+ /** Check if a file is generated by clank (first line starts with marker) */
69
+ export async function isGeneratedByClank(filePath: string): Promise<boolean> {
70
+ if (!(await fileExists(filePath))) return false;
71
+ if (await isSymlink(filePath)) return false;
72
+ try {
73
+ const content = await readFile(filePath, "utf-8");
74
+ return content.startsWith(generatedMarker);
75
+ } catch {
76
+ return false;
77
+ }
78
+ }
79
+
80
+ /** Strip YAML frontmatter and extract description/paths */
81
+ export function stripFrontmatter(raw: string): {
82
+ content: string;
83
+ description?: string;
84
+ paths?: string;
85
+ } {
86
+ const fence = "---";
87
+ const fenceNewline = `\n${fence}`;
88
+
89
+ if (!raw.startsWith(fence)) {
90
+ return { content: raw };
91
+ }
92
+
93
+ const endIndex = raw.indexOf(fenceNewline, fence.length);
94
+ if (endIndex === -1) {
95
+ return { content: raw };
96
+ }
97
+
98
+ const frontmatter = raw.slice(fence.length, endIndex).trim();
99
+ const content = raw.slice(endIndex + fenceNewline.length).replace(/^\n+/, "");
100
+
101
+ let description: string | undefined;
102
+ let paths: string | undefined;
103
+
104
+ for (const line of frontmatter.split("\n")) {
105
+ const descMatch = line.match(/^description:\s*(.+)/);
106
+ if (descMatch) {
107
+ description = descMatch[1].trim().replace(/^["']|["']$/g, "");
108
+ }
109
+ const pathsMatch = line.match(/^paths:\s*(.+)/);
110
+ if (pathsMatch) {
111
+ // paths can be inline array: ["src/**/*.ts"] or plain string
112
+ paths = pathsMatch[1]
113
+ .trim()
114
+ .replace(/^\[|\]$/g, "")
115
+ .replace(/["']/g, "");
116
+ }
117
+ }
118
+
119
+ return { content, description, paths };
120
+ }
121
+
122
+ /** Convert a filename to a human-readable heading */
123
+ export function humanizeFilename(filename: string): string {
124
+ return filename.replace(/\.md$/, "").replace(/[-_]/g, " ");
125
+ }
126
+
127
+ /** Remove generated agent files from target (for unlink) */
128
+ export async function removeGeneratedAgentFiles(
129
+ targetRoot: string,
130
+ ): Promise<string[]> {
131
+ const removed: string[] = [];
132
+ for (const name of agentFiles) {
133
+ const path = join(targetRoot, name);
134
+ if (await isGeneratedByClank(path)) {
135
+ await unlink(path);
136
+ removed.push(name);
137
+ }
138
+ }
139
+ return removed;
140
+ }
141
+
142
+ /** Collect all .md files from claude/rules/ across overlay scopes */
143
+ async function collectRulesFiles(
144
+ overlayRoot: string,
145
+ gitContext: GitContext,
146
+ ): Promise<RuleFile[]> {
147
+ const dirs = [
148
+ join(overlayRoot, "global/claude/rules"),
149
+ join(
150
+ overlayProjectDir(overlayRoot, gitContext.projectName),
151
+ "claude/rules",
152
+ ),
153
+ join(overlayWorktreeDir(overlayRoot, gitContext), "claude/rules"),
154
+ ];
155
+
156
+ const allFiles: RuleFile[] = [];
157
+ for (const dir of dirs) {
158
+ if (!(await fileExists(dir))) continue;
159
+ for await (const { path, isDirectory } of walkDirectory(dir)) {
160
+ if (isDirectory) continue;
161
+ if (!path.endsWith(".md")) continue;
162
+ const raw = await readFile(path, "utf-8");
163
+ const parsed = stripFrontmatter(raw);
164
+ allFiles.push({
165
+ filename: basename(path),
166
+ ...parsed,
167
+ });
168
+ }
169
+ }
170
+
171
+ // Sort alphabetically by filename for deterministic output
172
+ allFiles.sort((a, b) => a.filename.localeCompare(b.filename));
173
+ return allFiles;
174
+ }
175
+
176
+ /** Read root-level agents.md from the overlay (project scope, then global) */
177
+ async function readRootAgentsMd(
178
+ overlayRoot: string,
179
+ gitContext: GitContext,
180
+ ): Promise<string | null> {
181
+ const projectPath = join(
182
+ overlayProjectDir(overlayRoot, gitContext.projectName),
183
+ "agents.md",
184
+ );
185
+ if (await fileExists(projectPath)) {
186
+ return readFile(projectPath, "utf-8");
187
+ }
188
+
189
+ // Fall back to global agents.md
190
+ const globalPath = join(overlayRoot, "global/agents.md");
191
+ if (await fileExists(globalPath)) {
192
+ return readFile(globalPath, "utf-8");
193
+ }
194
+
195
+ return null;
196
+ }
197
+
198
+ /** Get overlay paths for the header comments */
199
+ function getOverlayPaths(
200
+ overlayRoot: string,
201
+ gitContext: GitContext,
202
+ ): { agentsMdPath: string; rulesDir: string } {
203
+ const projectDir = overlayProjectDir(overlayRoot, gitContext.projectName);
204
+ return {
205
+ agentsMdPath: join(projectDir, "agents.md"),
206
+ rulesDir: join(projectDir, "claude/rules"),
207
+ };
208
+ }
209
+
210
+ /** Build the consolidated markdown content */
211
+ function buildConsolidatedContent(
212
+ agentsMdContent: string | null,
213
+ rules: RuleFile[],
214
+ overlayPaths: { agentsMdPath: string; rulesDir: string },
215
+ ): string {
216
+ const lines: string[] = [];
217
+
218
+ // Header comments
219
+ lines.push(`${generatedMarker} - do not edit -->`);
220
+ if (agentsMdContent) {
221
+ lines.push(`<!-- Source: ${overlayPaths.agentsMdPath} -->`);
222
+ }
223
+ lines.push(`<!-- Rules: ${overlayPaths.rulesDir} -->`);
224
+ lines.push("");
225
+
226
+ // Base agents.md content
227
+ if (agentsMdContent) {
228
+ lines.push(agentsMdContent.trimEnd());
229
+ }
230
+
231
+ // Append each rule as a section
232
+ for (const rule of rules) {
233
+ lines.push("");
234
+ lines.push("---");
235
+ lines.push("");
236
+
237
+ const heading = rule.description || humanizeFilename(rule.filename);
238
+ lines.push(`## ${heading}`);
239
+
240
+ if (rule.paths) {
241
+ lines.push(`Applies to: ${rule.paths}`);
242
+ }
243
+
244
+ lines.push("");
245
+ lines.push(rule.content.trimEnd());
246
+ }
247
+
248
+ lines.push("");
249
+ return lines.join("\n");
250
+ }
package/src/FsUtil.ts CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  readdir,
6
6
  readFile,
7
7
  readlink,
8
+ realpath,
8
9
  symlink,
9
10
  unlink,
10
11
  writeFile,
@@ -28,6 +29,27 @@ interface WalkContext {
28
29
  skip?: (relPath: string, isDirectory: boolean) => boolean;
29
30
  }
30
31
 
32
+ /** Normalize path separators to forward slashes (for cross-platform string comparisons) */
33
+ export function toSlash(p: string): string {
34
+ return p.replaceAll("\\", "/");
35
+ }
36
+
37
+ /** Resolve Windows 8.3 short names to canonical long paths.
38
+ * On non-Windows, returns the path unchanged. */
39
+ export async function resolveWindowsPath(p: string): Promise<string> {
40
+ if (process.platform !== "win32") return p;
41
+ try {
42
+ return await realpath(p);
43
+ } catch {
44
+ return p;
45
+ }
46
+ }
47
+
48
+ /** Get the current working directory, resolving Windows 8.3 short names */
49
+ export async function getCwd(): Promise<string> {
50
+ return resolveWindowsPath(process.cwd());
51
+ }
52
+
31
53
  /**
32
54
  * Create a symbolic link, removing existing link/file first
33
55
  * @param target - The path the symlink should point to (absolute)
@@ -45,7 +67,24 @@ export async function createSymlink(
45
67
  // File doesn't exist, which is fine
46
68
  }
47
69
 
48
- await symlink(target, linkPath);
70
+ try {
71
+ await symlink(target, linkPath);
72
+ } catch (error: unknown) {
73
+ if (
74
+ error instanceof Error &&
75
+ "code" in error &&
76
+ error.code === "EPERM" &&
77
+ process.platform === "win32"
78
+ ) {
79
+ throw new Error(
80
+ `Permission denied creating symlink.\n` +
81
+ `On Windows, symlinks require Developer Mode or administrator privileges.\n` +
82
+ `Enable Developer Mode: Settings > Update & Security > For Developers\n` +
83
+ `Or use clank from WSL.`,
84
+ );
85
+ }
86
+ throw error;
87
+ }
49
88
  }
50
89
 
51
90
  /** Remove a symlink if it exists */
@@ -62,12 +101,13 @@ export async function removeSymlink(linkPath: string): Promise<void> {
62
101
 
63
102
  /**
64
103
  * Get the symlink target path (absolute)
104
+ * Uses forward slashes so readlink returns consistent paths across platforms.
65
105
  * @param _from - The location of the symlink (unused, kept for API compatibility)
66
106
  * @param to - The target of the symlink (absolute)
67
107
  * @returns Absolute path to the target
68
108
  */
69
109
  export function getLinkTarget(_from: string, to: string): string {
70
- return to;
110
+ return toSlash(to);
71
111
  }
72
112
 
73
113
  /** Recursively walk a directory, yielding all files and directories */
@@ -118,7 +158,7 @@ export async function isTrackedByGit(
118
158
  repoRoot: string,
119
159
  ): Promise<boolean> {
120
160
  try {
121
- const relPath = relative(repoRoot, filePath);
161
+ const relPath = toSlash(relative(repoRoot, filePath));
122
162
  await execFileAsync("git", ["ls-files", "--error-unmatch", relPath], {
123
163
  cwd: repoRoot,
124
164
  });
@@ -138,6 +178,16 @@ export async function isSymlink(filePath: string): Promise<boolean> {
138
178
  }
139
179
  }
140
180
 
181
+ /** Check if a path is a real (non-symlink) file tracked by git */
182
+ export async function isTrackedRealFile(
183
+ filePath: string,
184
+ repoRoot: string,
185
+ ): Promise<boolean> {
186
+ if (!(await fileExists(filePath))) return false;
187
+ if (await isSymlink(filePath)) return false;
188
+ return isTrackedByGit(filePath, repoRoot);
189
+ }
190
+
141
191
  /** Get path relative to cwd, or "." if same directory */
142
192
  export function relativePath(cwd: string, path: string): string {
143
193
  return relative(cwd, path) || ".";
@@ -195,7 +245,7 @@ function* processEntry(
195
245
  | { recurse: string; options: WalkOptions; root: string }
196
246
  > {
197
247
  const fullPath = join(dir, entry.name);
198
- const relPath = relative(ctx.root, fullPath);
248
+ const relPath = toSlash(relative(ctx.root, fullPath));
199
249
 
200
250
  if (entry.isDirectory()) {
201
251
  if (shouldSkipDir(entry.name, ctx.skipDirs, ctx.includeHidden)) return;
package/src/Git.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { basename, isAbsolute, join } from "node:path";
2
2
  import { exec } from "./Exec.ts";
3
+ import { resolveWindowsPath } from "./FsUtil.ts";
3
4
 
4
5
  /** Selected git project/worktree metadata */
5
6
  export interface GitContext {
@@ -34,7 +35,9 @@ export async function detectGitRoot(
34
35
  ): Promise<string> {
35
36
  const toplevel = await gitCommand("rev-parse --show-toplevel", cwd);
36
37
  if (toplevel) {
37
- return toplevel;
38
+ // On Windows, git returns forward-slash paths that may use long names
39
+ // while cwd uses 8.3 short names. Resolve to canonical form.
40
+ return await resolveWindowsPath(toplevel);
38
41
  }
39
42
  throw new Error("Not in a git repository");
40
43
  }
@@ -89,7 +92,7 @@ export async function isGitWorktree(
89
92
  }
90
93
 
91
94
  // Worktrees have .git/worktrees/* in their git-dir path
92
- return gitDir.includes("/worktrees/");
95
+ return gitDir.replaceAll("\\", "/").includes("/worktrees/");
93
96
  }
94
97
 
95
98
  /** Parse repository name from git remote URL (handles HTTPS and SSH formats) */
package/src/Gitignore.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { basename, dirname, join, relative } from "node:path";
3
+ import picomatch from "picomatch";
3
4
  import { agentFiles, targetManagedDirs } from "./AgentFiles.ts";
4
5
  import { filterClankLines } from "./Exclude.ts";
5
- import { fileExists, walkDirectory } from "./FsUtil.ts";
6
+ import { fileExists, toSlash, walkDirectory } from "./FsUtil.ts";
6
7
  import { getGitDir } from "./Git.ts";
7
8
  import { partition } from "./Util.ts";
8
9
 
@@ -52,7 +53,7 @@ export async function collectGitignorePatterns(
52
53
 
53
54
  // 3. Find nested .gitignore files
54
55
  for (const path of await findNestedGitignores(targetRoot)) {
55
- const basePath = relative(targetRoot, dirname(path));
56
+ const basePath = toSlash(relative(targetRoot, dirname(path)));
56
57
  await parseGitignoreFile(path, result, { basePath });
57
58
  }
58
59
 
@@ -144,6 +145,23 @@ export function deduplicateGlobs(globs: string[]): string[] {
144
145
  return [...universal, ...uncovered];
145
146
  }
146
147
 
148
+ /** Load a repo's .gitignore and return a matcher for ignored filenames */
149
+ export async function loadGitignore(
150
+ repoRoot: string,
151
+ ): Promise<(name: string) => boolean> {
152
+ const gitignorePath = join(repoRoot, ".gitignore");
153
+ if (!(await fileExists(gitignorePath))) return () => false;
154
+
155
+ const content = await readFile(gitignorePath, "utf-8");
156
+ const patterns = content
157
+ .split("\n")
158
+ .map((line) => line.trim())
159
+ .filter((line) => line && !line.startsWith("#") && !line.startsWith("!"));
160
+
161
+ if (patterns.length === 0) return () => false;
162
+ return picomatch(patterns);
163
+ }
164
+
147
165
  /** Parse a gitignore file and accumulate results */
148
166
  async function parseGitignoreFile(
149
167
  source: string,