Files
Dominik Schulz 76a213d4c6 feat(backend): add pure-Go go-git storage backend (#3657)
* feat(backend): add pure-Go go-git storage backend

Add an opt-in storage/RCS backend (gogit) that implements backend.Storage
using github.com/go-git/go-git/v5. It removes the runtime dependency on the
git binary for cross-compiled and single-binary distributions and for
Windows users without Git for Windows on PATH.

- New package internal/backend/storage/gogit implementing the existing
  backend.Storage interface (file ops delegate to fs.Store, RCS ops use
  go-git). The interface contract is unchanged.
- Registered as gogit with priority 12 (after gitfs) so the CLI backend
  remains the auto-detected default when git is present.
- Selectable via storage.backend=gogit, GOPASS_GIT_BACKEND=gogit or
  --storage=gogit.
- Automatic fallback to gogit when the git binary is absent: the gitfs
  loader now fails Handles/New/Init/Clone with ErrGitBinaryNotFound and
  backend.ResolveStorageBackend downgrades a requested GitFS backend.
- File-level merge with a two-parent merge commit for non-fast-forward
  pulls. A true conflict never discards a secret: the local version is kept
  and the remote version is written to <name>.conflict-<shortsha>.
- All transport/auth logic isolated in auth.go (the go-git v6 seam).
  SSH via x/crypto/ssh + ssh-agent; HTTPS via git credential fill when git
  is available, otherwise URL userinfo.
- Signed commits shell out to git commit -S when configured and git is
  present; otherwise an unsigned commit is created with a warning.
  gpg.format=ssh signing is unsupported.
- doctor is aware of the pure-Go backend.
- Docs: docs/backends/gogit.md, docs/backends.md, docs/config.md,
  docs/components.dot, docs/commands/git.md, ADR A-17 marked implemented.
- Tests: unit + merge-matrix tests, a cross-backend object-graph parity
  test, and an opt-in integration test.

Signed-off-by: Dominik Schulz <dominik.schulz@gauner.org>

* fix(backend): address review feedback on the go-git backend

Harden and correct the pure-Go go-git storage backend following review of
the initial implementation.

Security and correctness:

- Validate every path from a remote tree before materialising it: reject
  absolute paths, paths escaping the store root, and anything inside .git.
  Also reject .git/.git/... in fs.Store.safePath as defence in depth, so a
  crafted remote commit can no longer plant a hook or rewrite the config.
- Pull before pushing so a remote that is ahead no longer makes every push
  fail with a non-fast-forward error.
- Replace HardReset with MergeReset when fast-forwarding: untracked files
  are preserved and uncommitted edits abort the reset instead of being
  silently discarded. Materialise an unborn HEAD first, which is what
  `gopass setup --remote` needs.
- Stage explicit paths with AddOptions{Path} only. go-git checks All before
  Path, so the previous {All:true, Path:rel} staged the whole worktree and
  swept unrelated files into every "Save secret" commit.
- Never resolve a concurrent .gpg-id (recipient) change automatically: abort
  the pull with a clear error and leave the worktree untouched.
- Make the merge transactional: compute the whole resolution before writing
  anything and restore the index and worktree to HEAD on failure.
- Recreate symlinks from the remote tree instead of writing the target as a
  regular file, refuse submodules, and include the file mode in the
  identical-on-both-sides check.
- Route the merge commit through the same signing path as Commit.
- Pick a collision-free conflict-file name by growing the hash prefix.
- Fix Revisions: go-git's PathFilter compares each commit with the next in
  iteration order, so walk the log and diff against the first parent.
- Honour commit.gpgsign from the global scope and pass --no-verify to the
  signing shell-out so git hooks are never run.
- Fix urlUserInfo: only short-circuit on a URL that carries a password and
  percent-decode the userinfo. Run `git credential fill` in the store
  directory. Redact the password from clone errors.

Backend selection:

- Centralise the flag > env > config > default precedence in
  ResolveGitBackend and GitBackendFromEnvOrConfig, and use it from init,
  rcs and clone so GOPASS_GIT_BACKEND is honoured everywhere.
- Only honour GOPASS_GIT_BACKEND when the configured/detected backend is
  already a git-family backend, so a cryptfs or jjfs store is not reopened
  as plain gogit/gitfs.
- Resolve the cryptfs substorage so cryptfs stores open on git-less systems.
- Move fossilfs to priority 20 to resolve the collision with gogit (12).

Also stop reporting the expected "nothing to commit" result of the
.gitattributes commit as a failure.

Add unit tests for the new behaviour (add isolation, push-pulls-first,
untracked preservation, symlink merge, .git rejection, .gpg-id conflict,
conflict-name collision, revisions across merges, path validation, auth,
no-git path), a cross-backend merge test, and a `gopass sync` integration
test. Reconcile the ADR and docs/backends/gogit.md with the implementation.

Signed-off-by: Dominik Schulz <dominik.schulz@gauner.org>

* test(gogit): make cross-backend git CLI checks work on Windows

The cross-backend tests invoked the git CLI in ways the MSYS2 git used by
the Windows CI job cannot handle:

- `git -C <dir>` is implemented with a chdir() that does not accept a
  Windows-style backslash path (e.g. `C:\msys64\tmp\...\001`), so it
  failed with exit status 128 even though the repository is valid. Set the
  process working directory via cmd.Dir instead, matching the gitfs backend.
- `rev-parse HEAD^{tree}` is mangled by the MSYS2 runtime's brace expansion
  (`{tree}` -> `tree`), yielding "ambiguous argument 'HEAD^tree'". Use
  `show -s --format=%T HEAD` instead.

Also capture stderr so future failures are diagnosable.

Signed-off-by: Dominik Schulz <dominik.schulz@gauner.org>

* fix(backend): address review feedback on the go-git backend

Second review round on the pure-Go go-git storage backend:

- fs: refuse to write through symlinked path components and replace a
  symlink with a regular file instead of following it, so a remote tree
  cannot plant a link into .git and then write through it. SetSymlink now
  only accepts relative targets that stay inside the store and outside
  .git. The .git guard also covers Windows aliases (GIT~1, trailing
  dots/spaces, alternate data streams) and nested components.
- gogit: skip local-only deletions in the merge plan (staging them failed
  with "entry not found"), refuse a non-fast-forward merge over
  uncommitted changes, and restore only the affected paths on failure.
- gogit: preserve staged files across an unborn-HEAD pull (gopass setup
  --remote), return early when the remote is an ancestor of local instead
  of creating an empty merge commit, and report a missing remote branch
  instead of silently doing nothing.
- gogit: make the unsigned-commit fallback opt-in via
  gogit.allow-unsigned-commits, accept yes/on/1 for commit.gpgsign, sign
  merge commits via .git/MERGE_HEAD (git commit -p is --patch), and honour
  the commit timestamp for signed commits.
- gogit: strip userinfo from remote URLs before handing them to go-git so
  credentials cannot leak through go-git's error messages; fix
  splitHTTPURL for username-only URLs and disable the terminal prompt for
  git credential fill.
- gogit: resolve user.name/user.email local-over-global, compare revisions
  against all parents, exclude untracked files from staged changes, delete
  loose objects on prune, and treat .age-recipients/.age-ids/
  .gopass-mapping as recipient files.
- backend: only honour storage.backend for git-family backends and apply
  GOPASS_GIT_BACKEND after auto-detection, so a cryptfs/jjfs store is not
  reopened as plain gogit/gitfs.
- docs: reconcile the ADR, docs/backends/gogit.md and docs/config.md with
  the implementation; drop the duplicate A-17 index row.

Signed-off-by: Dominik Schulz <dominik.schulz@gauner.org>

* fix(backend): address remaining review feedback on the go-git backend

- fs: reject .jj (and .git) metadata paths in safePath; rename the guard to
  rejectVCSDir/IsVCSDirName and cover the .jj aliases in the tests.
- gogit: preserve the executable bit when materialising remote tree entries so
  a merge does not record a spurious mode change.
- gogit: add tests for executable-mode preservation, retry after a partial
  merge failure, symlink-then-regular-file against .git/config, and the
  opt-in signed-commit requirement.
- fossilfs: rephrase the priority comment (highest among VCS backends).
- docs: reconcile the ADR and gogit backend docs with the implementation
  (all-parents TREESAME history, prune handler, opt-in unsigned fallback,
  .jj guard, executable mode).

Signed-off-by: Dominik Schulz <dominik.schulz@gauner.org>

* test(gogit): make executable-mode merge assertion Windows-aware

TestGoGitMergePreservesExecutableMode asserted Unix semantics on all
platforms. Windows has no executable bit: os.Chmod cannot set it and
go-git derives the index mode from the OS file mode
(filemode.NewFromOSFileMode), so both the worktree bit and the recorded
index mode are non-executable there. ADR A-17 already accepts this
("gopass secrets are never executable"), so gate the Unix-only
assertions on GOOS and assert the regular-file outcome on Windows.

Also document that the executable bit is preserved except on Windows in
docs/backends/gogit.md and in the applyFileMode comment, and reword the
test comment to describe the resolved mode rather than a Unix mode.

Signed-off-by: Dominik Schulz <dominik.schulz@gauner.org>

---------

Signed-off-by: Dominik Schulz <dominik.schulz@gauner.org>
2026-10-14 09:40:57 +02:00
..