Skip to content

Commit ba8591b

Browse files
committed
computer/git: make per-command help match the parsers
The usage lines went in by hand and several described a command that does not exist. hash-object advertised a positional path when it only reads --stdin, reset offered --soft and --mixed when both are explicitly refused, and log omitted the --format it had just gained. A caller following that help got exit 129 for doing what it said. Correct every line against the flags its parser actually declares, including the short spellings that were missing, and add a test that walks each advertised long flag back through the command it belongs to. Drift now fails a test rather than reaching a caller. Normalize --pretty to --format while parsing, rather than reading whichever key happened to be set. They are one option in real git, so the last one written should win; before this, --format always did regardless of order.
1 parent ef2edd3 commit ba8591b

2 files changed

Lines changed: 109 additions & 28 deletions

File tree

packages/computer/src/git/cli.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2441,9 +2441,83 @@ describe("runGitCli — log --format", () => {
24412441
expect(res.stdout).toBe("aaaaaaa %zz\n");
24422442
});
24432443

2444+
it("lets the last of --format and --pretty win, in either order", async () => {
2445+
const { client } = fakeClient({}, { log: () => [sample("a".repeat(40), "release")] });
2446+
const formatThenPretty = await runGitCli(client, {
2447+
argv: ["log", "--format=%s", "--pretty=%h"],
2448+
});
2449+
expect(formatThenPretty.stdout).toBe("aaaaaaa\n");
2450+
const prettyThenFormat = await runGitCli(client, {
2451+
argv: ["log", "--pretty=%h", "--format=%s"],
2452+
});
2453+
expect(prettyThenFormat.stdout).toBe("release\n");
2454+
});
2455+
24442456
it("rejects --format combined with --oneline", async () => {
24452457
const { client } = fakeClient();
24462458
const res = await runGitCli(client, { argv: ["log", "--oneline", "--format=%h"] });
24472459
expect(res.exitCode).toBe(129);
24482460
});
24492461
});
2462+
2463+
describe("runGitCli — help stays honest about the flags each command takes", () => {
2464+
// The usage lines are maintained by hand, so they can drift from the
2465+
// parsers they describe. Rather than re-check every line by eye, take
2466+
// each long flag the help advertises and confirm the command does not
2467+
// reject it as unknown or unsupported. A flag that no longer exists,
2468+
// or was never accepted, fails here instead of misleading a caller.
2469+
const commands = [
2470+
"add",
2471+
"branch",
2472+
"cat-file",
2473+
"checkout",
2474+
"clean",
2475+
"clone",
2476+
"commit",
2477+
"config",
2478+
"diff",
2479+
"fetch",
2480+
"hash-object",
2481+
"init",
2482+
"log",
2483+
"ls-files",
2484+
"ls-tree",
2485+
"merge",
2486+
"pull",
2487+
"push",
2488+
"remote",
2489+
"reset",
2490+
"rev-parse",
2491+
"rm",
2492+
"show",
2493+
"stash",
2494+
"status",
2495+
"switch",
2496+
"symbolic-ref",
2497+
"tag",
2498+
"update-ref",
2499+
];
2500+
2501+
it("advertises only flags the command's parser accepts", async () => {
2502+
const offenders: string[] = [];
2503+
for (const command of commands) {
2504+
const { client } = fakeClient();
2505+
const help = await runGitCli(client, { argv: ["help", command] });
2506+
expect(help.exitCode, `git help ${command}`).toBe(0);
2507+
2508+
for (const flag of help.stdout.match(/--[a-z][a-z-]*/g) ?? []) {
2509+
const { client: probe } = fakeClient();
2510+
const res = await runGitCli(probe, { argv: [command, flag] });
2511+
// The parser rejects an unknown flag at 129 with a message naming
2512+
// it. Any other failure means the flag was understood and the
2513+
// command merely wanted different arguments, which is fine here.
2514+
const unknown =
2515+
res.stderr.includes(`unknown option '${flag}'`) ||
2516+
res.stderr.includes(`${flag} is not supported`) ||
2517+
res.stderr.includes(`only --stdin is supported`);
2518+
if (unknown) offenders.push(`${command} ${flag}: ${res.stderr.trim()}`);
2519+
}
2520+
}
2521+
expect(offenders).toEqual([]);
2522+
});
2523+
});

packages/computer/src/git/cli.ts

