Skip to content

Commit 2bdbcee

Browse files
CopilotfboschCopilot
authored
Fix zero value handling in CLI output and explicit file protocol configuration (#13)
* Initial plan * Add tocFormat configuration with tree and compressed formats Co-authored-by: fbosch <6979916+fbosch@users.noreply.github.com> * Add tests for TOC format configuration Co-authored-by: fbosch <6979916+fbosch@users.noreply.github.com> * Address code review feedback and fix test failures Co-authored-by: fbosch <6979916+fbosch@users.noreply.github.com> * Update compressed format to Vercel AGENTS.md style Co-authored-by: fbosch <6979916+fbosch@users.noreply.github.com> * Plan to remove frontmatter and update format Co-authored-by: fbosch <6979916+fbosch@users.noreply.github.com> * Remove frontmatter and update TOC formats Co-authored-by: fbosch <6979916+fbosch@users.noreply.github.com> * Update src/config.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Add test for toc=false backward compatibility Co-authored-by: fbosch <6979916+fbosch@users.noreply.github.com> * fix(git): add force to detach checkout * feat(git): add timeoutMs config for operations * feat(git): add persistent cache for repo fetches * feat(git): add env config for cache dir * refactor: extract cache dir utilities to shared module Co-authored-by: fbosch <6979916+fbosch@users.noreply.github.com> * docs: clarify timeoutMs is CLI-only option Co-authored-by: fbosch <6979916+fbosch@users.noreply.github.com> * fix: handle zero values correctly and set explicit file protocol config Co-authored-by: fbosch <6979916+fbosch@users.noreply.github.com> * feat(toc): unify toc config into single field --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: fbosch <6979916+fbosch@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent d9cf4e0 commit 2bdbcee

15 files changed

Lines changed: 1054 additions & 97 deletions

README.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ Documentation is cached in a gitignored location, exposed to agent and tool targ
1919
- **Fast**: Local cache avoids network roundtrips after sync.
2020
- **Flexible**: Cache full repos or just the subdirectories you need.
2121

22-
> **Note**: Sources are downloaded to a local cache. If you provide a `targetDir`, `docs-cache` creates a symlink or copy from the cache to that target directory. The target should be outside `.docs`.
22+
> **Note**: Sources are downloaded to a local cache. If you provide a `targetDir`, `docs-cache` creates a symlink or copy from the cache to that target directory.
2323
2424
## Usage
2525

@@ -96,10 +96,10 @@ All fields in `defaults` apply to all sources unless overridden per-source.
9696
| `targetMode` | How to link or copy from the cache to the destination. Default: `"symlink"` on Unix, `"copy"` on Windows. |
9797
| `depth` | Git clone depth. Default: `1`. |
9898
| `required` | Whether missing sources should fail. Default: `true`. |
99-
| `maxBytes` | Maximum total bytes to materialize. Default: `200000000`. |
99+
| `maxBytes` | Maximum total bytes to materialize. Default: `200000000` (200 MB). |
100100
| `maxFiles` | Maximum total files to materialize. |
101101
| `allowHosts` | Allowed Git hosts. Default: `["github.com", "gitlab.com"]`. |
102-
| `toc` | Generate per-source `TOC.md` listing all documentation files. Default: `true`. |
102+
| `toc` | Generate per-source `TOC.md`. Default: `true`. Supports `true`, `false`, or a format (`"tree"`, `"compressed"`). |
103103

104104
### Source options
105105

@@ -122,9 +122,9 @@ All fields in `defaults` apply to all sources unless overridden per-source.
122122
| `required` | Whether missing sources should fail. |
123123
| `maxBytes` | Maximum total bytes to materialize. |
124124
| `maxFiles` | Maximum total files to materialize. |
125-
| `toc` | Generate per-source `TOC.md` listing all documentation files. |
125+
| `toc` | Generate per-source `TOC.md`. Supports `true`, `false`, or a format (`"tree"`, `"compressed"`). |
126126

127-
> **Note**: Sources are always downloaded to `.docs/<id>/`. If you provide a `targetDir`, `docs-cache` will create a symlink or copy pointing from the cache to that target directory. The target should be outside `.docs`.
127+
> **Note**: Sources are always downloaded to `.docs/<id>/`. If you provide a `targetDir`, `docs-cache` will create a symlink or copy pointing from the cache to that target directory. The target should be outside `.docs`. Git operation timeout is configured via the `--timeout-ms` CLI flag, not as a per-source configuration option.
128128
129129
</details>
130130

docs.config.schema.json

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,19 @@
6161
}
6262
},
6363
"toc": {
64-
"type": "boolean"
64+
"anyOf": [
65+
{
66+
"type": "boolean"
67+
},
68+
{
69+
"type": "string",
70+
"enum": ["tree", "compressed"]
71+
}
72+
]
73+
},
74+
"tocFormat": {
75+
"type": "string",
76+
"enum": ["tree", "compressed"]
6577
}
6678
},
6779
"additionalProperties": false
@@ -146,7 +158,19 @@
146158
"additionalProperties": false
147159
},
148160
"toc": {
149-
"type": "boolean"
161+
"anyOf": [
162+
{
163+
"type": "boolean"
164+
},
165+
{
166+
"type": "string",
167+
"enum": ["tree", "compressed"]
168+
}
169+
]
170+
},
171+
"tocFormat": {
172+
"type": "string",
173+
"enum": ["tree", "compressed"]
150174
}
151175
},
152176
"required": ["id", "repo"],

