clank-cli 0.1.59 → 0.1.62

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
@@ -8,6 +8,7 @@ Common commands:
8
8
  - **`clank link`** to connect overlay files to your project.
9
9
  - **`clank commit`** to commit changes in the overlay repository.
10
10
  - **`clank check`** to show overlay status and find misaligned files.
11
+ - **`clank files`** to list clank-managed files for piping into tools like `rg`.
11
12
 
12
13
  ## Why a Separate Repository?
13
14
 
@@ -137,6 +138,29 @@ clank check
137
138
  # The following overlay files no longer match...
138
139
  ```
139
140
 
141
+ ### `clank files [path]`
142
+
143
+ List clank-managed files in the current repo as paths relative to your current directory (useful for `xargs rg` workflows).
144
+
145
+ By default, this includes `clank/` files and agent files (`AGENTS.md`, etc.), but excludes dot-prefixed directories like `.claude/` and `.gemini/`. Use `--hidden` to include those.
146
+
147
+ **Options:**
148
+ - `--hidden` - Include files under dot-prefixed directories (`.claude/`, `.gemini/`)
149
+ - `--depth <n>` - Max depth under `clank/` directories (e.g. `--depth 1` includes `*/clank/*.md` but excludes `*/clank/*/*.md`)
150
+ - `-0, --null` - NUL-separate output paths (recommended when piping to `xargs`)
151
+ - `--no-dedupe` - Disable deduplication of agent files and prompts
152
+ - `--linked-only` - Only include symlinks into the overlay
153
+ - `--unlinked-only` - Only include non-overlay files/symlinks
154
+ - `--global|--project|--worktree` - Only include linked files from that scope (implies `--linked-only`)
155
+
156
+ **Examples:**
157
+ ```bash
158
+ clank files -0 | xargs -0 rg "TODO"
159
+ clank files --depth 1
160
+ clank files --hidden | rg '^\\.claude/'
161
+ clank files . # Only this directory/subtree (relative to cwd)
162
+ ```
163
+
140
164
  ### `clank rm <files...>` (alias: `remove`)
141
165
 
142
166
  Remove file(s) from both the overlay repository and the local project symlinks. Accepts [scope options](#scope-options); if omitted, clank detects the scope from the symlink.
@@ -181,7 +205,7 @@ clank help structure
181
205
 
182
206
  ### `--config <path>` (global option)
183
207
 
184
- Specify a custom config file location (default `~/.config/clank/config.js`).
208
+ Specify a custom config file location (default `~/.config/clank/clank.config.js`).
185
209
 
186
210
  ```bash
187
211
  clank --config /tmp/test-config.js init /tmp/test-overlay
@@ -219,23 +243,25 @@ clank/
219
243
 
220
244
  ## Configuration
221
245
 
222
- Global configuration is stored by default in `~/.config/clank/config.js`:
246
+ Global configuration is stored in `~/.config/clank/clank.config.js`:
223
247
 
224
248
  ```javascript
225
249
  export default {
226
250
  overlayRepo: "~/clankover",
227
251
  agents: ["agents", "claude", "gemini"],
228
252
  vscodeSettings: "auto", // "auto" | "always" | "never"
229
- vscodeGitignore: true
253
+ vscodeGitignore: true,
254
+ ignore: [".obsidian", "*.bak"]
230
255
  };
231
256
  ```
232
257
 
233
- - `agents` - which symlinks to create for agent files like CLAUDE.md
258
+ - `agents` - which symlinks to create for agent files like CLAUDE.md; also controls which agent file/prompt path is preferred for `clank files` output when deduping.
234
259
  - `vscodeSettings` - when to generate `.vscode/settings.json` to show clank files in VS Code
235
260
  - `"auto"` (default): only if project already has a `.vscode` directory
236
261
  - `"always"`: always generate settings
237
262
  - `"never"`: never auto-generate (you can still run `clank vscode` manually)
238
263
  - `vscodeGitignore` - add `.vscode/settings.json` to `.git/info/exclude` (default: true)
264
+ - `ignore` - glob patterns to skip in the overlay (e.g., `[".obsidian", "*.bak", ".DS_Store"]`).
239
265
 
240
266
  By default, clank creates symlinks for AGENTS.md, CLAUDE.md, and GEMINI.md.
241
267
  Run `clank unlink` then `clank link` to apply config changes.
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.59",
4
+ "version": "0.1.62",
5
5
  "author": "",
6
6
  "type": "module",
7
7
  "bin": {
@@ -14,11 +14,13 @@
14
14
  "repository": "github:mighdoll/clank",
15
15
  "dependencies": {
16
16
  "commander": "^14.0.2",
17
- "cosmiconfig": "^9.0.0"
17
+ "cosmiconfig": "^9.0.0",
18
+ "picomatch": "^4.0.3"
18
19
  },
19
20
  "devDependencies": {
20
- "@biomejs/biome": "^2.3.8",
21
+ "@biomejs/biome": "^2.3.10",
21
22
  "@types/node": "^24.10.1",
23
+ "@types/picomatch": "^4.0.2",
22
24
  "@typescript/native-preview": "7.0.0-dev.20251221.1",
23
25
  "@vitest/ui": "^4.0.16",
24
26
  "execa": "^9.6.1",
@@ -25,8 +25,6 @@ export interface AgentFileClassification {
25
25
  outdatedSymlinks: OutdatedSymlink[];
26
26
  }
27
27
 
28
- type PartialClassification = Partial<AgentFileClassification>;
29
-
30
28
  export interface OutdatedSymlink {
31
29
  /** Path to the symlink in the target */
