Skip to content

Fix #11: run the command behind git -c, -C and other global options - #13

Merged
blindmansion merged 4 commits into
blindmansion:mainfrom
Hazzng:fix/global-config-option
Oct 9, 2026
Merged

blindmansion merged 4 commits into
blindmansion:mainfrom
Hazzng:fix/global-config-option

Conversation

@Hazzng

@Hazzng Hazzng commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

Fixes #11

Summary

git -c user.name=x -c user.email=x@y.z commit -m msg now creates the commit, authored x <x@y.z>, and exits 0. Before this change it printed the top-level help to stdout, exited 0, and created nothing. git -C <path> <cmd> runs the command from <path>. An unknown leading option such as --bogus-opt exits 129 with unknown option: --bogus-opt and the usage line, as real git does.

Root cause

Git.execute() in src/git.ts took args[0] as the subcommand. -c is not a registered command and is not in KNOWN_UNIMPLEMENTED_COMMANDS, so the parser fell through to the help text with exit 0. Nothing parsed git's global options before dispatch.

A second, related gap surfaced while wiring the overlay. The configOverrides tier in getConfigValue matched keys by exact string, and callers spell keys inconsistently (merge.conflictstyle lowercase, init.defaultBranch camelCase). Any -c value whose casing differed from the caller's would have been silently ignored, the same failure class the issue reports. Real git compares section and variable names case-insensitively.

The fix

parseGlobalOptions in src/git.ts splits leading options from the subcommand and returns either a GlobalOptions record (chdirs, config, args) or an ExecResult error. It handles:

  • -c <name>=<value>, repeatable. A name without = reads as true. That mirrors how just-git's parseConfig already reads a valueless key in a config file, and differs from real git (see Not covered). -c =value fails with error: empty config key and -c foo=1 with error: key does not contain a section: foo, both exit 128 under fatal: unable to parse command-line config.
  • -C <path>, repeatable and cumulative. A missing directory fails with fatal: cannot change to '<path>': No such file or directory, exit 128.
  • -p, --paginate, -P, --no-pager, accepted and ignored since just-git never pages.
  • --help and --version pass through unchanged.
  • Anything else starting with - exits 129 with unknown option: <opt> plus the usage text. A missing argument to -c or -C uses git's wording (-c expects a configuration string, no directory given for -C), exit 129.

The -c overlay rides the existing locked tier of ConfigOverrides, so it reaches every getConfigValue and getConfigValueAll caller and the direct read in init.ts without a new code path. It sits below operator-locked values and above everything else, so createGit({ config: { locked: { "user.name": "Locked" } } }) still wins over git -c user.name=agent. The command tree closes over the extensions at construction, so an invocation with -c builds a fresh tree with the merged overrides for that call only. Measured cost is 0.3 ms per call.

canonicalConfigKey in src/lib/config.ts lowercases the section and variable name and keeps the subsection as typed. Override maps are canonicalised when createGit receives them, -c keys are canonicalised at parse time, and the two lookup functions canonicalise the requested key. init.ts reads init.defaultbranch accordingly. git config --list already printed file keys in lowercase, so operator keys now print the same way.

Blocked commands and beforeCommand/afterCommand hooks see the real subcommand, its own arguments, and the -C adjusted cwd. Hooks do not see the -c pairs themselves.

Tests

test/global-options.test.ts, 19 tests, all run through Git.execute via just-bash or Git.exec. They cover the commit author, the one-invocation overlay (git config user.name afterwards still shows the old value), init.defaultBranch without a repository, arbitrary keys with mixed case, the valueless form, locked-beats--c, each -c and -C error, -C from a parent directory with --show-prefix, pager flags, --help/--version, a blocked push behind -c, and the hook event shape.

Every test except the hook test asserts the exit code of the command under test. Tests that run a command assert its exact stdout, and the success paths assert an empty stderr. Error paths assert exact stderr and an empty stdout. Two tests check stdout loosely: --version by prefix and --help for the Commands: heading. The hook test asserts only the event the hook received.

One test pins the lookup canonicalisation on its own. git -c clean.requireForce=false clean must print Removing u, exit 0 and delete the untracked file, which matches real git. The -c key is lowercased at parse time while clean.ts asks for clean.requireForce, so the test fails if getConfigValue stops canonicalising the requested key. With the two canonicalConfigKey calls in getConfigValue and getConfigValueAll reverted it fails with expected "Removing u\n", received "", and the other 18 tests stay green.

The test commit lands before the fix commit so the red run is in history.

bun test is 3507 pass, 29 skip, 0 fail (baseline 3488 plus these 19). bun oracle validate fails the same five steps on this branch and on faa98c5, all in config unset, rebase --abort and worktree add wording that the local git 2.34.1 emits differently from the 2.53 target.

Not covered

  • git -c foo.bar config foo.bar prints true, the way just-git reads a valueless key in a config file. Real git prints an empty line for that read, and git -c user.name commit fails in real git with error: missing value for 'user.name', exit 128, where just-git commits with the name true.
  • git config user.name Repo followed by git -c user.name=Cli config --list prints only user.name=Repo. Real git prints user.name=Repo and user.name=Cli as two lines. handleList in src/commands/config.ts appends a locked key only when the file does not already have it, so a -c key the file lacks is appended after the file entries and a -c key the file has is left out of the listing. Operator-locked keys from createGit({ config: { locked } }) go through the same code, so changing it is out of scope here.
  • -C pointing at a regular file reports No such file or directory where git says Not a directory.
  • --git-dir, --work-tree, --bare, --namespace, --exec-path and --config-env are reported as unknown options.

Hazzng added 4 commits October 9, 2026 14:46
…patch

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.
@Hazzng

Hazzng commented Oct 9, 2026

Copy link
Copy Markdown
Contributor Author

@blindmansion could you review this when you have a moment? It makes git -c name=value <cmd> and git -C <path> <cmd> run the command instead of printing help and exiting 0. We need this in prod.

@blindmansion

Copy link
Copy Markdown
Owner

Thanks for raising, taking a look now

@blindmansion
blindmansion merged commit 4bcfdac into blindmansion:main Oct 9, 2026
2 checks passed
@blindmansion

Copy link
Copy Markdown
Owner

This fix and a few followups are now published in 1.9.1

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

git -c name=value <command> prints help and exits 0 without running the command

2 participants