src/api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export { cleanCache } from "./clean";
2+
export { cleanGitCache } from "./clean-git-cache";
23
export { parseArgs } from "./cli/parse-args";
34
export { loadConfig } from "./config";
45
export { redactRepoUrl } from "./git/redact";

src/clean-git-cache.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { readdir, rm, stat } from "node:fs/promises";
2+
import path from "node:path";
3+
4+
import { exists, resolveGitCacheDir } from "./git/cache-dir";
5+
6+
const getDirSize = async (dirPath: string): Promise<number> => {
7+
try {
8+
const entries = await readdir(dirPath, { withFileTypes: true });
9+
let totalSize = 0;
10+
11+
for (const entry of entries) {
12+
const fullPath = path.join(dirPath, entry.name);
13+
if (entry.isDirectory()) {
14+
totalSize += await getDirSize(fullPath);
15+
} else {
16+
const stats = await stat(fullPath);
17+
totalSize += stats.size;
18+
}
19+
}
20+
21+
return totalSize;
22+
} catch {
23+
return 0;
24+
}
25+
};
26+
27+
const countCachedRepos = async (cacheDir: string): Promise<number> => {
28+
try {
29+
const entries = await readdir(cacheDir);
30+
return entries.length;
31+
} catch {
32+
return 0;
33+
}
34+
};
35+
36+
export type CleanGitCacheResult = {
37+
removed: boolean;
38+
cacheDir: string;
39+
repoCount?: number;
40+
bytesFreed?: number;
41+
};
42+
43+
export type CleanGitCacheOptions = {
44+
json?: boolean;
45+
};
46+
47+
export const cleanGitCache = async (
48+
options: CleanGitCacheOptions = {},
49+
): Promise<CleanGitCacheResult> => {
50+
const cacheDir = resolveGitCacheDir();
51+
const cacheExists = await exists(cacheDir);
52+
53+
if (!cacheExists) {
54+
return {
55+
removed: false,
56+
cacheDir,
57+
};
58+
}
59+
60+
// Get stats before removal
61+
const repoCount = await countCachedRepos(cacheDir);
62+
const bytesFreed = await getDirSize(cacheDir);
63+
64+
// Remove the cache directory
65+
await rm(cacheDir, { recursive: true, force: true });
66+
67+
return {
68+
removed: true,
69+
cacheDir,
70+
repoCount,
71+
bytesFreed,
72+
};
73+
};