32
30
  symlinkPath: string;
@@ -38,6 +36,8 @@ export interface OutdatedSymlink {
38
36
  expectedTarget: string;
39
37
  }
40
38
 
39
+ type PartialClassification = Partial<AgentFileClassification>;
40
+
41
41
  /** Find all agent files in the repository and classify them.
42
42
  * Returns absolute paths in the classification.
43
43
  */
@@ -127,6 +127,21 @@ To fix:
127
127
  return sections.join("\n\n");
128
128
  }
129
129
 
130
+ /** Find all agent files in the repository */
131
+ async function findAllAgentFiles(targetRoot: string): Promise<string[]> {
132
+ const files: string[] = [];
133
+ const agentFileSet = new Set(agentFiles);
134
+
135
+ for await (const { path, isDirectory } of walkDirectory(targetRoot)) {
136
+ if (isDirectory) continue;
137
+ if (agentFileSet.has(basename(path))) {
138
+ files.push(path);
139
+ }
140
+ }
141
+
142
+ return files;
143
+ }
144
+
130
145
  /** Classify a single agent file */
131
146
  async function classifySingleAgentFile(
132
147
  filePath: string,
@@ -146,6 +161,18 @@ async function classifySingleAgentFile(
146
161
  return {};
147
162
  }
148
163
 
164
+ /** Merge sparse classifications into a complete classification with arrays */
165
+ function mergeClassifications(
166
+ items: PartialClassification[],
167
+ ): AgentFileClassification {
168
+ return {
169
+ tracked: items.flatMap((i) => i.tracked ?? []),
170
+ untracked: items.flatMap((i) => i.untracked ?? []),
171
+ staleSymlinks: items.flatMap((i) => i.staleSymlinks ?? []),
172
+ outdatedSymlinks: items.flatMap((i) => i.outdatedSymlinks ?? []),
173
+ };
174
+ }
175
+
149
176
  /** Classify an agent symlink - check if stale or outdated */
150
177
  async function classifyAgentSymlink(
151
178
  filePath: string,
@@ -179,30 +206,3 @@ async function classifyAgentSymlink(
179
206
 
180
207
  return {};
181
208
  }
182
-
183
- /** Merge sparse classifications into a complete classification with arrays */
184
- function mergeClassifications(
185
- items: PartialClassification[],
186
- ): AgentFileClassification {
187
- return {
188
- tracked: items.flatMap((i) => i.tracked ?? []),
189
- untracked: items.flatMap((i) => i.untracked ?? []),
190
- staleSymlinks: items.flatMap((i) => i.staleSymlinks ?? []),
191
- outdatedSymlinks: items.flatMap((i) => i.outdatedSymlinks ?? []),
192
- };
193
- }
194
-
195
- /** Find all agent files in the repository */
196
- async function findAllAgentFiles(targetRoot: string): Promise<string[]> {
197
- const files: string[] = [];
198
- const agentFileSet = new Set(agentFiles);
199
-
200
- for await (const { path, isDirectory } of walkDirectory(targetRoot)) {
201
- if (isDirectory) continue;
202
- if (agentFileSet.has(basename(path))) {
203
- files.push(path);
204
- }
205
- }
206
-
207
- return files;
208
- }
package/src/Cli.ts CHANGED
@@ -4,6 +4,7 @@ import { defaultOverlayDir, setConfigPath } from "./Config.ts";
4
4
  import { addCommand } from "./commands/Add.ts";
5
5
  import { checkCommand } from "./commands/Check.ts";
6
6
  import { commitCommand } from "./commands/Commit.ts";
7
+ import { filesCommand } from "./commands/Files.ts";
7
8
  import { initCommand } from "./commands/Init.ts";
8
9
  import { linkCommand } from "./commands/Link.ts";
9
10
  import { moveCommand } from "./commands/Move.ts";
@@ -92,6 +93,24 @@ function registerCoreCommands(program: Command): void {
92
93
  registerUtilityCommands(program);
93
94
  }
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));
112
+ }
113
+
95
114
  function registerOverlayCommands(program: Command): void {
96
115
  program
97
116
  .command("init")
@@ -145,6 +164,8 @@ function registerUtilityCommands(program: Command): void {
145
164
  .description("Show overlay status and check for issues")
146
165
  .action(withErrorHandling(checkCommand));
147
166
 
167
+ registerFilesCommand(program);
168
+
148
169
  program
149
170
  .command("vscode")
150
171
  .description("Generate VS Code settings to show clank files")
@@ -152,27 +173,17 @@ function registerUtilityCommands(program: Command): void {
152
173
  .action(withErrorHandling(vscodeCommand));
153
174
  }
154
175
 
155
- function registerHelpCommands(program: Command): void {
156
- const help = program
157
- .command("help")
158
- .description("Show help information")
159
- .argument("[command]", "Command to show help for")
160
- .action((commandName?: string) => {
161
- if (!commandName) {
162
- program.help();
163
- }
164
- const subcommand = program.commands.find((c) => c.name() === commandName);
165
- if (subcommand) {
166
- subcommand.help();
167
- } else {
168
- console.error(`Unknown command: ${commandName}`);
169
- process.exit(1);
170
- }
171
- });
172
- help
173
- .command("structure")
174
- .description("Show overlay directory structure")
175
- .action(() => console.log(structureHelp));
176
+ function withErrorHandling<T extends unknown[]>(
177
+ fn: (...args: T) => Promise<void>,
178
+ ): (...args: T) => Promise<void> {
179
+ return async (...args: T) => {
180
+ try {
181
+ await fn(...args);
182
+ } catch (error) {
183
+ console.error("Error:", error instanceof Error ? error.message : error);
184
+ process.exit(1);
185
+ }
186
+ };
176
187
  }
177
188
 
178
189
  function registerRmCommand(program: Command): void {
@@ -215,15 +226,50 @@ function registerMvCommand(program: Command): void {
215
226
  cmd.action(withErrorHandling(moveCommand));
216
227
  }
217
228
 
218
- function withErrorHandling<T extends unknown[]>(
219
- fn: (...args: T) => Promise<void>,
220
- ): (...args: T) => Promise<void> {
221
- return async (...args: T) => {
222
- try {
223
- await fn(...args);
224
- } catch (error) {
225
- console.error("Error:", error instanceof Error ? error.message : error);
226
- process.exit(1);
227
- }
228
- };
229
+ function registerFilesCommand(program: Command): void {
230
+ const files = program
231
+ .command("files")
232
+ .alias("list")
233
+ .description("List clank-managed files (paths relative to cwd)")
234
+ .argument(
235
+ "[path]",
236
+ "Limit to this directory/subtree (relative to cwd; default: repo root)",
237
+ )
238
+ .option("--hidden", "Include files under dot-prefixed directories")
239
+ .option("--depth <n>", "Max depth under clank/ directories")
240
+ .option("-0, --null", "NUL-separate output paths")
241
+ .option("--no-dedupe", "Disable deduplication");
242
+
243
+ files.addOption(
244
+ new Option(
245
+ "-g, --global",
246
+ "Only include linked files from global scope",
247
+ ).conflicts(["project", "worktree"]),
248
+ );
249
+ files.addOption(
250
+ new Option(
251
+ "-p, --project",
252
+ "Only include linked files from project scope",
253
+ ).conflicts(["global", "worktree"]),
254
+ );
255
+ files.addOption(
256
+ new Option(
257
+ "-w, --worktree",
258
+ "Only include linked files from worktree scope",
259
+ ).conflicts(["global", "project"]),
260
+ );
261
+ files.addOption(
262
+ new Option(
263
+ "--linked-only",
264
+ "Only include symlinks into the overlay",
265
+ ).conflicts(["unlinkedOnly"]),
266
+ );
267
+ files.addOption(
268
+ new Option(
269
+ "--unlinked-only",
270
+ "Only include non-overlay files/symlinks",
271
+ ).conflicts(["linkedOnly"]),
272
+ );
273
+
274
+ files.action(withErrorHandling(filesCommand));
229
275
  }
package/src/Config.ts CHANGED
@@ -4,8 +4,6 @@ import { join } from "node:path";
4
4
  import { cosmiconfig } from "cosmiconfig";
5
5
  import { fileExists } from "./FsUtil.ts";
6
6
 
7
- export const defaultOverlayDir = "clankover";
8
-
9
7
  export interface ClankConfig {
10
8
  overlayRepo: string;
11
9
  agents: string[];
@@ -13,8 +11,12 @@ export interface ClankConfig {
13
11
  vscodeSettings?: "auto" | "always" | "never";
14
12
  /** Add .vscode/settings.json to .git/info/exclude (default: true) */
15
13
  vscodeGitignore?: boolean;
14
+ /** Patterns to ignore when walking overlay (e.g., [".obsidian", "*.bak"]) */
15
+ ignore?: string[];
16
16
  }
17
17
 
18
+ export const defaultOverlayDir = "clankover";
19
+
18
20
  const defaultConfig: ClankConfig = {
19
21
  overlayRepo: join(homedir(), defaultOverlayDir),
20
22
  agents: ["agents", "claude", "gemini"],
@@ -28,14 +30,11 @@ export function setConfigPath(path: string | undefined): void {
28
30
  customConfigPath = path;
29
31
  }
30
32
 
31
- /** Load global clank configuration from ~/.config/clank/config.js or similar */
33
+ /** Load global clank configuration from ~/.config/clank/clank.config.js or similar */
32
34
  export async function loadConfig(): Promise<ClankConfig> {
33
35
  if (customConfigPath) {
34
36
  const result = await explorer.load(customConfigPath);
35
- if (!result) {
36
- throw new Error(`Config file not found: ${customConfigPath}`);
37
- }
38
- if (result.isEmpty) {
37
+ if (!result || result.isEmpty) {
39
38
  return defaultConfig;
40
39
  }
41
40
  return { ...defaultConfig, ...result.config };
@@ -48,10 +47,10 @@ export async function loadConfig(): Promise<ClankConfig> {
48
47
  return { ...defaultConfig, ...result.config };
49
48
  }
50
49
 
51
- /** Create default configuration file at ~/.config/clank/config.js */
50
+ /** Create default configuration file at ~/.config/clank/clank.config.js */
52
51
  export async function createDefaultConfig(overlayRepo?: string): Promise<void> {
53
52
  const configDir = getConfigDir();
54
- const configPath = customConfigPath || join(configDir, "config.js");
53
+ const configPath = customConfigPath || join(configDir, "clank.config.js");
55
54
 
56
55
  const config = {
57
56
  ...defaultConfig,
@@ -82,12 +81,6 @@ export async function getOverlayPath(): Promise<string> {
82
81
  return expandPath(config.overlayRepo);
83
82
  }
84
83
 
85
- /** Get the XDG config directory (respects XDG_CONFIG_HOME, defaults to ~/.config/clank) */
86
- function getConfigDir(): string {
87
- const xdgConfig = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
88
- return join(xdgConfig, "clank");
89
- }
90
-
91
84
  /** Validate overlay repository exists, throw if not */
92
85
  export async function validateOverlayExists(
93
86
  overlayRoot: string,
@@ -98,3 +91,9 @@ export async function validateOverlayExists(
98
91
  );
99
92
  }
100
93
  }
94
+
95
+ /** Get the XDG config directory (respects XDG_CONFIG_HOME, defaults to ~/.config/clank) */
96
+ function getConfigDir(): string {
97
+ const xdgConfig = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
98
+ return join(xdgConfig, "clank");
99
+ }
package/src/Exclude.ts CHANGED
@@ -102,15 +102,6 @@ export async function removeGitExcludes(targetRoot: string): Promise<void> {
102
102
  console.log("Removed clank entries from .git/info/exclude");
103
103
  }
104
104
 
105
- /** Remove the clank section from exclude file content */
106
- function removeClankSection(content: string): string {
107
- const pattern = new RegExp(
108
- `\\n*${clankMarkerStart}[\\s\\S]*?${clankMarkerEnd}\\n*`,
109
- "g",
110
- );
111
- return content.replace(pattern, "\n");
112
- }
113
-
114
105
  /** Filter out clank section from lines */
115
106
  export function filterClankLines(lines: string[]): string[] {
116
107
  const result: string[] = [];
@@ -129,6 +120,15 @@ export function filterClankLines(lines: string[]): string[] {
129
120
  return result;
130
121
  }
131
122
 
123
+ /** Remove the clank section from exclude file content */
124
+ function removeClankSection(content: string): string {
125
+ const pattern = new RegExp(
126
+ `\\n*${clankMarkerStart}[\\s\\S]*?${clankMarkerEnd}\\n*`,
127
+ "g",
128
+ );
129
+ return content.replace(pattern, "\n");
130
+ }
131
+
132
132
  /** Check if a directory has any tracked files */
133
133
  async function hasTrackedFiles(
134
134
  dirPath: string,
package/src/FsUtil.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { Dirent } from "node:fs";
1
2
  import {
2
3
  lstat,
3
4
  mkdir,
@@ -11,6 +12,22 @@ import {
11
12
  import { dirname, isAbsolute, join, relative } from "node:path";
12
13
  import { execFileAsync } from "./Exec.ts";
13
14
 
15
+ export interface WalkOptions {
16
+ /** Directories to skip (default: [".git", "node_modules"]) */
17
+ skipDirs?: string[];
18
+ /** Include dot-prefixed directories (default: true) */
19
+ includeHiddenDirs?: boolean;
20
+ /** Skip entries matching this predicate (checked before recursing into directories) */
21
+ skip?: (relPath: string, isDirectory: boolean) => boolean;
22
+ }
23
+
24
+ interface WalkContext {
25
+ root: string;
26
+ skipDirs: string[];
27
+ includeHidden: boolean;
28
+ skip?: (relPath: string, isDirectory: boolean) => boolean;
29
+ }
30
+
14
31
  /**
15
32
  * Create a symbolic link, removing existing link/file first
16
33
  * @param target - The path the symlink should point to (absolute)
@@ -56,28 +73,27 @@ export function getLinkTarget(_from: string, to: string): string {
56
73
  /** Recursively walk a directory, yielding all files and directories */
57
74
  export async function* walkDirectory(
58
75
  dir: string,
59
- options: { skipDirs?: string[] } = {},
76
+ options: WalkOptions = {},
77
+ rootDir?: string,
60
78
  ): AsyncGenerator<{ path: string; isDirectory: boolean }> {
61
- const skipDirs = options.skipDirs || [".git", "node_modules"];
62
-
63
- try {
64
- const entries = await readdir(dir, { withFileTypes: true });
65
-
66
- for (const entry of entries) {
67
- if (skipDirs.includes(entry.name)) continue;
68
-
69
- const fullPath = join(dir, entry.name);
70
-
71
- if (entry.isDirectory()) {
72
- yield { path: fullPath, isDirectory: true };
73
- yield* walkDirectory(fullPath, options);
74
- } else if (entry.isFile() || entry.isSymbolicLink()) {
75
- yield { path: fullPath, isDirectory: false };
79
+ const ctx: WalkContext = {
80
+ root: rootDir ?? dir,
81
+ skipDirs: options.skipDirs ?? [".git", "node_modules"],
82
+ includeHidden: options.includeHiddenDirs ?? true,
83
+ skip: options.skip,
84
+ };
85
+
86
+ const entries = await tryReadDir(dir);
87
+ if (!entries) return;
88
+
89
+ for (const entry of entries) {
90
+ for (const result of processEntry(entry, dir, ctx)) {
91
+ if ("recurse" in result) {
92
+ yield* walkDirectory(result.recurse, result.options, result.root);
93
+ } else {
94
+ yield result;
76
95
  }
77
96
  }
78
- } catch (_error) {
79
- // Directory doesn't exist or can't be read
80
- return;
81
97
  }
82
98
  }
83
99
 
@@ -161,3 +177,49 @@ export async function writeJsonFile(
161
177
  ): Promise<void> {
162
178
  await writeFile(filePath, `${JSON.stringify(data, null, 2)}\n`);
163
179
  }
180
+
181
+ async function tryReadDir(dir: string): Promise<Dirent[] | null> {
182
+ try {
183
+ return await readdir(dir, { withFileTypes: true });
184
+ } catch {
185
+ return null;
186
+ }
187
+ }
188
+
189
+ function* processEntry(
190
+ entry: Dirent,
191
+ dir: string,
192
+ ctx: WalkContext,
193
+ ): Generator<
194
+ | { path: string; isDirectory: boolean }
195
+ | { recurse: string; options: WalkOptions; root: string }
196
+ > {
197
+ const fullPath = join(dir, entry.name);
198
+ const relPath = relative(ctx.root, fullPath);
199
+
200
+ if (entry.isDirectory()) {
201
+ if (shouldSkipDir(entry.name, ctx.skipDirs, ctx.includeHidden)) return;
202
+ if (ctx.skip?.(relPath, true)) return;
203
+ yield { path: fullPath, isDirectory: true };
204
+ yield {
205
+ recurse: fullPath,
206
+ options: {
207
+ skipDirs: ctx.skipDirs,
208
+ includeHiddenDirs: ctx.includeHidden,
209
+ skip: ctx.skip,
210
+ },
211
+ root: ctx.root,
212
+ };
213
+ } else if (entry.isFile() || entry.isSymbolicLink()) {
214
+ if (ctx.skip?.(relPath, false)) return;
215
+ yield { path: fullPath, isDirectory: false };
216
+ }
217
+ }
218
+
219
+ function shouldSkipDir(
220
+ name: string,
221
+ skipDirs: string[],
222
+ includeHidden: boolean,
223
+ ): boolean {
224
+ return skipDirs.includes(name) || (!includeHidden && name.startsWith("."));
225
+ }
package/src/Git.ts CHANGED
@@ -115,16 +115,6 @@ export function parseRepoName(url: string): string | null {
115
115
  return basename(normalizedUrl);
116
116
  }
117
117
 
118
- /** Execute a git command and return stdout, or null if it fails */
119
- async function gitCommand(args: string, cwd?: string): Promise<string | null> {
120
- try {
121
- const { stdout } = await exec(`git ${args}`, { cwd });
122
- return stdout.trim();
123
- } catch {
124
- return null;
125
- }
126
- }
127
-
128
118
  /** Get the .git directory for the current worktree */
129
119
  export async function getGitDir(cwd: string): Promise<string | null> {
130
120
  const gitDir = await gitCommand("rev-parse --git-dir", cwd);
@@ -138,3 +128,13 @@ export async function getGitCommonDir(cwd: string): Promise<string | null> {
138
128
  if (!gitDir) return null;
139
129
  return isAbsolute(gitDir) ? gitDir : join(cwd, gitDir);
140
130
  }
131
+
132
+ /** Execute a git command and return stdout, or null if it fails */
133
+ async function gitCommand(args: string, cwd?: string): Promise<string | null> {
134
+ try {
135
+ const { stdout } = await exec(`git ${args}`, { cwd });
136
+ return stdout.trim();
137
+ } catch {
138
+ return null;
139
+ }
140
+ }
package/src/Gitignore.ts CHANGED
@@ -169,22 +169,19 @@ async function parseGitignoreFile(
169
169
  result.negationWarnings.push(...parsed.negationWarnings);
170
170
  }
171
171
 
172
- /** Parse a single gitignore line */
173
- function parseLine(
174
- trimmed: string,
175
- source: string,
176
- basePath: string,
177
- ): { pattern?: GitignorePattern; negation?: string } {
178
- // Skip empty lines and comments
179
- if (!trimmed || trimmed.startsWith("#")) return {};
180
-
181
- const isNegation = trimmed.startsWith("!");
182
- const pattern = isNegation ? trimmed.slice(1) : trimmed;
172
+ /** Find all nested .gitignore files (excluding root) */
173
+ async function findNestedGitignores(targetRoot: string): Promise<string[]> {
174
+ const gitignores: string[] = [];
175
+ const rootGitignore = join(targetRoot, ".gitignore");
183
176
 
184
- if (isNegation) {
185
- return { negation: pattern };
177
+ for await (const { path, isDirectory } of walkDirectory(targetRoot)) {
178
+ if (isDirectory) continue;
179
+ if (basename(path) === ".gitignore" && path !== rootGitignore) {
180
+ gitignores.push(path);
181
+ }
186
182
  }
187
- return { pattern: { pattern, basePath, negation: false, source } };
183
+
184
+ return gitignores;
188
185
  }
189
186
 
190
187
  /** Parse gitignore file content into patterns */
@@ -211,17 +208,20 @@ function parseGitignoreContent(
211
208
  return { patterns, negationWarnings };
212
209
  }
213
210
 
214
- /** Find all nested .gitignore files (excluding root) */
215
- async function findNestedGitignores(targetRoot: string): Promise<string[]> {
216
- const gitignores: string[] = [];
217
- const rootGitignore = join(targetRoot, ".gitignore");
211
+ /** Parse a single gitignore line */
212
+ function parseLine(
213
+ trimmed: string,
214
+ source: string,
215
+ basePath: string,
216
+ ): { pattern?: GitignorePattern; negation?: string } {
217
+ // Skip empty lines and comments
218
+ if (!trimmed || trimmed.startsWith("#")) return {};
218
219
 
219
- for await (const { path, isDirectory } of walkDirectory(targetRoot)) {
220
- if (isDirectory) continue;
221
- if (basename(path) === ".gitignore" && path !== rootGitignore) {
222
- gitignores.push(path);
223
- }
224
- }
220
+ const isNegation = trimmed.startsWith("!");
221
+ const pattern = isNegation ? trimmed.slice(1) : trimmed;
225
222
 
226
- return gitignores;
223
+ if (isNegation) {
224
+ return { negation: pattern };
225
+ }
226
+ return { pattern: { pattern, basePath, negation: false, source } };
227
227
  }