gogit: add Git-compatible config get, set, and unset - #76
Draft
MuskanPaliwal wants to merge 8 commits into
Draft
Conversation
Addresses go-git#12, which asked for CLI-level config introspection. Its suggested `config --set user.name` spelling is stale: Git rejects --set (exit 129), and current upstream (v2.55.GIT) documents `git config get|set|unset` subcommands. Both spellings are supported here — the modern subcommands and the legacy flags — so existing callers keep working. The previous configCmd corrupted any repository whose key had a subsection. `gogit config remote.origin.url <url>` wrote `origin.url` as an option under [remote] while leaving [remote "origin"] intact, producing a file neither Git nor go-git can parse: git config remote.origin.url -> fatal: bad config line 9 (exit 128) gogit config user.name -> illegal character U+002E '.' Fixing that means representing keys the way Git does — splitting at the first and last dot, so remote.team.one.url addresses [remote "team.one"] url — and it means not round-tripping the file through go-git's raw decoder/encoder. That pair is lossy in two ways that silently damage a user's config: it drops every comment, and its decoder folds [user ""] into [user] (decoder.go calls AddOption with an empty subsection, which Config.AddOption routes to the section), merging two distinct keys. So config file access moves to internal/plumbing/format/config, a line-oriented parser that keeps the original bytes and splices individual variables, leaving comments, blank lines, indentation and ordering untouched. It covers quoted values, escapes, backslash-newline continuations, trailing comments, CRLF, same-line options, legacy [a.sub] headers and empty subsections; anything it cannot parse becomes an error rather than a guess. Writes go through a temp file and rename, with the Close error checked, so a failed write cannot truncate the original. Also in this change: - Exit statuses match Git: 1 for a missing key, 5 for unsetting an absent key or collapsing a multivalued one, 128 for a malformed file. A missing key is now distinct from an explicitly empty value, which still prints one blank line and exits 0. - Reads consult system, XDG, global, local and -c overrides in Git's precedence order, honouring GIT_CONFIG_GLOBAL/SYSTEM/NOSYSTEM. Writes still default to the repository config. - --file, --local, --global, --system, --all and --path, with --path doing Git's path canonicalisation (leading ~), not file selection. - --add appends instead of replacing, and set refuses to overwrite a multivalued key without --all, as Git does. - Repository discovery handles .git files, linked worktrees (config resolves through commondir), bare repositories and GIT_DIR. Diagnostic paths are reported the way Git spells them rather than absolutised. Verified by 147 Go tests plus a differential harness that runs each invocation under Git 2.50.0 and gogit in twin repositories: 76/76 match on stdout, exit status and resulting file contents, and 17/18 match on stderr. The outstanding case is `--file <bare-name>` inside a bare repo, where Git's cwd-prefix rewriting says ./config; reproducing that needs gogit to chdir to the top level, which is out of scope here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Pp48YmGBdCMfRjvqEHRpe
Git writes a variable using the spelling from the command line rather
than folding it, and creates a section header the same way. gogit
lower-cased both, so it wrote bytes Git would not:
config Core.MyVar Value -> git: [Core] MyVar = Value
gogit: [core] myvar = Value
Upstream gates on this. t1300-config.sh case 17, "mixed case", appends
Section.Movie into an existing [section] and expects the variable spelled
Movie; running it against gogit halted there.
The rule is broader than creation. Git rewrites the whole "name = value"
pair, so setting an existing variable through a differently cased key
re-spells it too: [section] Movie = old, set via Section.MOVIE, becomes
MOVIE = new. The previous code went out of its way to preserve the
file's spelling on rewrite, which was wrong in the other direction.
So Key now carries the spelling it was given rather than a folded form,
and case-insensitivity moves from the data to the comparison: Matches
folds section and variable names, leaves subsection names byte-exact,
and keeps the empty subsection distinct from no subsection. Key must no
longer be compared with ==, which the -c override lookup was doing.
The parser likewise records each section and variable as the file spells
it. The one place folding stays is the deprecated [section.subsection]
header form, where Git folds the whole header and the subsection really
is case-insensitive.
Verified against Git 2.50.0 across thirteen paired invocations covering
creation, append into an existing section of either case, rewrite
through a differently cased key, --add, --replace-all, unset, valueless
variables, options sharing a line with their header, and subsection
case-sensitivity; all thirteen agree on file contents, stdout and exit
status. t1300-config.sh now reaches case 20, up from 17; the new blocker
is value-pattern matching on --replace-all, which remains out of scope.
The earlier differential suites are unchanged at 43/43, 33/33 and 17/18,
and make conformance still passes 42/42.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Pp48YmGBdCMfRjvqEHRpe
Entire-Checkpoint: 0825631a1f33
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #12
Add Git-compatible configuration inspection and mutation through both modern subcommands and established legacy spellings:
gogit config get <key>gogit config set <key> <value>gogit config unset <key>gogit config --get,--get-all,--add,--replace-all,--unset, and--unset-allgogit config <key> [<value>]The implementation reads the effective configuration hierarchy while directing writes to one physical configuration file. It preserves comments, formatting, repeated sections, key spelling, symlink targets, and Git-compatible exit behavior.
Why configuration needs two different models
A configuration read is a merged view assembled from system, global, repository, worktree, included-file, and command-line sources. A write must instead mutate exactly one physical file.
The implementation keeps these operations separate:
--allis requested;--file,--local,--global, or--system, defaulting to the repository configuration;--globalreads both XDG and~/.gitconfig, while writes prefer an existing~/.gitconfig, then an existing XDG file, and otherwise create~/.gitconfig;config.worktreewhenextensions.worktreeConfigenables it, while ordinary local writes continue to target the common repository configuration.Default effective reads follow Git include directives at their original position in the file. This supports unconditional includes and the
gitdir,gitdir/i,onbranch, andhasconfig:remote.*.urlconditions. Explicit single-scope and--filereads leave includes disabled, matching Git’s default include behavior for those modes.Format-preserving configuration editing
The new internal configuration representation retains the source structure rather than decoding and regenerating the entire file. Mutations can therefore change one option without discarding unrelated comments, blank lines, quoting, casing, repeated sections, or same-line section declarations.
Ordered entries also distinguish a valueless option from an explicitly empty value. That distinction is required for Git boolean semantics, where a bare option means true but
option =does not.Configuration keys are parsed and matched case-insensitively while preserving the spelling supplied by the caller when a new key is written.
Safe concurrent writes
Configuration mutation uses Git-style
<config>.lockfiles created exclusively. The lock covers the complete read-modify-write transaction:This prevents two successful writers from committing stale snapshots over one another. A contending writer fails visibly instead of reporting success while silently losing another update.
New files use normal
0666 & umaskcreation semantics. Existing files retain their permission bits.Git-compatible behavior
The command supports configuration precedence, repeated values, scope selection, linked worktrees, bare repositories,
-coverrides, path expansion, and Git-specific exit statuses.--pathexpands both the current user forms (~and~/...) and existing named users (~user/...).GIT_CONFIG_NOSYSTEMuses Git-style boolean parsing, so values such as0,false,no, andoffdo not disable the system scope.The literal
config --setspelling is deliberately not added: GNU Git rejects that flag. The supported forms are the modernconfig set <key> <value>command and the legacy bareconfig <key> <value>form.Verification
The following checks pass:
Focused comparisons against GNU Git also verified:
~/.gitconfigis the explicit global write target when both global files exist;GIT_CONFIG_NOSYSTEM=falseleaves system configuration enabled;~user/pathexpands to the named user’s home directory.