Skip to content
Merged
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
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Documentation is cached in a gitignored location, exposed to agent and tool targ
- **Fast**: Local cache avoids network roundtrips after sync.
- **Flexible**: Cache full repos or just the subdirectories you need.

> **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`.
> **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.

## Usage

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

### Source options

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

> **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`.
> **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.

</details>

Expand Down
28 changes: 26 additions & 2 deletions docs.config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,19 @@
}
},
"toc": {
"type": "boolean"
"anyOf": [
{
"type": "boolean"
},
{
"type": "string",
"enum": ["tree", "compressed"]
}
]
},
"tocFormat": {
"type": "string",
"enum": ["tree", "compressed"]
}
},
"additionalProperties": false
Expand Down Expand Up @@ -146,7 +158,19 @@
"additionalProperties": false
},
"toc": {
"type": "boolean"
"anyOf": [
{
"type": "boolean"
},
{
"type": "string",
"enum": ["tree", "compressed"]
}
]
},
"tocFormat": {
"type": "string",
"enum": ["tree", "compressed"]
}
},
"required": ["id", "repo"],
Expand Down
1 change: 1 addition & 0 deletions src/api.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { cleanCache } from "./clean";
export { cleanGitCache } from "./clean-git-cache";
export { parseArgs } from "./cli/parse-args";
export { loadConfig } from "./config";
export { redactRepoUrl } from "./git/redact";
Expand Down
73 changes: 73 additions & 0 deletions src/clean-git-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { readdir, rm, stat } from "node:fs/promises";
import path from "node:path";

import { exists, resolveGitCacheDir } from "./git/cache-dir";

const getDirSize = async (dirPath: string): Promise<number> => {
try {
const entries = await readdir(dirPath, { withFileTypes: true });
let totalSize = 0;

for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name);
if (entry.isDirectory()) {
totalSize += await getDirSize(fullPath);
} else {
const stats = await stat(fullPath);
totalSize += stats.size;
}
}

return totalSize;
} catch {
return 0;
}
};

const countCachedRepos = async (cacheDir: string): Promise<number> => {
try {
const entries = await readdir(cacheDir);
return entries.length;
} catch {
return 0;
}
};

export type CleanGitCacheResult = {
removed: boolean;
cacheDir: string;
repoCount?: number;
bytesFreed?: number;
};

export type CleanGitCacheOptions = {
json?: boolean;
};

