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

11 KiB

Conventions

Normative reference for commit messages, versioning, branch and tag names, and file naming.

See CONTRIBUTING.md for the contribution workflow and docs/hacking.md for the development environment.

1. Commit messages

Commit subjects follow Conventional Commits 1.0.0.

1.1 Format

<type>[(<scope>)][!]: <description>

[optional body]

[optional footers]

Rules:

  • Limit the subject line to 72 characters.
  • Write the description in the imperative mood: "add", not "adds" or "added".
  • Start the description in lowercase. Do not end it with a period.
  • Include a Signed-off-by: footer in every commit, as required by CONTRIBUTING.md. Conventional Commits governs the subject line; the Developer Certificate of Origin adds a trailer. Both apply.
  • Write the pull request title as a valid Conventional Commit.

The pull request title requirement follows from the merge strategy. Pull requests are squash-merged: pull request #3489, titled fix: Avoid NPE when attempting to edit a non-existing secret, landed on master as the single-parent commit eb1368e4 with the subject fix: Avoid NPE when attempting to edit a non-existing secret (#3489). The pull request title, not the individual commit subjects, is the string that reaches CHANGELOG.md.

1.2 Types

This list is exhaustive. Reject any other type.

Type Meaning SemVer impact Changelog section
feat New user-visible capability MINOR Added
fix Bug fix PATCH Fixed
security Security fix or hardening PATCH Security
perf Performance improvement, no behaviour change PATCH Changed
refactor Internal restructuring, no behaviour change PATCH Changed
revert Reverts a previous commit PATCH Changed
deps Dependency version change PATCH Changed
docs Documentation only none omitted
test Tests only none omitted
build Build system: Makefile, goreleaser, Dockerfile none omitted
ci GitHub Actions, linter configuration, workflows none omitted
chore Housekeeping that fits nothing above none omitted

Two entries extend the set suggested by the specification:

  • security populates the Security section of the changelog, which Keep a Changelog defines as a first-class category. Five commits since 2025-01-01 already use it.
  • deps is the preferred type for hand-written dependency commits. Accept chore(deps) as well: Dependabot emits it, and 147 of the 344 commit subjects since 2025-01-01 carry the chore type, nearly all of them dependency bumps.

The following appear as commit types in the history and are not types. Use them as scopes: otp (2 commits since 2025-01-01), age, bug, fscopy, and openbsd (1 each).

1.3 Scopes

The scope is optional. Prefer to supply one. Do not invent scopes; omit the scope when none fits. Omit the scope for a change spanning several areas rather than listing more than one.

Commands — the 42 top-level subcommands: 40 registered by (*Action).GetCommands in internal/action/commands.go, plus pwgen from internal/action/pwgen and completion, both added by getCommands in main.go:

alias audit cat clone completion config convert copy create delete doctor edit
env find fsck fscopy fsmove generate grep history init insert link list merge
mounts move otp process pwgen rcs recipients reorg secret-service setup show sum
sync templates unclip update version

Backends: age, gpg, plain, cryptfs, fossilfs, fs, gitfs, gogit, jjfs

Subsystems: action, audit, backend, cache, completion, config, create, cui, editor, hashsum, hook, notify, out, queue, recipients, reminder, store, store/leaf, store/root, tpl, tree, updater

Public API: pkg/gopass, api, secrets, appdir, clipboard, ctxutil, debug, fsutil, otp, passkey, pinentry, protect, pwgen, qrcon, set, tempfile, termio

Meta: deps, release, changelog, docs, adr, ci, build

Write pkg/gopass out in full as a scope. Public-API changes must be greppable against the A-12 stability contract.

1.4 Breaking changes

This repository has two independent compatibility surfaces. Mark them differently. Do not conflate them.

Surface broken Marker Version impact
gopass CLI: commands, flags, output format, exit codes, config keys, store format ! after the type or scope, and a BREAKING CHANGE: footer MAJOR
Go module pkg/gopass only: an exported symbol removed or changed a PKG-BREAK: footer, no ! none; MINOR at most
Both ! + BREAKING CHANGE: + PKG-BREAK: MAJOR

Do not mark a module-only break with !. No CLI user can observe such a change, and ! demands a major release.

Example of a module-only break, per A-12:

refactor(pkg/gopass): drop Store.GetRevision in favour of Store.History

Deprecated since 1.17.0; the two-minor / three-month window has elapsed.

PKG-BREAK: pkg/gopass: remove Store.GetRevision, use Store.History instead
Signed-off-by: Your Name <your@example.com>

The PKG-BREAK: footer is the machine-readable form of the [PKG-BREAK] changelog tag mandated by A-12.

1.5 Changelog control footers

