Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
quarto-dev
GitHub Repository: quarto-dev/quarto-cli
Path: blob/main/tools/subcommand-history.ts
6453 views
1
/**
2
* subcommand-history.ts
3
*
4
* Analyzes git history of a cliffy-based CLI to produce a timeline
5
* of when commands/subcommands were introduced and removed.
6
*
7
* Usage: quarto run tools/subcommand-history.ts
8
*
9
* ## What this tool detects
10
*
11
* 1. **Top-level commands** - directories under `src/command/` (e.g., `render/`, `publish/`)
12
* 2. **Cliffy subcommands** - registered via `.command()` API
13
* 3. **Publish providers** - directories under `src/publish/` (e.g., `netlify/`, `gh-pages/`)
14
*
15
* ## What this tool cannot detect
16
*
17
* Commands that parse arguments internally rather than using cliffy's subcommand system.
18
*
19
* **Cliffy `.command()` registration** (detected):
20
* ```typescript
21
* // In call/cmd.ts
22
* export const callCommand = new Command()
23
* .command("engine", engineCommand) // ← Creates "quarto call engine"
24
* .command("build-ts-extension", ...) // ← Creates "quarto call build-ts-extension"
25
* ```
26
* Cliffy handles routing to these subcommands automatically.
27
*
28
* **Internal argument parsing** (not detected):
29
* ```typescript
30
* // In install/cmd.ts
31
* export const installCommand = new Command()
32
* .arguments("[target...]") // ← Just takes arguments
33
* .action(async (options, ...target) => {
34
* if (target === "tinytex") { ... } // ← Code checks the value manually
35
* if (target === "chromium") { ... }
36
* });
37
* ```
38
* Here `tinytex` isn't a registered subcommand - it's just an argument value the code
39
* checks for.
40
*
41
* Both look the same to users (`quarto call engine` vs `quarto install tinytex`), but
42
* they're implemented differently. This tool can only detect the first pattern by
43
* searching for `.command("..."` in the code.
44
*/
45
46
import { join, relative } from "https://deno.land/std/path/mod.ts";
47
48
interface CommandEntry {
49
date: string;
50
hash: string;
51
command: string;
52
message: string;
53
removed?: boolean;
54
parent?: string; // for subcommands, the parent command name
55
}
56
57
// Execute git command and return stdout
58
async function runGit(args: string[], cwd?: string): Promise<string> {
59
const cmd = new Deno.Command("git", {
60
args,
61
cwd,
62
stdout: "piped",
63
stderr: "piped",
64
});
65
const { stdout, stderr, success } = await cmd.output();
66
if (!success) {
67
const errText = new TextDecoder().decode(stderr);
68
throw new Error(`git ${args.join(" ")} failed: ${errText}`);
69
}
70
return new TextDecoder().decode(stdout).trim();
71
}
72
73
// Find the git repository root
74
async function findGitRoot(): Promise<string> {
75
return await runGit(["rev-parse", "--show-toplevel"]);
76
}
77
78
// Parse git log output line: "YYYY-MM-DD hash message"
79
function parseGitLogLine(line: string): { date: string; hash: string; message: string } | null {
80
const match = line.match(/^(\d{4}-\d{2}-\d{2})\s+([a-f0-9]+)\s+(.*)$/);
81
if (!match) return null;
82
return { date: match[1], hash: match[2], message: match[3] };
83
}
84
85
// Find when a directory was first added to git
86
async function findDirectoryIntroduction(
87
dirPath: string,
88
gitRoot: string
89
): Promise<CommandEntry | null> {
90
const relPath = relative(gitRoot, dirPath);
91
try {
92
// Get the oldest commit that added files to this directory
93
const output = await runGit(
94
["log", "--diff-filter=A", "--format=%as %h %s", "--reverse", "--", relPath],
95
gitRoot
96
);
97
const lines = output.split("\n").filter((l) => l.trim());
98
if (lines.length === 0) return null;
99
100
const parsed = parseGitLogLine(lines[0]);
101
if (!parsed) return null;
102
103
const commandName = dirPath.split("/").pop() || "";
104
return {
105
date: parsed.date,
106
hash: parsed.hash,
107
command: commandName,
108
message: parsed.message,
109
};
110
} catch {
111
return null;
112
}
113
}
114
115
// Find when a string pattern was introduced (oldest commit containing it)
116
async function findStringIntroduction(
117
searchStr: string,
118
path: string,
119
gitRoot: string
120
): Promise<{ date: string; hash: string; message: string } | null> {
121
const relPath = relative(gitRoot, path);
122
try {
123
// -S finds commits where the string count changed (added or removed)
124
// --reverse gives oldest first
125
const output = await runGit(
126
["log", "-S", searchStr, "--format=%as %h %s", "--reverse", "--", relPath],
127
gitRoot
128
);
129
const lines = output.split("\n").filter((l) => l.trim());
130
if (lines.length === 0) return null;
131
132
return parseGitLogLine(lines[0]);
133
} catch {
134
return null;
135
}
136
}
137
138
// Find when a string pattern was removed (most recent commit where it was removed)
139
async function findStringRemoval(
140
searchStr: string,
141
path: string,
142
gitRoot: string
143
): Promise<{ date: string; hash: string; message: string } | null> {
144
const relPath = relative(gitRoot, path);
145
try {
146
// Get most recent commit that changed this string (without --reverse, newest first)
147
const output = await runGit(
148
["log", "-S", searchStr, "--format=%as %h %s", "--", relPath],
149
gitRoot
150
);
151
const lines = output.split("\n").filter((l) => l.trim());
152
if (lines.length === 0) return null;
153
154
// The most recent commit is the removal
155
return parseGitLogLine(lines[0]);
156
} catch {
157
return null;
158
}
159
}
160
161
// Cliffy built-in commands that are inherited by all commands
162
const CLIFFY_BUILTINS = new Set(["help", "completions"]);
163
164
// Extract command names from .command("name" patterns
165
function extractCliffyCommandNames(content: string): string[] {
166
const regex = /\.command\s*\(\s*["']([^"']+)["']/g;
167
const names: string[] = [];
168
let match;
169
while ((match = regex.exec(content)) !== null) {
170
// Extract just the command name (before any space for arguments like "install <tool:string>")
171
const fullName = match[1];
172
const cmdName = fullName.split(/\s+/)[0];
173
// Skip cliffy built-in commands
174
if (!CLIFFY_BUILTINS.has(cmdName)) {
175
names.push(cmdName);
176
}
177
}
178
return names;
179
}
180
181
// Get all directories in a path
182
async function getDirectories(path: string): Promise<string[]> {
183
const dirs: string[] = [];
184
try {
185
for await (const entry of Deno.readDir(path)) {
186
if (entry.isDirectory) {
187
dirs.push(entry.name);
188
}
189
}
190
} catch {
191
// Directory doesn't exist
192
}
193
return dirs.sort();
194
}
195
196
// Scan for top-level commands (directories in src/command/)
197
async function scanTopLevelCommands(
198
commandDir: string,
199
gitRoot: string
200
): Promise<CommandEntry[]> {
201
const entries: CommandEntry[] = [];
202
const dirs = await getDirectories(commandDir);
203
204
for (const dir of dirs) {
205
const dirPath = join(commandDir, dir);
206
const entry = await findDirectoryIntroduction(dirPath, gitRoot);
207
if (entry) {
208
entries.push(entry);
209
}
210
}
211
212
return entries.sort((a, b) => a.date.localeCompare(b.date));
213
}
214
215
// Read all TypeScript files in a directory recursively
216
async function readTsFiles(dir: string): Promise<Map<string, string>> {
217
const files = new Map<string, string>();
218
219
async function walk(currentDir: string) {
220
try {
221
for await (const entry of Deno.readDir(currentDir)) {
222
const fullPath = join(currentDir, entry.name);
223
if (entry.isDirectory) {
224
await walk(fullPath);
225
} else if (entry.name.endsWith(".ts")) {
226
try {
227
const content = await Deno.readTextFile(fullPath);
228
files.set(fullPath, content);
229
} catch {
230
// Skip unreadable files
231
}
232
}
233
}
234
} catch {
235
// Skip unreadable directories
236
}
237
}
238
239
await walk(dir);
240
return files;
241
}
242
243
// Scan for cliffy subcommands in command directories
244
async function scanCliffySubcommands(
245
commandDir: string,
246
gitRoot: string
247
): Promise<Map<string, CommandEntry[]>> {
248
const subcommandsByParent = new Map<string, CommandEntry[]>();
249
const topLevelDirs = await getDirectories(commandDir);
250
251
for (const parentCmd of topLevelDirs) {
252
const parentDir = join(commandDir, parentCmd);
253
const tsFiles = await readTsFiles(parentDir);
254
255
// Collect all .command() names in this parent's directory
256
const commandNames = new Set<string>();
257
for (const [filePath, content] of tsFiles) {
258
const names = extractCliffyCommandNames(content);
259
for (const name of names) {
260
commandNames.add(name);
261
}
262
}
263
264
if (commandNames.size === 0) continue;
265
266
const entries: CommandEntry[] = [];
267
for (const cmdName of commandNames) {
268
// Search for when this .command("name" was introduced
269
const searchStr = `.command("${cmdName}`;
270
const intro = await findStringIntroduction(searchStr, parentDir, gitRoot);
271
272
// Also try single quotes
273
if (!intro) {
274
const searchStrSingle = `.command('${cmdName}`;
275
const introSingle = await findStringIntroduction(searchStrSingle, parentDir, gitRoot);
276
if (introSingle) {
277
entries.push({
278
date: introSingle.date,
279
hash: introSingle.hash,
280
command: `quarto ${parentCmd} ${cmdName}`,
281
message: introSingle.message,
282
parent: parentCmd,
283
});
284
}
285
} else {
286
entries.push({
287
date: intro.date,
288
hash: intro.hash,
289
command: `quarto ${parentCmd} ${cmdName}`,
290
message: intro.message,
291
parent: parentCmd,
292
});
293
}
294
}
295
296
if (entries.length > 0) {
297
entries.sort((a, b) => a.date.localeCompare(b.date));
298
subcommandsByParent.set(parentCmd, entries);
299
}
300
}
301
302
return subcommandsByParent;
303
}
304
305
// Search git history for .command() patterns that no longer exist in HEAD
306
async function scanRemovedCommands(
307
commandDir: string,
308
gitRoot: string
309
): Promise<CommandEntry[]> {
310
const removed: CommandEntry[] = [];
311
const relCommandDir = relative(gitRoot, commandDir);
312
313
// Get all .command("X") patterns that ever existed in git history
314
// Using git log -p to see actual diff content
315
try {
316
const output = await runGit(
317
["log", "-p", "--all", "-S", '.command("', "--", relCommandDir],
318
gitRoot
319
);
320
321
// Extract all command names from the diff output (lines starting with +)
322
const historicalCommands = new Set<string>();
323
const addedPattern = /^\+.*\.command\s*\(\s*["']([^"']+)["']/gm;
324
let match;
325
while ((match = addedPattern.exec(output)) !== null) {
326
const cmdName = match[1].split(/\s+/)[0];
327
// Skip cliffy built-in commands
328
if (!CLIFFY_BUILTINS.has(cmdName)) {
329
historicalCommands.add(cmdName);
330
}
331
}
332
333
// Get current commands in HEAD
334
const currentCommands = new Set<string>();
335
const tsFiles = await readTsFiles(commandDir);
336
for (const [_, content] of tsFiles) {
337
const names = extractCliffyCommandNames(content);
338
for (const name of names) {
339
currentCommands.add(name);
340
}
341
}
342
343
// Find commands that existed historically but not now
344
for (const cmd of historicalCommands) {
345
if (!currentCommands.has(cmd)) {
346
// Find when it was removed (most recent commit that touched this pattern)
347
const searchStr = `.command("${cmd}`;
348
const removal = await findStringRemoval(searchStr, commandDir, gitRoot);
349
if (removal) {
350
// Try to figure out the parent command from the git diff context
351
// For now, just use the command name
352
removed.push({
353
date: removal.date,
354
hash: removal.hash,
355
command: `quarto ??? ${cmd}`,
356
message: removal.message,
357
removed: true,
358
});
359
}
360
}
361
}
362
} catch (e) {
363
console.error("Error scanning for removed commands:", e);
364
}
365
366
return removed.sort((a, b) => a.date.localeCompare(b.date));
367
}
368
369
// Format entries as markdown table with aligned columns
370
function formatTable(
371
entries: CommandEntry[],
372
columns: ("date" | "hash" | "command" | "message")[]
373
): string {
374
if (entries.length === 0) return "_No entries found_\n";
375
376
const headers: Record<string, string> = {
377
date: "Date",
378
hash: "Hash",
379
command: "Command",
380
message: "Commit Message",
381
};
382
383
// Build all cell values first
384
const allRows: string[][] = [];
385
386
// Header row
387
allRows.push(columns.map((c) => headers[c]));
388
389
// Data rows
390
for (const e of entries) {
391
const row = columns.map((c) => {
392
if (c === "command" && e.removed) {
393
return `~~${e[c]}~~`;
394
}
395
return e[c] || "";
396
});
397
allRows.push(row);
398
}
399
400
// Calculate max width for each column
401
const colWidths = columns.map((_, i) =>
402
Math.max(...allRows.map((row) => row[i].length))
403
);
404
405
// Format header row with padding
406
const headerRow =
407
"| " +
408
columns.map((c, i) => headers[c].padEnd(colWidths[i])).join(" | ") +
409
" |";
410
411
// Format separator row with dashes
412
const separatorRow =
413
"|" + colWidths.map((w) => "-".repeat(w + 2)).join("|") + "|";
414
415
// Format data rows with padding
416
const dataRows = entries.map((e) => {
417
const values = columns.map((c, i) => {
418
let val: string;
419
if (c === "command" && e.removed) {
420
val = `~~${e[c]}~~`;
421
} else {
422
val = e[c] || "";
423
}
424
return val.padEnd(colWidths[i]);
425
});
426
return "| " + values.join(" | ") + " |";
427
});
428
429
return [headerRow, separatorRow, ...dataRows].join("\n") + "\n";
430
}
431
432
// Scan publish providers (directories in src/publish/)
433
async function scanPublishProviders(
434
publishDir: string,
435
gitRoot: string
436
): Promise<CommandEntry[]> {
437
const entries: CommandEntry[] = [];
438
const dirs = await getDirectories(publishDir);
439
440
// Exclude non-provider directories/files
441
const excludeDirs = new Set(["common"]);
442
443
for (const dir of dirs) {
444
if (excludeDirs.has(dir)) continue;
445
446
const dirPath = join(publishDir, dir);
447
const entry = await findDirectoryIntroduction(dirPath, gitRoot);
448
if (entry) {
449
entries.push({
450
...entry,
451
command: `quarto publish ${dir}`,
452
parent: "publish",
453
});
454
}
455
}
456
457
return entries.sort((a, b) => a.date.localeCompare(b.date));
458
}
459
460
// Find removed publish providers by checking git history
461
async function scanRemovedPublishProviders(
462
publishDir: string,
463
gitRoot: string
464
): Promise<CommandEntry[]> {
465
const removed: CommandEntry[] = [];
466
const relPublishDir = relative(gitRoot, publishDir);
467
468
try {
469
// Get all directories that ever existed in src/publish/
470
const output = await runGit(
471
["log", "--all", "--name-status", "--diff-filter=D", "--", relPublishDir],
472
gitRoot
473
);
474
475
// Extract deleted directory names
476
const deletedDirs = new Set<string>();
477
const deletePattern = /^D\s+src\/publish\/([^/]+)\//gm;
478
let match;
479
while ((match = deletePattern.exec(output)) !== null) {
480
const dir = match[1];
481
if (dir !== "common") {
482
deletedDirs.add(dir);
483
}
484
}
485
486
// Get current providers
487
const currentDirs = new Set(await getDirectories(publishDir));
488
489
// Find providers that were deleted
490
for (const dir of deletedDirs) {
491
if (!currentDirs.has(dir)) {
492
// Find when it was removed
493
const searchStr = `src/publish/${dir}/`;
494
const removalOutput = await runGit(
495
["log", "--diff-filter=D", "--format=%as %h %s", "-1", "--", searchStr],
496
gitRoot
497
);
498
const parsed = parseGitLogLine(removalOutput);
499
if (parsed) {
500
removed.push({
501
date: parsed.date,
502
hash: parsed.hash,
503
command: `quarto publish ${dir}`,
504
message: parsed.message,
505
removed: true,
506
parent: "publish",
507
});
508
}
509
}
510
}
511
} catch (e) {
512
console.error("Error scanning for removed publish providers:", e);
513
}
514
515
return removed.sort((a, b) => a.date.localeCompare(b.date));
516
}
517
518
// Main function
519
async function main() {
520
const gitRoot = await findGitRoot();
521
const commandDir = join(gitRoot, "src/command");
522
const publishDir = join(gitRoot, "src/publish");
523
524
console.log("# Quarto CLI Command History\n");
525
console.log("_Generated by analyzing git history of cliffy `.command()` registrations and directory structure._\n");
526
527
// 1. Top-level commands
528
console.log("## Top-Level Commands (`quarto <verb>`)\n");
529
const topLevel = await scanTopLevelCommands(commandDir, gitRoot);
530
console.log(formatTable(topLevel, ["date", "hash", "command", "message"]));
531
532
// 2. Subcommands grouped by parent
533
console.log("\n## Subcommands (`quarto <verb> <noun>`)\n");
534
console.log("_Note: Only subcommands registered via cliffy's `.command()` API are tracked. Commands that parse their arguments internally (e.g., `quarto install tinytex`, `quarto check jupyter`) are not detected._\n");
535
const subcommands = await scanCliffySubcommands(commandDir, gitRoot);
536
537
// Add publish providers as subcommands
538
const publishProviders = await scanPublishProviders(publishDir, gitRoot);
539
if (publishProviders.length > 0) {
540
subcommands.set("publish", publishProviders);
541
}
542
543
// Sort parent commands alphabetically
544
const sortedParents = [...subcommands.keys()].sort();
545
for (const parent of sortedParents) {
546
const entries = subcommands.get(parent)!;
547
console.log(`### ${parent}\n`);
548
console.log(formatTable(entries, ["date", "hash", "command"]));
549
console.log("");
550
}
551
552
// 3. Removed commands
553
console.log("\n## Removed Commands\n");
554
const removedCliffy = await scanRemovedCommands(commandDir, gitRoot);
555
const removedPublish = await scanRemovedPublishProviders(publishDir, gitRoot);
556
const allRemoved = [...removedCliffy, ...removedPublish].sort((a, b) =>
557
a.date.localeCompare(b.date)
558
);
559
560
if (allRemoved.length > 0) {
561
console.log(formatTable(allRemoved, ["date", "hash", "command", "message"]));
562
} else {
563
console.log("_No removed commands detected._\n");
564
}
565
}
566
567
main().catch((e) => {
568
console.error("Error:", e.message);
569
Deno.exit(1);
570
});
571
572