import { join, relative } from "https://deno.land/std/path/mod.ts";
interface CommandEntry {
date: string;
hash: string;
command: string;
message: string;
removed?: boolean;
parent?: string;
}
async function runGit(args: string[], cwd?: string): Promise<string> {
const cmd = new Deno.Command("git", {
args,
cwd,
stdout: "piped",
stderr: "piped",
});
const { stdout, stderr, success } = await cmd.output();
if (!success) {
const errText = new TextDecoder().decode(stderr);
throw new Error(`git ${args.join(" ")} failed: ${errText}`);
}
return new TextDecoder().decode(stdout).trim();
}
async function findGitRoot(): Promise<string> {
return await runGit(["rev-parse", "--show-toplevel"]);
}
function parseGitLogLine(line: string): { date: string; hash: string; message: string } | null {
const match = line.match(/^(\d{4}-\d{2}-\d{2})\s+([a-f0-9]+)\s+(.*)$/);
if (!match) return null;
return { date: match[1], hash: match[2], message: match[3] };
}
async function findDirectoryIntroduction(
dirPath: string,
gitRoot: string
): Promise<CommandEntry | null> {
const relPath = relative(gitRoot, dirPath);
try {
const output = await runGit(
["log", "--diff-filter=A", "--format=%as %h %s", "--reverse", "--", relPath],
gitRoot
);
const lines = output.split("\n").filter((l) => l.trim());
if (lines.length === 0) return null;
const parsed = parseGitLogLine(lines[0]);
if (!parsed) return null;
const commandName = dirPath.split("/").pop() || "";
return {
date: parsed.date,
hash: parsed.hash,
command: commandName,
message: parsed.message,
};
} catch {
return null;
}
}
async function findStringIntroduction(
searchStr: string,
path: string,
gitRoot: string
): Promise<{ date: string; hash: string; message: string } | null> {
const relPath = relative(gitRoot, path);
try {
const output = await runGit(
["log", "-S", searchStr, "--format=%as %h %s", "--reverse", "--", relPath],
gitRoot
);
const lines = output.split("\n").filter((l) => l.trim());
if (lines.length === 0) return null;
return parseGitLogLine(lines[0]);
} catch {
return null;
}
}
async function findStringRemoval(
searchStr: string,
path: string,
gitRoot: string
): Promise<{ date: string; hash: string; message: string } | null> {
const relPath = relative(gitRoot, path);
try {
const output = await runGit(
["log", "-S", searchStr, "--format=%as %h %s", "--", relPath],
gitRoot
);
const lines = output.split("\n").filter((l) => l.trim());
if (lines.length === 0) return null;
return parseGitLogLine(lines[0]);
} catch {
return null;
}
}
const CLIFFY_BUILTINS = new Set(["help", "completions"]);
function extractCliffyCommandNames(content: string): string[] {
const regex = /\.command\s*\(\s*["']([^"']+)["']/g;
const names: string[] = [];
let match;
while ((match = regex.exec(content)) !== null) {
const fullName = match[1];
const cmdName = fullName.split(/\s+/)[0];
if (!CLIFFY_BUILTINS.has(cmdName)) {
names.push(cmdName);
}
}
return names;
}
async function getDirectories(path: string): Promise<string[]> {
const dirs: string[] = [];
try {
for await (const entry of Deno.readDir(path)) {
if (entry.isDirectory) {
dirs.push(entry.name);
}
}
} catch {
}
return dirs.sort();
}
async function scanTopLevelCommands(
commandDir: string,
gitRoot: string
): Promise<CommandEntry[]> {
const entries: CommandEntry[] = [];
const dirs = await getDirectories(commandDir);
for (const dir of dirs) {
const dirPath = join(commandDir, dir);
const entry = await findDirectoryIntroduction(dirPath, gitRoot);
if (entry) {
entries.push(entry);
}
}
return entries.sort((a, b) => a.date.localeCompare(b.date));
}
async function readTsFiles(dir: string): Promise<Map<string, string>> {
const files = new Map<string, string>();
async function walk(currentDir: string) {
try {
for await (const entry of Deno.readDir(currentDir)) {
const fullPath = join(currentDir, entry.name);
if (entry.isDirectory) {
await walk(fullPath);
} else if (entry.name.endsWith(".ts")) {
try {
const content = await Deno.readTextFile(fullPath);
files.set(fullPath, content);
} catch {
}
}
}
} catch {
}
}
await walk(dir);
return files;
}
async function scanCliffySubcommands(
commandDir: string,
gitRoot: string
): Promise<Map<string, CommandEntry[]>> {
const subcommandsByParent = new Map<string, CommandEntry[]>();
const topLevelDirs = await getDirectories(commandDir);
for (const parentCmd of topLevelDirs) {
const parentDir = join(commandDir, parentCmd);
const tsFiles = await readTsFiles(parentDir);
const commandNames = new Set<string>();
for (const [filePath, content] of tsFiles) {
const names = extractCliffyCommandNames(content);
for (const name of names) {
commandNames.add(name);
}
}
if (commandNames.size === 0) continue;
const entries: CommandEntry[] = [];
for (const cmdName of commandNames) {
const searchStr = `.command("${cmdName}`;
const intro = await findStringIntroduction(searchStr, parentDir, gitRoot);
if (!intro) {
const searchStrSingle = `.command('${cmdName}`;
const introSingle = await findStringIntroduction(searchStrSingle, parentDir, gitRoot);
if (introSingle) {
entries.push({
date: introSingle.date,
hash: introSingle.hash,
command: `quarto ${parentCmd} ${cmdName}`,
message: introSingle.message,
parent: parentCmd,
});
}
} else {
entries.push({
date: intro.date,
hash: intro.hash,
command: `quarto ${parentCmd} ${cmdName}`,
message: intro.message,
parent: parentCmd,
});
}
}
if (entries.length > 0) {
entries.sort((a, b) => a.date.localeCompare(b.date));
subcommandsByParent.set(parentCmd, entries);
}
}
return subcommandsByParent;
}
async function scanRemovedCommands(
commandDir: string,
gitRoot: string
): Promise<CommandEntry[]> {
const removed: CommandEntry[] = [];
const relCommandDir = relative(gitRoot, commandDir);
try {
const output = await runGit(
["log", "-p", "--all", "-S", '.command("', "--", relCommandDir],
gitRoot
);
const historicalCommands = new Set<string>();
const addedPattern = /^\+.*\.command\s*\(\s*["']([^"']+)["']/gm;
let match;
while ((match = addedPattern.exec(output)) !== null) {
const cmdName = match[1].split(/\s+/)[0];
if (!CLIFFY_BUILTINS.has(cmdName)) {
historicalCommands.add(cmdName);
}
}
const currentCommands = new Set<string>();
const tsFiles = await readTsFiles(commandDir);
for (const [_, content] of tsFiles) {
const names = extractCliffyCommandNames(content);
for (const name of names) {
currentCommands.add(name);
}
}
for (const cmd of historicalCommands) {
if (!currentCommands.has(cmd)) {
const searchStr = `.command("${cmd}`;
const removal = await findStringRemoval(searchStr, commandDir, gitRoot);
if (removal) {
removed.push({
date: removal.date,
hash: removal.hash,
command: `quarto ??? ${cmd}`,
message: removal.message,
removed: true,
});
}
}
}
} catch (e) {
console.error("Error scanning for removed commands:", e);
}
return removed.sort((a, b) => a.date.localeCompare(b.date));
}
function formatTable(
entries: CommandEntry[],
columns: ("date" | "hash" | "command" | "message")[]
): string {
if (entries.length === 0) return "_No entries found_\n";
const headers: Record<string, string> = {
date: "Date",
hash: "Hash",
command: "Command",
message: "Commit Message",
};
const allRows: string[][] = [];
allRows.push(columns.map((c) => headers[c]));
for (const e of entries) {
const row = columns.map((c) => {
if (c === "command" && e.removed) {
return `~~${e[c]}~~`;
}
return e[c] || "";
});
allRows.push(row);
}
const colWidths = columns.map((_, i) =>
Math.max(...allRows.map((row) => row[i].length))
);
const headerRow =
"| " +
columns.map((c, i) => headers[c].padEnd(colWidths[i])).join(" | ") +
" |";
const separatorRow =
"|" + colWidths.map((w) => "-".repeat(w + 2)).join("|") + "|";
const dataRows = entries.map((e) => {
const values = columns.map((c, i) => {
let val: string;
if (c === "command" && e.removed) {
val = `~~${e[c]}~~`;
} else {
val = e[c] || "";
}
return val.padEnd(colWidths[i]);
});
return "| " + values.join(" | ") + " |";
});
return [headerRow, separatorRow, ...dataRows].join("\n") + "\n";
}
async function scanPublishProviders(
publishDir: string,
gitRoot: string
): Promise<CommandEntry[]> {
const entries: CommandEntry[] = [];
const dirs = await getDirectories(publishDir);
const excludeDirs = new Set(["common"]);
for (const dir of dirs) {
if (excludeDirs.has(dir)) continue;
const dirPath = join(publishDir, dir);
const entry = await findDirectoryIntroduction(dirPath, gitRoot);
if (entry) {
entries.push({
...entry,
command: `quarto publish ${dir}`,
parent: "publish",
});
}
}
return entries.sort((a, b) => a.date.localeCompare(b.date));
}
async function scanRemovedPublishProviders(
publishDir: string,
gitRoot: string
): Promise<CommandEntry[]> {
const removed: CommandEntry[] = [];
const relPublishDir = relative(gitRoot, publishDir);
try {
const output = await runGit(
["log", "--all", "--name-status", "--diff-filter=D", "--", relPublishDir],
gitRoot
);
const deletedDirs = new Set<string>();
const deletePattern = /^D\s+src\/publish\/([^/]+)\//gm;
let match;
while ((match = deletePattern.exec(output)) !== null) {
const dir = match[1];
if (dir !== "common") {
deletedDirs.add(dir);
}
}
const currentDirs = new Set(await getDirectories(publishDir));
for (const dir of deletedDirs) {
if (!currentDirs.has(dir)) {
const searchStr = `src/publish/${dir}/`;
const removalOutput = await runGit(
["log", "--diff-filter=D", "--format=%as %h %s", "-1", "--", searchStr],
gitRoot
);
const parsed = parseGitLogLine(removalOutput);
if (parsed) {
removed.push({
date: parsed.date,
hash: parsed.hash,
command: `quarto publish ${dir}`,
message: parsed.message,
removed: true,
parent: "publish",
});
}
}
}
} catch (e) {
console.error("Error scanning for removed publish providers:", e);
}
return removed.sort((a, b) => a.date.localeCompare(b.date));
}
async function main() {
const gitRoot = await findGitRoot();
const commandDir = join(gitRoot, "src/command");
const publishDir = join(gitRoot, "src/publish");
console.log("# Quarto CLI Command History\n");
console.log("_Generated by analyzing git history of cliffy `.command()` registrations and directory structure._\n");
console.log("## Top-Level Commands (`quarto <verb>`)\n");
const topLevel = await scanTopLevelCommands(commandDir, gitRoot);
console.log(formatTable(topLevel, ["date", "hash", "command", "message"]));
console.log("\n## Subcommands (`quarto <verb> <noun>`)\n");
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");
const subcommands = await scanCliffySubcommands(commandDir, gitRoot);
const publishProviders = await scanPublishProviders(publishDir, gitRoot);
if (publishProviders.length > 0) {
subcommands.set("publish", publishProviders);
}
const sortedParents = [...subcommands.keys()].sort();
for (const parent of sortedParents) {
const entries = subcommands.get(parent)!;
console.log(`### ${parent}\n`);
console.log(formatTable(entries, ["date", "hash", "command"]));
console.log("");
}
console.log("\n## Removed Commands\n");
const removedCliffy = await scanRemovedCommands(commandDir, gitRoot);
const removedPublish = await scanRemovedPublishProviders(publishDir, gitRoot);
const allRemoved = [...removedCliffy, ...removedPublish].sort((a, b) =>
a.date.localeCompare(b.date)
);
if (allRemoved.length > 0) {
console.log(formatTable(allRemoved, ["date", "hash", "command", "message"]));
} else {
console.log("_No removed commands detected._\n");
}
}
main().catch((e) => {
console.error("Error:", e.message);
Deno.exit(1);
});