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..70bcf300 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,87 @@ 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"; + +interface GlobalOptions { + chdirs: string[]; + config: Record | undefined; + 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, + }; +} + +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"); + if (!key.includes(".")) 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) }; +} + +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 +370,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 +462,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 +523,13 @@ export class Git { } } - const result = await this.inner.execute(args, ctx); + 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..fa4f08a6 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,22 @@ 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. + */ +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 +566,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 +578,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 new file mode 100644 index 00000000..074ffe4b --- /dev/null +++ b/test/global-options.test.ts @@ -0,0 +1,211 @@ +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"; + +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"); + 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 () => { + 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`); + expect(r.stderr).toBe(""); + expect(r.exitCode).toBe(0); + } + }); + + 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.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, 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" }); + 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"); + expect(r.stderr).toBe(""); + expect(r.exitCode).toBe(0); + }); + + 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.stdout).toBe(""); + 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"); + expect(prefix.exitCode).toBe(0); + }); + + 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.stdout).toBe(""); + 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.stdout).toBe(""); + 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.stderr).toBe(""); + 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.stdout).toBe(""); + 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" }]); + }); +});