From ae2a85435fb5350831f10b5bc3f0c480832a5389 Mon Sep 17 00:00:00 2001 From: QuangNguyen2609 Date: Fri, 9 Oct 2026 14:46:30 +1030 Subject: [PATCH 1/4] Add failing tests for git -c, -C and unknown global options (#11) --- test/global-options.test.ts | 187 ++++++++++++++++++++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 test/global-options.test.ts diff --git a/test/global-options.test.ts b/test/global-options.test.ts new file mode 100644 index 00000000..e6d9126d --- /dev/null +++ b/test/global-options.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, test } from "bun:test"; +import { Bash, InMemoryFs } from "just-bash"; +import { createGit } from "../src/git"; + +const USAGE = + "usage: git [--version] [--help] [-C ] [-c =]\n" + + " [-p | --paginate | -P | --no-pager] []\n"; + +/** A shell whose `git` goes through `Git.execute`, the way just-bash integrations do. */ +function shell(options?: Parameters[0]): Bash { + return new Bash({ fs: new InMemoryFs(), customCommands: [createGit(options)], cwd: "/" }); +} + +async function initRepo(bash: Bash): Promise { + await bash.exec("mkdir -p /r && cd /r && git init -q && echo a > f && git add f"); +} + +describe("git -c =", () => { + test("runs the command with the overlay applied to the author identity", async () => { + const bash = shell(); + await initRepo(bash); + const commit = await bash.exec("git -c user.name=x -c user.email=x@y.z commit -q -m via-c", { + cwd: "/r", + }); + expect(commit.stderr).toBe(""); + expect(commit.exitCode).toBe(0); + const log = await bash.exec("git log --format='%an <%ae>'", { cwd: "/r" }); + expect(log.stdout).toBe("x \n"); + }); + + test("beats repo config for one invocation and does not persist", async () => { + const bash = shell(); + await initRepo(bash); + await bash.exec("git config user.name old", { cwd: "/r" }); + const overlaid = await bash.exec("git -c user.name=tmp config user.name", { cwd: "/r" }); + expect(overlaid.stdout).toBe("tmp\n"); + const after = await bash.exec("git config user.name", { cwd: "/r" }); + expect(after.stdout).toBe("old\n"); + }); + + test("covers arbitrary keys, lowercasing section and key", async () => { + const bash = shell(); + await initRepo(bash); + for (const [arg, key] of [ + ["Commit.GPGSign=false", "commit.gpgsign"], + ["core.pager=cat", "core.pager"], + ["color.ui=never", "color.ui"], + ["advice.detachedHead=false", "advice.detachedhead"], + ]) { + const r = await bash.exec(`git -c ${arg} config ${key}`, { cwd: "/r" }); + expect(r.stdout).toBe(`${arg?.split("=")[1]}\n`); + } + }); + + test("init.defaultBranch overlay applies without a repository", async () => { + const bash = shell(); + await bash.exec("mkdir /n"); + const init = await bash.exec("git -c init.defaultBranch=trunk init -q", { cwd: "/n" }); + expect(init.exitCode).toBe(0); + const head = await bash.exec("git branch --show-current", { cwd: "/n" }); + expect(head.stdout).toBe("trunk\n"); + }); + + test("a name without '=' reads as true", async () => { + const bash = shell(); + await initRepo(bash); + const r = await bash.exec("git -c foo.bar config foo.bar", { cwd: "/r" }); + expect(r.stdout).toBe("true\n"); + expect(r.exitCode).toBe(0); + }); + + test("a locked operator override still wins over -c", async () => { + const bash = shell({ config: { locked: { "user.name": "Locked" } } }); + await initRepo(bash); + const r = await bash.exec("git -c user.name=agent config user.name", { cwd: "/r" }); + expect(r.stdout).toBe("Locked\n"); + }); + + test("rejects an empty key", async () => { + const r = await shell().exec("git -c =value status"); + expect(r.stdout).toBe(""); + expect(r.stderr).toBe( + "error: empty config key\nfatal: unable to parse command-line config\n", + ); + expect(r.exitCode).toBe(128); + }); + + test("rejects a key without a section", async () => { + const r = await shell().exec("git -c foo=1 status"); + expect(r.stderr).toBe( + "error: key does not contain a section: foo\nfatal: unable to parse command-line config\n", + ); + expect(r.exitCode).toBe(128); + }); + + test("rejects -c with no argument", async () => { + const r = await shell().exec("git -c"); + expect(r.stdout).toBe(""); + expect(r.stderr).toBe(`-c expects a configuration string\n${USAGE}`); + expect(r.exitCode).toBe(129); + }); +}); + +describe("git -C ", () => { + test("runs the command from the given directory", async () => { + const bash = shell(); + await initRepo(bash); + await bash.exec("mkdir /r/sub"); + const status = await bash.exec("git -C r status --short", { cwd: "/" }); + expect(status.stdout).toBe("A f\n"); + expect(status.exitCode).toBe(0); + const prefix = await bash.exec("git -C /r -C sub rev-parse --show-prefix", { cwd: "/" }); + expect(prefix.stdout).toBe("sub/\n"); + }); + + test("fails when the directory does not exist", async () => { + const r = await shell().exec("git -C nope status"); + expect(r.stdout).toBe(""); + expect(r.stderr).toBe("fatal: cannot change to 'nope': No such file or directory\n"); + expect(r.exitCode).toBe(128); + }); + + test("rejects -C with no argument", async () => { + const r = await shell().exec("git -C"); + expect(r.stderr).toBe(`no directory given for -C\n${USAGE}`); + expect(r.exitCode).toBe(129); + }); +}); + +describe("other leading options", () => { + test("an unknown global option exits 129 with usage", async () => { + const r = await shell().exec("git --bogus-opt status"); + expect(r.stdout).toBe(""); + expect(r.stderr).toBe(`unknown option: --bogus-opt\n${USAGE}`); + expect(r.exitCode).toBe(129); + }); + + test("an attached -c value is an unknown option, as in git", async () => { + const r = await shell().exec("git -cfoo.bar=1 status"); + expect(r.stderr).toBe(`unknown option: -cfoo.bar=1\n${USAGE}`); + expect(r.exitCode).toBe(129); + }); + + test("pager options are accepted and ignored", async () => { + const bash = shell(); + await initRepo(bash); + for (const opt of ["--no-pager", "-P", "--paginate", "-p"]) { + const r = await bash.exec(`git ${opt} status --short`, { cwd: "/r" }); + expect(r.stdout).toBe("A f\n"); + expect(r.exitCode).toBe(0); + } + }); + + test("--version and --help keep working", async () => { + const bash = shell(); + const version = await bash.exec("git --version"); + expect(version.exitCode).toBe(0); + expect(version.stdout.startsWith("just-git version ")).toBe(true); + const help = await bash.exec("git --help"); + expect(help.exitCode).toBe(0); + expect(help.stdout).toContain("Commands:"); + }); +}); + +describe("dispatch sees the real command", () => { + test("a blocked command stays blocked behind -c", async () => { + const git = createGit({ disabled: ["push"] }); + const r = await git.exec("-c user.name=x push", { fs: new InMemoryFs(), cwd: "/" }); + expect(r.stderr).toBe("git: 'push' is not available in this environment\n"); + expect(r.exitCode).toBe(1); + }); + + test("beforeCommand receives the command, its own args, and the -C cwd", async () => { + const seen: { command: string; args: string[]; cwd: string }[] = []; + const git = createGit({ + hooks: { + beforeCommand: ({ command, args, cwd }) => { + seen.push({ command, args, cwd }); + }, + }, + }); + const fs = new InMemoryFs(); + await fs.mkdir("/w", { recursive: true }); + await git.exec("-C /w -c user.name=x status --short", { fs, cwd: "/" }); + expect(seen).toEqual([{ command: "status", args: ["--short"], cwd: "/w" }]); + }); +}); From e03285c8ff2bf63badda4cdb2c4af81ea6728eda Mon Sep 17 00:00:00 2001 From: QuangNguyen2609 Date: Fri, 9 Oct 2026 14:54:30 +1030 Subject: [PATCH 2/4] Fix #11: parse -c, -C and other global options before dispatch Leading -c name=value pairs become a per-invocation config overlay below operator-locked values, -C changes the working directory before repo discovery, pager flags are accepted and ignored, and any other leading option exits 129 with git's usage text. Override keys are now matched case-insensitively, as git does. --- .changeset/global-config-option.md | 5 ++ docs/FILE_REFERENCE.md | 2 +- src/commands/init.ts | 4 +- src/git.ts | 125 ++++++++++++++++++++++++++++- src/lib/config.ts | 26 +++++- test/global-options.test.ts | 4 +- 6 files changed, 153 insertions(+), 13 deletions(-) create mode 100644 .changeset/global-config-option.md diff --git a/.changeset/global-config-option.md b/.changeset/global-config-option.md new file mode 100644 index 00000000..cfe655a2 --- /dev/null +++ b/.changeset/global-config-option.md @@ -0,0 +1,5 @@ +--- +"just-git": patch +--- + +`git -c = ` and `git -C ` now run the command with the overlay or directory applied, instead of printing help and exiting 0. An unknown leading option exits 129 with `unknown option: `. Config override keys are matched case-insensitively, as in git. diff --git a/docs/FILE_REFERENCE.md b/docs/FILE_REFERENCE.md index 3811d1df..4309753b 100644 --- a/docs/FILE_REFERENCE.md +++ b/docs/FILE_REFERENCE.md @@ -38,7 +38,7 @@ Exported symbols from `src/lib/`. Generated by `bun scripts/gen-lib-reference.ts ### lib/config.ts -- `parseConfig`, `parseConfigMulti`, `formatConfigValue`, `serializeConfig`, `setConfigValueRaw`, `unsetConfigValueRaw`, `readConfig`, `writeConfig`, `getConfigValue`, `setConfigValue`, `getConfigValueAll`, `addConfigValueRaw`, `addConfigValue`, `unsetConfigValue` +- `parseConfig`, `parseConfigMulti`, `formatConfigValue`, `serializeConfig`, `setConfigValueRaw`, `unsetConfigValueRaw`, `readConfig`, `writeConfig`, `getConfigValue`, `canonicalConfigKey`, `setConfigValue`, `getConfigValueAll`, `addConfigValueRaw`, `addConfigValue`, `unsetConfigValue` - **types**: `GitConfigSection`, `GitConfig`, `GitConfigMultiSection`, `GitConfigMulti` ### lib/date.ts diff --git a/src/commands/init.ts b/src/commands/init.ts index 0db22704..f3bd2ad2 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -17,8 +17,8 @@ export function registerInitCommand(parent: Command, ext?: GitExtensions) { handler: async (args, ctx) => { const initialBranch = args.initialBranch ?? - ext?.configOverrides?.locked?.["init.defaultBranch"] ?? - ext?.configOverrides?.defaults?.["init.defaultBranch"]; + ext?.configOverrides?.locked?.["init.defaultbranch"] ?? + ext?.configOverrides?.defaults?.["init.defaultbranch"]; const targetDir = args.directory ? resolve(ctx.cwd, args.directory) : ctx.cwd; if (args.directory) { diff --git a/src/git.ts b/src/git.ts index c8a45863..0b5c382f 100644 --- a/src/git.ts +++ b/src/git.ts @@ -13,6 +13,8 @@ import { } from "./hooks.ts"; import type { MergeDriver } from "./lib/merge-ort.ts"; import { contextFromExtensions } from "./lib/command-utils.ts"; +import { canonicalConfigKey } from "./lib/config.ts"; +import { resolve } from "./lib/path.ts"; import { findRepo as findRepoOnFs } from "./lib/repo.ts"; import type { CredentialCache } from "./lib/transport/remote.ts"; import type { GitContext, ObjectStore, RefStore, RemoteResolver } from "./lib/types.ts"; @@ -243,6 +245,97 @@ function mergeIdentityIntoConfig( }; } +function canonicalizeOverrides(config: ConfigOverrides | undefined): ConfigOverrides | undefined { + if (!config) return config; + const canon = (tier: Record) => + Object.fromEntries(Object.entries(tier).map(([k, v]) => [canonicalConfigKey(k), v])); + return { + ...(config.locked ? { locked: canon(config.locked) } : {}), + ...(config.defaults ? { defaults: canon(config.defaults) } : {}), + }; +} + +const GLOBAL_USAGE = + "usage: git [--version] [--help] [-C ] [-c =]\n" + + " [-p | --paginate | -P | --no-pager] []\n"; + +/** Options git accepts before the subcommand, e.g. `git -C sub -c user.name=x commit`. */ +interface GlobalOptions { + /** `-C` directories in order, each relative to the previous one. */ + chdirs: string[]; + /** `-c` overlay keyed by normalised dotted name, or undefined when none given. */ + config: Record | undefined; + /** The subcommand and its arguments. */ + args: string[]; +} + +function usageError(message: string): ExecResult { + return { stdout: "", stderr: `${message}\n${GLOBAL_USAGE}`, exitCode: 129 }; +} + +function configParseError(message: string): ExecResult { + return { + stdout: "", + stderr: `error: ${message}\nfatal: unable to parse command-line config\n`, + exitCode: 128, + }; +} + +/** + * Split leading global options from the subcommand. Messages and exit + * codes match git: 129 for a malformed option, 128 for a bad `-c` key. + */ +function parseGlobalOptions(args: string[]): GlobalOptions | ExecResult { + const chdirs: string[] = []; + let config: Record | undefined; + let i = 0; + while (i < args.length) { + const arg = args[i]!; + if (!arg.startsWith("-") || arg === "-" || arg === "--help" || arg === "--version") break; + i++; + switch (arg) { + case "-c": { + const spec = args[i++]; + if (spec === undefined) return usageError("-c expects a configuration string"); + const eq = spec.indexOf("="); + const key = eq === -1 ? spec : spec.slice(0, eq); + if (key === "") return configParseError("empty config key"); + const dot = key.indexOf("."); + if (dot === -1) return configParseError(`key does not contain a section: ${key}`); + config ??= {}; + config[canonicalConfigKey(key)] = eq === -1 ? "true" : spec.slice(eq + 1); + break; + } + case "-C": { + const dir = args[i++]; + if (dir === undefined) return usageError("no directory given for -C"); + chdirs.push(dir); + break; + } + case "-p": + case "--paginate": + case "-P": + case "--no-pager": + break; + default: + return usageError(`unknown option: ${arg}`); + } + } + return { chdirs, config, args: args.slice(i) }; +} + +/** `-c` values sit below operator-locked values and above everything else. */ +function withCommandLineConfig( + overrides: ConfigOverrides | undefined, + config: Record, +): ConfigOverrides { + return { ...overrides, locked: { ...config, ...overrides?.locked } }; +} + +async function isDirectory(fs: FileSystem, path: string): Promise { + return (await fs.exists(path)) && (await fs.stat(path)).isDirectory; +} + /** * Git command handler. Runs git subcommands against a virtual filesystem. * @@ -287,7 +380,9 @@ export class Git { this.blocked = options?.disabled?.length ? new Set(options.disabled) : null; const network = options?.network; - const configOverrides = mergeIdentityIntoConfig(options?.identity, options?.config); + const configOverrides = canonicalizeOverrides( + mergeIdentityIntoConfig(options?.identity, options?.config), + ); const gitDirExt = options?.gitDir ? { @@ -377,10 +472,26 @@ export class Git { return this.execute(args, { fs, cwd, env, stdin: ctx?.stdin ?? "" }); }; - execute = (args: string[], ctx: CommandContext): Promise => { + execute = (rawArgs: string[], ctx: CommandContext): Promise => { return this.withLock(ctx.fs, async () => { + const global = parseGlobalOptions(rawArgs); + if ("exitCode" in global) return global; + const { args, config, chdirs } = global; const command = args[0] ?? ""; + let cwd = ctx.cwd; + for (const dir of chdirs) { + cwd = resolve(cwd, dir); + if (!(await isDirectory(ctx.fs, cwd))) { + return { + stdout: "", + stderr: `fatal: cannot change to '${dir}': No such file or directory\n`, + exitCode: 128, + }; + } + } + if (cwd !== ctx.cwd) ctx = { ...ctx, cwd }; + if (command === "--version" || command === "version") { return { stdout: `just-git version ${VERSION} (virtual git implementation)\n`, @@ -422,7 +533,15 @@ export class Git { } } - const result = await this.inner.execute(args, ctx); + // Command handlers close over the extensions, so a per-invocation + // overlay means a fresh command tree (about 0.3ms) for this call only. + const inner = config + ? createGitCommand({ + ...this.ext, + configOverrides: withCommandLineConfig(this.ext.configOverrides, config), + }).toCommand() + : this.inner; + const result = await inner.execute(args, ctx); if (this.hooks?.afterCommand) { await this.hooks.afterCommand({ diff --git a/src/lib/config.ts b/src/lib/config.ts index 291313c3..0c240699 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -513,7 +513,8 @@ export async function getConfigValue( dottedKey: string, ): Promise { const overrides = ctx.configOverrides; - const locked = overrides?.locked?.[dottedKey]; + const canonical = canonicalConfigKey(dottedKey); + const locked = overrides?.locked?.[canonical]; if (locked !== undefined) return locked; const config = await readConfig(ctx); @@ -521,7 +522,23 @@ export async function getConfigValue( const fromFile = config[section]?.[key]; if (fromFile !== undefined) return fromFile; - return overrides?.defaults?.[dottedKey]; + return overrides?.defaults?.[canonical]; +} + +/** + * Lowercase the section and variable name of a dotted key, leaving any + * subsection as typed, the way git compares config keys. Override maps + * are stored and looked up by this form. + */ +export function canonicalConfigKey(dottedKey: string): string { + const first = dottedKey.indexOf("."); + if (first === -1) return dottedKey.toLowerCase(); + const last = dottedKey.lastIndexOf("."); + return ( + dottedKey.slice(0, first).toLowerCase() + + dottedKey.slice(first, last) + + dottedKey.slice(last).toLowerCase() + ); } /** @@ -550,7 +567,8 @@ export async function setConfigValue( */ export async function getConfigValueAll(ctx: GitContext, dottedKey: string): Promise { const overrides = ctx.configOverrides; - const locked = overrides?.locked?.[dottedKey]; + const canonical = canonicalConfigKey(dottedKey); + const locked = overrides?.locked?.[canonical]; if (locked !== undefined) return [locked]; const raw = await readConfigRaw(ctx); @@ -561,7 +579,7 @@ export async function getConfigValueAll(ctx: GitContext, dottedKey: string): Pro if (fromFile && fromFile.length > 0) return fromFile; } - const def = overrides?.defaults?.[dottedKey]; + const def = overrides?.defaults?.[canonical]; if (def !== undefined) return [def]; return []; } diff --git a/test/global-options.test.ts b/test/global-options.test.ts index e6d9126d..4c650b8a 100644 --- a/test/global-options.test.ts +++ b/test/global-options.test.ts @@ -79,9 +79,7 @@ describe("git -c =", () => { test("rejects an empty key", async () => { const r = await shell().exec("git -c =value status"); expect(r.stdout).toBe(""); - expect(r.stderr).toBe( - "error: empty config key\nfatal: unable to parse command-line config\n", - ); + expect(r.stderr).toBe("error: empty config key\nfatal: unable to parse command-line config\n"); expect(r.exitCode).toBe(128); }); From b44199c308179ee24c2bce9b0c0c092757a6e7d7 Mon Sep 17 00:00:00 2001 From: QuangNguyen2609 Date: Fri, 9 Oct 2026 15:07:34 +1030 Subject: [PATCH 3/4] Drop narrating comments and simplify -c key check (#11) --- src/git.ts | 14 +------------- src/lib/config.ts | 3 +-- test/global-options.test.ts | 1 - 3 files changed, 2 insertions(+), 16 deletions(-) diff --git a/src/git.ts b/src/git.ts index 0b5c382f..70bcf300 100644 --- a/src/git.ts +++ b/src/git.ts @@ -259,13 +259,9 @@ const GLOBAL_USAGE = "usage: git [--version] [--help] [-C ] [-c =]\n" + " [-p | --paginate | -P | --no-pager] []\n"; -/** Options git accepts before the subcommand, e.g. `git -C sub -c user.name=x commit`. */ interface GlobalOptions { - /** `-C` directories in order, each relative to the previous one. */ chdirs: string[]; - /** `-c` overlay keyed by normalised dotted name, or undefined when none given. */ config: Record | undefined; - /** The subcommand and its arguments. */ args: string[]; } @@ -281,10 +277,6 @@ function configParseError(message: string): ExecResult { }; } -/** - * Split leading global options from the subcommand. Messages and exit - * codes match git: 129 for a malformed option, 128 for a bad `-c` key. - */ function parseGlobalOptions(args: string[]): GlobalOptions | ExecResult { const chdirs: string[] = []; let config: Record | undefined; @@ -300,8 +292,7 @@ function parseGlobalOptions(args: string[]): GlobalOptions | ExecResult { const eq = spec.indexOf("="); const key = eq === -1 ? spec : spec.slice(0, eq); if (key === "") return configParseError("empty config key"); - const dot = key.indexOf("."); - if (dot === -1) return configParseError(`key does not contain a section: ${key}`); + if (!key.includes(".")) return configParseError(`key does not contain a section: ${key}`); config ??= {}; config[canonicalConfigKey(key)] = eq === -1 ? "true" : spec.slice(eq + 1); break; @@ -324,7 +315,6 @@ function parseGlobalOptions(args: string[]): GlobalOptions | ExecResult { return { chdirs, config, args: args.slice(i) }; } -/** `-c` values sit below operator-locked values and above everything else. */ function withCommandLineConfig( overrides: ConfigOverrides | undefined, config: Record, @@ -533,8 +523,6 @@ export class Git { } } - // Command handlers close over the extensions, so a per-invocation - // overlay means a fresh command tree (about 0.3ms) for this call only. const inner = config ? createGitCommand({ ...this.ext, diff --git a/src/lib/config.ts b/src/lib/config.ts index 0c240699..fa4f08a6 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -527,8 +527,7 @@ export async function getConfigValue( /** * Lowercase the section and variable name of a dotted key, leaving any - * subsection as typed, the way git compares config keys. Override maps - * are stored and looked up by this form. + * subsection as typed, the way git compares config keys. */ export function canonicalConfigKey(dottedKey: string): string { const first = dottedKey.indexOf("."); diff --git a/test/global-options.test.ts b/test/global-options.test.ts index 4c650b8a..d7f28dfa 100644 --- a/test/global-options.test.ts +++ b/test/global-options.test.ts @@ -6,7 +6,6 @@ const USAGE = "usage: git [--version] [--help] [-C ] [-c =]\n" + " [-p | --paginate | -P | --no-pager] []\n"; -/** A shell whose `git` goes through `Git.execute`, the way just-bash integrations do. */ function shell(options?: Parameters[0]): Bash { return new Bash({ fs: new InMemoryFs(), customCommands: [createGit(options)], cwd: "/" }); } From 697d130e6c443568791df0c0adab02d5d78adf64 Mon Sep 17 00:00:00 2001 From: QuangNguyen2609 Date: Fri, 9 Oct 2026 15:13:26 +1030 Subject: [PATCH 4/4] Test -c lookup canonicalisation and tighten assertions (#11) --- test/global-options.test.ts | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/test/global-options.test.ts b/test/global-options.test.ts index d7f28dfa..074ffe4b 100644 --- a/test/global-options.test.ts +++ b/test/global-options.test.ts @@ -33,8 +33,23 @@ describe("git -c =", () => { await bash.exec("git config user.name old", { cwd: "/r" }); const overlaid = await bash.exec("git -c user.name=tmp config user.name", { cwd: "/r" }); expect(overlaid.stdout).toBe("tmp\n"); + expect(overlaid.stderr).toBe(""); + expect(overlaid.exitCode).toBe(0); const after = await bash.exec("git config user.name", { cwd: "/r" }); expect(after.stdout).toBe("old\n"); + expect(after.exitCode).toBe(0); + }); + + test("reaches a caller that spells the key in camelCase", async () => { + const bash = shell(); + await initRepo(bash); + await bash.exec("echo junk > u && mkdir d && echo x > d/y", { cwd: "/r" }); + const clean = await bash.exec("git -c clean.requireForce=false clean", { cwd: "/r" }); + expect(clean.stdout).toBe("Removing u\n"); + expect(clean.stderr).toBe(""); + expect(clean.exitCode).toBe(0); + const ls = await bash.exec("ls", { cwd: "/r" }); + expect(ls.stdout).toBe("d\nf\n"); }); test("covers arbitrary keys, lowercasing section and key", async () => { @@ -48,6 +63,8 @@ describe("git -c =", () => { ]) { const r = await bash.exec(`git -c ${arg} config ${key}`, { cwd: "/r" }); expect(r.stdout).toBe(`${arg?.split("=")[1]}\n`); + expect(r.stderr).toBe(""); + expect(r.exitCode).toBe(0); } }); @@ -55,12 +72,14 @@ describe("git -c =", () => { const bash = shell(); await bash.exec("mkdir /n"); const init = await bash.exec("git -c init.defaultBranch=trunk init -q", { cwd: "/n" }); + expect(init.stderr).toBe(""); expect(init.exitCode).toBe(0); const head = await bash.exec("git branch --show-current", { cwd: "/n" }); expect(head.stdout).toBe("trunk\n"); + expect(head.exitCode).toBe(0); }); - test("a name without '=' reads as true", async () => { + test("a name without '=' reads as true, as parseConfig reads a valueless key", async () => { const bash = shell(); await initRepo(bash); const r = await bash.exec("git -c foo.bar config foo.bar", { cwd: "/r" }); @@ -73,6 +92,8 @@ describe("git -c =", () => { await initRepo(bash); const r = await bash.exec("git -c user.name=agent config user.name", { cwd: "/r" }); expect(r.stdout).toBe("Locked\n"); + expect(r.stderr).toBe(""); + expect(r.exitCode).toBe(0); }); test("rejects an empty key", async () => { @@ -84,6 +105,7 @@ describe("git -c =", () => { test("rejects a key without a section", async () => { const r = await shell().exec("git -c foo=1 status"); + expect(r.stdout).toBe(""); expect(r.stderr).toBe( "error: key does not contain a section: foo\nfatal: unable to parse command-line config\n", ); @@ -108,6 +130,7 @@ describe("git -C ", () => { expect(status.exitCode).toBe(0); const prefix = await bash.exec("git -C /r -C sub rev-parse --show-prefix", { cwd: "/" }); expect(prefix.stdout).toBe("sub/\n"); + expect(prefix.exitCode).toBe(0); }); test("fails when the directory does not exist", async () => { @@ -119,6 +142,7 @@ describe("git -C ", () => { test("rejects -C with no argument", async () => { const r = await shell().exec("git -C"); + expect(r.stdout).toBe(""); expect(r.stderr).toBe(`no directory given for -C\n${USAGE}`); expect(r.exitCode).toBe(129); }); @@ -134,6 +158,7 @@ describe("other leading options", () => { test("an attached -c value is an unknown option, as in git", async () => { const r = await shell().exec("git -cfoo.bar=1 status"); + expect(r.stdout).toBe(""); expect(r.stderr).toBe(`unknown option: -cfoo.bar=1\n${USAGE}`); expect(r.exitCode).toBe(129); }); @@ -144,6 +169,7 @@ describe("other leading options", () => { for (const opt of ["--no-pager", "-P", "--paginate", "-p"]) { const r = await bash.exec(`git ${opt} status --short`, { cwd: "/r" }); expect(r.stdout).toBe("A f\n"); + expect(r.stderr).toBe(""); expect(r.exitCode).toBe(0); } }); @@ -163,6 +189,7 @@ describe("dispatch sees the real command", () => { test("a blocked command stays blocked behind -c", async () => { const git = createGit({ disabled: ["push"] }); const r = await git.exec("-c user.name=x push", { fs: new InMemoryFs(), cwd: "/" }); + expect(r.stdout).toBe(""); expect(r.stderr).toBe("git: 'push' is not available in this environment\n"); expect(r.exitCode).toBe(1); });