Footer Effect
Changelog-Section: Added|Changed|Deprecated|Removed|Fixed|Security Overrides the type-to-section mapping. Required for Removed and Deprecated, which have no corresponding commit type.
RELEASE_NOTES=<text> or RELEASE_NOTES=n/a Overrides the bullet text, or suppresses the entry.
PKG-BREAK: <text> Emits a [PKG-BREAK]-prefixed bullet.

2. Versioning

This project follows Semantic Versioning 2.0.0.

2.1 Covered by SemVer

SemVer applies to the gopass command line interface and its observable behaviour: subcommands, flags and their aliases, the stdout and stderr contracts, JSON output schemas, exit codes (docs/exit-codes.md), configuration keys (docs/config.md), the on-disk store format, and the generated shell completions and man page.

2.2 Not covered by SemVer

The Go module github.com/gopasspw/gopass carries no independent semver guarantees. pkg/gopass is governed by A-12 and by the package documentation in pkg/gopass/doc.go, which declare it best-effort stable:

  • Additive changes — new exported symbols, new functional-option parameters — may appear in any release.
  • Breaking changes — removal or signature change of an exported symbol, change to an interface method set or to error semantics — require a [PKG-BREAK] entry in CHANGELOG.md and a deprecation window of two minor releases or three months, whichever is longer.

2.3 Precedence

Release a change that breaks only pkg/ consumers as MINOR or PATCH. Do not bump MAJOR for it. Release a change that breaks CLI users as MAJOR regardless of its pkg/ impact.

2.4 Pre-releases

Use vX.Y.Z-rc.N only: SemVer pre-release identifiers, dot-separated, numeric. Sign the tag. Cut it from the merged release/vX.Y.Z-rc.N branch. See docs/releases.md.

3. Branch names

<type>/<kebab-slug>[-<issue>]
  • Use a commit type from §1.2 as <type>.
  • Restrict the slug to [a-z0-9-], plus the single / separator. Use no spaces and no underscores. Limit the whole name to 60 characters.
  • Append the issue number when one exists: refactor/separate-storage-rcs-3411.
  • Do not create these prefixes by hand; the release automation owns them: release/vX.Y.Z and release/vX.Y.Z-rc.N, created by helpers/release/main.go, and prep/vX.Y.Z, documented in docs/releases.md.
  • Do not push feature branches to gopasspw/gopass. Work on a fork.

4. Tag names

  • Name releases vX.Y.Z and pre-releases vX.Y.Z-rc.N.
  • Sign every tag (git tag -s).
  • Create no other tags in this repository.

.github/workflows/autorelease.yml triggers on push: tags: 'v*'. helpers/release/main.go strips the leading v and parses the remainder as semver.

5. File names

5.1 General rules

  • Use lowercase kebab-case, restricted to [a-z0-9._-].
  • Use no spaces.
  • Zero-pad any numeric component that participates in lexical ordering.
  • Prefix with YYYY-MM-DD when chronological order matters.
  • Order components from most general to most specific, left to right.

5.2 Architecture decision records

Name records docs/adr/A-NN-<kebab-slug>.md, with NN zero-padded to two digits.

  • Write the H1 as # A-NN: <Title>, matching the file name.
  • Open the record with a **Status:** line — proposed, accepted, deferred, implemented, partially implemented, or superseded by A-NN — and a **Source:** line.
  • Never reuse a number. Supersede a decision by writing a new record.
  • Update docs/adr/README.md in the same commit that adds or supersedes a record.

5.3 Other documentation

  • Name command specifications docs/commands/<command>.md, where the file name is exactly the command name.
  • Name backend documents docs/backends/<backend>.md and use cases docs/usecases/<kebab-slug>.md.
  • Use lowercase throughout. Keep the SCREAMING_CASE names of root-level documents, which is the GitHub convention.

5.4 Go files

  • Use lowercase names with no underscores, except for the reserved suffixes below. Prefer a single word matching the primary type or the command.
  • Place a command implementation in internal/action/<command>.go with an accompanying internal/action/<command>_test.go.
  • Reserved suffixes: _test.go; the GOOS and GOARCH suffixes _unix.go, _windows.go, _linux.go, _darwin.go, _others.go; _gen.go for generated code; _fuzz.go for fuzz targets.
  • Give a build-tag variant that is not GOOS-based a descriptive suffix and an explicit //go:build line, for example screenshot_supported.go and screenshot_stub.go.
  • Split a file above roughly 600 lines along an existing type or feature seam.

6. Changelog

CHANGELOG.md follows Keep a Changelog 1.1.0: an ## [Unreleased] section at the top, then one ## [X.Y.Z] - YYYY-MM-DD section per release, each containing only the non-empty subsections of Added, Changed, Deprecated, Removed, Fixed, and Security.

helpers/release generates the entries from commit subjects at release time, using the mapping in §1.2 and the footers in §1.5. Do not edit CHANGELOG.md by hand, with two exceptions. Add a hand-written ## [Unreleased] entry for:

  • any change to the exported surface of pkg/, as required by A-12; and
  • any security: change.