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 +30 -4
- package/package.json +5 -3
- package/src/ClassifyFiles.ts +29 -29
- package/src/Cli.ts +78 -32
- package/src/Config.ts +14 -15
- package/src/Exclude.ts +9 -9
- package/src/FsUtil.ts +81 -19
- package/src/Git.ts +10 -10
- package/src/Gitignore.ts +25 -25
- package/src/Mapper.ts +77 -72
- package/src/OverlayGit.ts +30 -3
- package/src/OverlayLinks.ts +28 -17
- package/src/Util.ts +10 -0
- package/src/commands/Add.ts +107 -107
- package/src/commands/Check.ts +159 -139
- package/src/commands/Commit.ts +1 -1
- package/src/commands/Files.ts +38 -0
- package/src/commands/Link.ts +227 -193
- package/src/commands/Move.ts +16 -16
- package/src/commands/Rm.ts +29 -29
- package/src/commands/VsCode.ts +24 -24
- package/src/commands/files/Dedupe.ts +134 -0
- package/src/commands/files/Scan.ts +278 -0
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
|
|
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.
|
|
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.
|
|
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",
|
package/src/ClassifyFiles.ts
CHANGED
|
@@ -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
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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:
|
|
76
|
+
options: WalkOptions = {},
|
|
77
|
+
rootDir?: string,
|
|
60
78
|
): AsyncGenerator<{ path: string; isDirectory: boolean }> {
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
/**
|
|
173
|
-
function
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
185
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
220
|
-
|
|
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
|
-
|
|
223
|
+
if (isNegation) {
|
|
224
|
+
return { negation: pattern };
|
|
225
|
+
}
|
|
226
|
+
return { pattern: { pattern, basePath, negation: false, source } };
|
|
227
227
|
}
|