export const cleanGitCache = async (
options: CleanGitCacheOptions = {},
): Promise<CleanGitCacheResult> => {
const cacheDir = resolveGitCacheDir();
const cacheExists = await exists(cacheDir);

if (!cacheExists) {
return {
removed: false,
cacheDir,
};
}

// Get stats before removal
const repoCount = await countCachedRepos(cacheDir);
const bytesFreed = await getDirSize(cacheDir);

// Remove the cache directory
await rm(cacheDir, { recursive: true, force: true });

return {
removed: true,
cacheDir,
repoCount,
bytesFreed,
};
};
44 changes: 36 additions & 8 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,15 @@ const HELP_TEXT = `
Usage: ${CLI_NAME} <command> [options]

Commands:
add Add sources to the config (supports github:org/repo#ref)
remove Remove sources from the config and targets
sync Synchronize cache with config
status Show cache status
clean Remove cache
prune Remove unused data
verify Validate cache integrity
init Create a new config interactively
add Add sources to the config (supports github:org/repo#ref)
remove Remove sources from the config and targets
sync Synchronize cache with config
status Show cache status
clean Remove project cache
clean-cache Clear global git cache
prune Remove unused data
verify Validate cache integrity
init Create a new config interactively

Global options:
--source <repo> (add only)
Expand Down Expand Up @@ -235,6 +236,33 @@ const runCommand = async (
}
return;
}
if (command === "clean-cache") {
const { cleanGitCache } = await import("../clean-git-cache");
const result = await cleanGitCache({
json: options.json,
});
if (options.json) {
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
} else if (result.removed) {
const sizeInMB =
result.bytesFreed !== undefined
? `${(result.bytesFreed / 1024 / 1024).toFixed(2)} MB`
: "unknown size";
const repoLabel =
result.repoCount !== undefined
? ` (${result.repoCount} cached repositor${result.repoCount === 1 ? "y" : "ies"})`
: "";
ui.line(
`${symbols.success} Cleared global git cache${repoLabel}: ${sizeInMB} freed`,
);
ui.line(`${symbols.info} Cache location: ${ui.path(result.cacheDir)}`);
} else {
ui.line(
`${symbols.info} Global git cache already empty at ${ui.path(result.cacheDir)}`,
);
}
return;
}
if (command === "prune") {
const { pruneCache } = await import("../prune");
const result = await pruneCache({
Expand Down
1 change: 1 addition & 0 deletions src/cli/parse-args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const COMMANDS = [
"sync",
"status",
"clean",
"clean-cache",
"prune",
"verify",
"init",
Expand Down
5 changes: 3 additions & 2 deletions src/config-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { z } from "zod";

export const TargetModeSchema = z.enum(["symlink", "copy"]);
export const CacheModeSchema = z.enum(["materialize"]);
export const TocFormatSchema = z.enum(["tree", "compressed"]);
export const IntegritySchema = z
.object({
type: z.enum(["commit", "manifest"]),
Expand All @@ -20,7 +21,7 @@ export const DefaultsSchema = z
maxBytes: z.number().min(1),
maxFiles: z.number().min(1).optional(),
allowHosts: z.array(z.string().min(1)).min(1),
toc: z.boolean().optional(),
toc: z.union([z.boolean(), TocFormatSchema]).optional(),
})
.strict();

Expand All @@ -39,7 +40,7 @@ export const SourceSchema = z
maxBytes: z.number().min(1).optional(),
maxFiles: z.number().min(1).optional(),
integrity: IntegritySchema.optional(),
toc: z.boolean().optional(),
toc: z.union([z.boolean(), TocFormatSchema]).optional(),
})
.strict();

Expand Down
15 changes: 10 additions & 5 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { assertSafeSourceId } from "./source-id";

export type CacheMode = "materialize";

export type TocFormat = "tree" | "compressed";

export type IntegrityType = "commit" | "manifest";

export interface DocsCacheIntegrity {
Expand All @@ -23,7 +25,7 @@ export interface DocsCacheDefaults {
maxBytes: number;
maxFiles?: number;
allowHosts: string[];
toc?: boolean;
toc?: boolean | TocFormat;
}

export interface DocsCacheSource {
Expand All @@ -40,7 +42,7 @@ export interface DocsCacheSource {
maxBytes?: number;
maxFiles?: number;
integrity?: DocsCacheIntegrity;
toc?: boolean;
toc?: boolean | TocFormat;
}

export interface DocsCacheConfig {
Expand All @@ -65,7 +67,7 @@ export interface DocsCacheResolvedSource {
maxBytes: number;
maxFiles?: number;
integrity?: DocsCacheIntegrity;
toc?: boolean;
toc?: boolean | TocFormat;
}

export const DEFAULT_CONFIG_FILENAME = "docs.config.json";
Expand Down Expand Up @@ -260,6 +262,7 @@ export const validateConfig = (input: unknown): DocsCacheConfig => {
if (!isRecord(defaultsInput)) {
throw new Error("defaults must be an object.");
}

defaults = {
ref:
defaultsInput.ref !== undefined
Expand Down Expand Up @@ -299,7 +302,7 @@ export const validateConfig = (input: unknown): DocsCacheConfig => {
: defaultValues.allowHosts,
toc:
defaultsInput.toc !== undefined
? assertBoolean(defaultsInput.toc, "defaults.toc")
? (defaultsInput.toc as boolean | TocFormat)
: defaultValues.toc,
};
} else if (targetModeOverride !== undefined) {
Expand Down Expand Up @@ -387,9 +390,11 @@ export const validateConfig = (input: unknown): DocsCacheConfig => {
`sources[${index}].integrity`,
);
}

if (entry.toc !== undefined) {
source.toc = assertBoolean(entry.toc, `sources[${index}].toc`);
source.toc = entry.toc as boolean | TocFormat;
}

return source;
});

Expand Down
42 changes: 42 additions & 0 deletions src/git/cache-dir.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { access } from "node:fs/promises";
import { homedir } from "node:os";
import path from "node:path";

/**
* Get platform-specific cache directory
* - macOS: ~/Library/Caches
* - Windows: %LOCALAPPDATA% or ~/AppData/Local
* - Linux: $XDG_CACHE_HOME or ~/.cache
*/
export const getCacheBaseDir = (): string => {
const home = homedir();
switch (process.platform) {
case "darwin":
return path.join(home, "Library", "Caches");
case "win32":
return process.env.LOCALAPPDATA || path.join(home, "AppData", "Local");
default:
// Linux and other Unix-like systems (XDG Base Directory)
return process.env.XDG_CACHE_HOME || path.join(home, ".cache");
}
};

/**
* Resolve the git cache directory
* Can be overridden via DOCS_CACHE_GIT_DIR environment variable
*/
export const resolveGitCacheDir = (): string =>
process.env.DOCS_CACHE_GIT_DIR ||
path.join(getCacheBaseDir(), "docs-cache-git");

/**
* Check if a file or directory exists
*/
export const exists = async (filePath: string): Promise<boolean> => {
try {
await access(filePath);
return true;
} catch {
return false;
}
};
Loading
Loading