src/cli/index.ts

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,15 @@ const HELP_TEXT = `
1212
Usage: ${CLI_NAME} <command> [options]
1313
1414
Commands:
15-
add Add sources to the config (supports github:org/repo#ref)
16-
remove Remove sources from the config and targets
17-
sync Synchronize cache with config
18-
status Show cache status
19-
clean Remove cache
20-
prune Remove unused data
21-
verify Validate cache integrity
22-
init Create a new config interactively
15+
add Add sources to the config (supports github:org/repo#ref)
16+
remove Remove sources from the config and targets
17+
sync Synchronize cache with config
18+
status Show cache status
19+
clean Remove project cache
20+
clean-cache Clear global git cache
21+
prune Remove unused data
22+
verify Validate cache integrity
23+
init Create a new config interactively
2324
2425
Global options:
2526
--source <repo> (add only)
@@ -235,6 +236,33 @@ const runCommand = async (
235236
}
236237
return;
237238
}
239+
if (command === "clean-cache") {
240+
const { cleanGitCache } = await import("../clean-git-cache");
241+
const result = await cleanGitCache({
242+
json: options.json,
243+
});
244+
if (options.json) {
245+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
246+
} else if (result.removed) {
247+
const sizeInMB =
248+
result.bytesFreed !== undefined
249+
? `${(result.bytesFreed / 1024 / 1024).toFixed(2)} MB`
250+
: "unknown size";
251+
const repoLabel =
252+
result.repoCount !== undefined
253+
? ` (${result.repoCount} cached repositor${result.repoCount === 1 ? "y" : "ies"})`
254+
: "";
255+
ui.line(
256+
`${symbols.success} Cleared global git cache${repoLabel}: ${sizeInMB} freed`,
257+
);
258+
ui.line(`${symbols.info} Cache location: ${ui.path(result.cacheDir)}`);
259+
} else {
260+
ui.line(
261+
`${symbols.info} Global git cache already empty at ${ui.path(result.cacheDir)}`,
262+
);
263+
}
264+
return;
265+
}
238266
if (command === "prune") {
239267
const { pruneCache } = await import("../prune");
240268
const result = await pruneCache({

src/cli/parse-args.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const COMMANDS = [
1010
"sync",
1111
"status",
1212
"clean",
13+
"clean-cache",
1314
"prune",
1415
"verify",
1516
"init",

src/config-schema.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { z } from "zod";
22

33
export const TargetModeSchema = z.enum(["symlink", "copy"]);
44
export const CacheModeSchema = z.enum(["materialize"]);
5+
export const TocFormatSchema = z.enum(["tree", "compressed"]);
56
export const IntegritySchema = z
67
.object({
78
type: z.enum(["commit", "manifest"]),
@@ -20,7 +21,7 @@ export const DefaultsSchema = z
2021
maxBytes: z.number().min(1),
2122
maxFiles: z.number().min(1).optional(),
2223
allowHosts: z.array(z.string().min(1)).min(1),
23-
toc: z.boolean().optional(),
24+
toc: z.union([z.boolean(), TocFormatSchema]).optional(),
2425
})
2526
.strict();
2627

@@ -39,7 +40,7 @@ export const SourceSchema = z
3940
maxBytes: z.number().min(1).optional(),
4041
maxFiles: z.number().min(1).optional(),
4142
integrity: IntegritySchema.optional(),
42-
toc: z.boolean().optional(),
43+
toc: z.union([z.boolean(), TocFormatSchema]).optional(),
4344
})
4445
.strict();
4546

src/config.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { assertSafeSourceId } from "./source-id";
66

77
export type CacheMode = "materialize";
88

9+
export type TocFormat = "tree" | "compressed";
10+
911
export type IntegrityType = "commit" | "manifest";
1012

1113
export interface DocsCacheIntegrity {
@@ -23,7 +25,7 @@ export interface DocsCacheDefaults {
2325
maxBytes: number;
2426
maxFiles?: number;
2527
allowHosts: string[];
26-
toc?: boolean;
28+
toc?: boolean | TocFormat;
2729
}
2830

2931
export interface DocsCacheSource {
@@ -40,7 +42,7 @@ export interface DocsCacheSource {
4042
maxBytes?: number;
4143
maxFiles?: number;
4244
integrity?: DocsCacheIntegrity;
43-
toc?: boolean;
45+
toc?: boolean | TocFormat;
4446
}
4547

4648
export interface DocsCacheConfig {
@@ -65,7 +67,7 @@ export interface DocsCacheResolvedSource {
6567
maxBytes: number;
6668
maxFiles?: number;
6769
integrity?: DocsCacheIntegrity;
68-
toc?: boolean;
70+
toc?: boolean | TocFormat;
6971
}
7072

7173
export const DEFAULT_CONFIG_FILENAME = "docs.config.json";
@@ -260,6 +262,7 @@ export const validateConfig = (input: unknown): DocsCacheConfig => {
260262
if (!isRecord(defaultsInput)) {
261263
throw new Error("defaults must be an object.");
262264
}
265+
263266
defaults = {
264267
ref:
265268
defaultsInput.ref !== undefined
@@ -299,7 +302,7 @@ export const validateConfig = (input: unknown): DocsCacheConfig => {
299302
: defaultValues.allowHosts,
300303
toc:
301304
defaultsInput.toc !== undefined
302-
? assertBoolean(defaultsInput.toc, "defaults.toc")
305+
? (defaultsInput.toc as boolean | TocFormat)
303306
: defaultValues.toc,
304307
};
305308
} else if (targetModeOverride !== undefined) {
@@ -387,9 +390,11 @@ export const validateConfig = (input: unknown): DocsCacheConfig => {
387390
`sources[${index}].integrity`,
388391
);
389392
}
393+
390394
if (entry.toc !== undefined) {
391-
source.toc = assertBoolean(entry.toc, `sources[${index}].toc`);
395+
source.toc = entry.toc as boolean | TocFormat;
392396
}
397+
393398
return source;
394399
});
395400

src/git/cache-dir.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { access } from "node:fs/promises";
2+
import { homedir } from "node:os";
3+
import path from "node:path";
4+
5+
/**
6+
* Get platform-specific cache directory
7+
* - macOS: ~/Library/Caches
8+
* - Windows: %LOCALAPPDATA% or ~/AppData/Local
9+
* - Linux: $XDG_CACHE_HOME or ~/.cache
10+
*/
11+
export const getCacheBaseDir = (): string => {
12+
const home = homedir();
13+
switch (process.platform) {
14+
case "darwin":
15+
return path.join(home, "Library", "Caches");
16+
case "win32":
17+
return process.env.LOCALAPPDATA || path.join(home, "AppData", "Local");
18+
default:
19+
// Linux and other Unix-like systems (XDG Base Directory)
20+
return process.env.XDG_CACHE_HOME || path.join(home, ".cache");
21+
}
22+
};
23+
24+
/**
25+
* Resolve the git cache directory
26+
* Can be overridden via DOCS_CACHE_GIT_DIR environment variable
27+
*/
28+
export const resolveGitCacheDir = (): string =>
29+
process.env.DOCS_CACHE_GIT_DIR ||
30+
path.join(getCacheBaseDir(), "docs-cache-git");
31+
32+
/**
33+
* Check if a file or directory exists
34+
*/
35+
export const exists = async (filePath: string): Promise<boolean> => {
36+
try {
37+
await access(filePath);
38+
return true;
39+
} catch {
40+
return false;
41+
}
42+
};

0 commit comments

Comments
 (0)