Skip to content

gogit: add Git-compatible config get, set, and unset - #76

Draft
MuskanPaliwal wants to merge 8 commits into
go-git:mainfrom
MuskanPaliwal:add-config-flags
Draft

gogit: add Git-compatible config get, set, and unset#76
MuskanPaliwal wants to merge 8 commits into
go-git:mainfrom
MuskanPaliwal:add-config-flags

Conversation

@MuskanPaliwal

Copy link
Copy Markdown

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-all
  • bare gogit 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:

  • reads collect sources in ascending precedence and return the last matching value unless --all is requested;
  • writes resolve one target according to --file, --local, --global, or --system, defaulting to the repository configuration;
  • --global reads both XDG and ~/.gitconfig, while writes prefer an existing ~/.gitconfig, then an existing XDG file, and otherwise create ~/.gitconfig;
  • linked worktrees read config.worktree when extensions.worktreeConfig enables 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, and hasconfig:remote.*.url conditions. Explicit single-scope and --file reads 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>.lock files created exclusively. The lock covers the complete read-modify-write transaction:

  1. resolve the real target when the configuration path is a symlink;
  2. acquire the exclusive lock;
  3. read the latest target contents;
  4. apply the mutation;
  5. preserve an existing file’s permissions;
  6. flush and atomically rename the lock over the target.

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 & umask creation semantics. Existing files retain their permission bits.

Git-compatible behavior

The command supports configuration precedence, repeated values, scope selection, linked worktrees, bare repositories, -c overrides, path expansion, and Git-specific exit statuses.

--path expands both the current user forms (~ and ~/...) and existing named users (~user/...). GIT_CONFIG_NOSYSTEM uses Git-style boolean parsing, so values such as 0, false, no, and off do not disable the system scope.

The literal config --set spelling is deliberately not added: GNU Git rejects that flag. The supported forms are the modern config set <key> <value> command and the legacy bare config <key> <value> form.

Verification

The following checks pass:

GOCACHE=/tmp/gogit-review-cache go test ./...
GOCACHE=/tmp/gogit-review-cache go test -race ./internal/plumbing/format/config ./cmd/gogit
GOCACHE=/tmp/gogit-review-cache go vet ./...
GOCACHE=/tmp/gogit-review-cache GOLANGCI_LINT_CACHE=/tmp/gogit-review-lint-cache build/tools/golangci-lint-v2.12.2 run ./...
git diff --check

Focused comparisons against GNU Git also verified:

  • a new configuration file created under umask 077 has mode 0600;
  • parallel writers never silently lose a successfully reported mutation;
  • included configuration values participate in effective lookup;
  • worktree configuration overrides common repository configuration;
  • ~/.gitconfig is the explicit global write target when both global files exist;
  • GIT_CONFIG_NOSYSTEM=false leaves system configuration enabled;
  • ~user/path expands to the named user’s home directory.

MuskanPaliwal and others added 8 commits August 25, 2026 15:39
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
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 config

1 participant