Lines changed: 35 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -159,36 +159,37 @@ export async function runGitCli(
159159
// actually accepts are listed: the whole point is to tell a caller what
160160
// works here, rather than what real git would take.
161161
const COMMAND_USAGE: Record<string, string> = {
162-
add: "git add [-A] <path>...",
163-
branch: "git branch [-d|-D <name>] [--show-current] [<name>]",
162+
add: "git add [-A|--all] [-f|--force] <path>...",
163+
branch: "git branch [-d|-D|--delete] [-f|--force] [--show-current] [<name>]",
164164
"cat-file": "git cat-file (-p|-t|-s) <oid>[:<path>]",
165-
checkout: "git checkout [-b <name>] [-f] <ref> [--] [<path>...]",
166-
clean: "git clean [-f] [-d] [-n] [<path>...]",
165+
checkout: "git checkout [-b] [-f|--force] <ref> [--] [<path>...]",
166+
clean: "git clean [-f|--force] [-d] [-n|--dry-run] [<path>...]",
167167
clone:
168-
"git clone [--depth <n>] [--branch <ref>] [--single-branch|--no-single-branch] <url> [<dir>]",
169-
commit: "git commit -m <message> [-a]",
170-
config: "git config [--get] <key> [<value>]",
171-
diff: "git diff [--stat] [--name-only] [<ref>] [--] [<path>...]",
172-
fetch: "git fetch [<remote>] [<ref>]",
173-
"hash-object": "git hash-object [-w] [-t <type>] <path>",
174-
init: "git init [<dir>]",
175-
log: "git log [-n <count>] [-<count>] [--oneline] [<ref>]",
176-
"ls-files": "git ls-files [<ref>]",
168+
"git clone [--depth <n>] [-b|--branch <ref>] [--single-branch|--no-single-branch] [--tags|--no-tags] <url> [<dir>]",
169+
commit: 'git commit -m|--message <message> [-a|--all] [--amend] [--author "Name <email>"]',
170+
config: "git config [--get|--get-all|--add|--unset] <key> [<value>]",
171+
diff: "git diff [--stat] [--name-only] [--name-status] [<ref>] [--] [<path>...]",
172+
fetch:
173+
"git fetch [--depth <n>] [--single-branch|--no-single-branch] [--tags|--no-tags] [--prune] [<remote>] [<ref>]",
174+
"hash-object": "git hash-object --stdin [-w]",
175+
init: "git init [-b|--initial-branch <name>] [--bare] [<dir>]",
176+
log: "git log [-n <count>] [-<count>] [--oneline] [--format|--pretty=<spec>] [<ref>]",
177+
"ls-files": "git ls-files [--ref <ref>]",
177178
"ls-tree": "git ls-tree <ref> [<path>]",
178-
merge: "git merge [--ff-only] [--no-ff] <ref>",
179-
pull: "git pull [<remote>] [<ref>]",
180-
push: "git push [-f|--force] [--delete] [<remote>] [<refspec>]",
181-
remote: "git remote [-v] [add <name> <url>] [remove <name>]",
182-
reset: "git reset [--hard|--soft|--mixed] [<ref>] [--] [<path>...]",
183-
"rev-parse": "git rev-parse <rev>",
184-
rm: "git rm [--cached] [-r] <path>...",
179+
merge: "git merge [--ff-only] [--no-ff] [-m|--message <message>] <ref>",
180+
pull: "git pull [--ff-only] [--no-ff] [<remote>] [<ref>]",
181+
push: "git push [-f|--force] [-d|--delete] [<remote>] [<refspec>]",
182+
remote: "git remote [add <name> <url>] [remove <name>]",
183+
reset: "git reset [--hard] [<ref>] [--] [<path>...]",
184+
"rev-parse": "git rev-parse [--abbrev-ref] [--show-toplevel] <rev>",
185+
rm: "git rm [--cached] <path>...",
185186
show: "git show [<ref>]",
186187
stash: "git stash [push|pop|apply|list|drop]",
187-
status: "git status [-s|--short|--porcelain]",
188-
switch: "git switch [-c <name>] <ref>",
189-
"symbolic-ref": "git symbolic-ref <name> [<ref>]",
190-
tag: "git tag [-d <name>] [<name> [<ref>]]",
191-
"update-ref": "git update-ref <ref> <oid>",
188+
status: "git status [-s|--short] [--porcelain[=<version>]]",
189+
switch: "git switch [-c] <ref>",
190+
"symbolic-ref": "git symbolic-ref [--short] [-q|--quiet] <name> [<ref>]",
191+
tag: "git tag [-d|--delete] [-f|--force] [<name> [<ref>]]",
192+
"update-ref": "git update-ref [--force] <ref> <oid>",
192193
help: "git help [<command>]",
193194
version: "git version",
194195
};
@@ -814,19 +815,25 @@ async function runLog(
814815
shorthandDepth = m[1];
815816
continue;
816817
}
818+
// --pretty is the same option as --format in real git. Normalize it
819+
// to the one key so the parser's last-write behavior decides which
820+
// wins: a caller appending an override to a command it did not build
821+
// gets the last one written, whichever spelling either of them used.
822+
if (arg === "--pretty" || arg.startsWith("--pretty=")) {
823+
rewritten.push(`--format${arg.slice("--pretty".length)}`);
824+
continue;
825+
}
817826
rewritten.push(arg);
818827
}
819828
const parsed = parseFlags(rewritten, {
820829
n: { kind: "value" },
821830
oneline: { kind: "bool" },
822831
format: { kind: "value" },
823-
pretty: { kind: "value" },
824832
});
825833
if ("error" in parsed) {
826834
return { stdout: "", stderr: `git log: ${parsed.error}\n`, exitCode: 129 };
827835
}
828-
// --pretty and --format are the same option in real git.
829-
const formatSpec = (parsed.flags.format ?? parsed.flags.pretty) as string | undefined;
836+
const formatSpec = parsed.flags.format as string | undefined;
830837
if (formatSpec !== undefined && parsed.flags.oneline === true) {
831838
return {
832839
stdout: "",

0 commit comments

Comments
 (0)