summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.serena/project.yml30
-rw-r--r--cmd/foostore/main.go9
-rw-r--r--internal/cli/cli.go245
-rw-r--r--internal/cli/cli_test.go170
-rw-r--r--internal/cli/migrate_kdbx.go11
-rw-r--r--internal/git/git.go47
-rw-r--r--internal/git/git_test.go46
-rw-r--r--internal/git/noop.go65
8 files changed, 583 insertions, 40 deletions
diff --git a/.serena/project.yml b/.serena/project.yml
index 38d1325..060590e 100644
--- a/.serena/project.yml
+++ b/.serena/project.yml
@@ -115,3 +115,33 @@ initial_prompt: ""
# override of the corresponding setting in serena_config.yml, see the documentation there.
# If null or missing, the value from the global config is used.
symbol_info_budget:
+
+# The language backend to use for this project.
+# If not set, the global setting from serena_config.yml is used.
+# Valid values: LSP, JetBrains
+# Note: the backend is fixed at startup. If a project with a different backend
+# is activated post-init, an error will be returned.
+language_backend:
+
+# list of regex patterns which, when matched, mark a memory entry as read‑only.
+# Extends the list from the global configuration, merging the two lists.
+read_only_memory_patterns: []
+
+# line ending convention to use when writing source files.
+# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
+# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
+line_ending:
+
+# list of regex patterns for memories to completely ignore.
+# Matching memories will not appear in list_memories or activate_project output
+# and cannot be accessed via read_memory or write_memory.
+# To access ignored memory files, use the read_file tool on the raw file path.
+# Extends the list from the global configuration, merging the two lists.
+# Example: ["_archive/.*", "_episodes/.*"]
+ignored_memory_patterns: []
+
+# advanced configuration option allowing to configure language server-specific options.
+# Maps the language key to the options.
+# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available.
+# No documentation on options means no options are available.
+ls_specific_settings: {}
diff --git a/cmd/foostore/main.go b/cmd/foostore/main.go
index dd0da3d..b68b05d 100644
--- a/cmd/foostore/main.go
+++ b/cmd/foostore/main.go
@@ -30,7 +30,12 @@ func main() {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
- c, err := cli.New(ctx)
+ // Capture the remaining arguments once so both New and Run receive the same
+ // slice. New parses --backend from it to select the backend at init time;
+ // Run strips --backend before dispatching commands.
+ args := flag.Args()
+
+ c, err := cli.New(ctx, args)
if err != nil {
fmt.Fprintf(os.Stderr, "FATAL %v\n", err)
os.Exit(3)
@@ -39,5 +44,5 @@ func main() {
// flag.Args() returns arguments after flags, so flag-aware invocations
// like `foostore -version` work while plain `foostore cat foo` still passes
// all args through unchanged.
- os.Exit(c.Run(ctx, flag.Args()))
+ os.Exit(c.Run(ctx, args))
}
diff --git a/internal/cli/cli.go b/internal/cli/cli.go
index dc1c2ba..9afdd9f 100644
--- a/internal/cli/cli.go
+++ b/internal/cli/cli.go
@@ -21,6 +21,7 @@ import (
"codeberg.org/snonux/foostore/internal/config"
"codeberg.org/snonux/foostore/internal/crypto"
"codeberg.org/snonux/foostore/internal/git"
+ "codeberg.org/snonux/foostore/internal/keepass"
"codeberg.org/snonux/foostore/internal/shell"
"codeberg.org/snonux/foostore/internal/store"
"codeberg.org/snonux/foostore/internal/version"
@@ -57,65 +58,95 @@ var SearchActions = map[string]store.Action{
// in without touching the dispatch or shell-loop logic. The current geheim
// backend is *store.Store, which satisfies Backend via the compile-time check
// in internal/backend/backend.go.
+//
+// g is declared as git.Gitter (interface) rather than *git.Git so that the
+// keepass backend can supply a git.NoOp when the kdbx file lives outside a git
+// repository. Dispatch code requires no nil checks; it always calls through the
+// interface regardless of whether real git operations or no-ops are performed.
+//
+// effectiveBackend is the resolved backend name (after applying the --backend
+// flag override on top of cfg.Backend). Guards such as cmdMigrateKDBX use
+// this field so they reflect the actual runtime backend, not just the config file value.
type CLI struct {
- cfg *config.Config
- st backend.Backend
- g *git.Git
- clip *clipboard.Clipboard
- sh *shell.Shell
- openKDBX func(string, string) (KDBXStore, error)
- now func() time.Time
- lastResult string // most recent search result description
+ cfg *config.Config
+ st backend.Backend
+ g git.Gitter // real *git.Git or *git.NoOp when kdbx is outside a repo
+ clip *clipboard.Clipboard
+ sh *shell.Shell
+ openKDBX func(string, string) (KDBXStore, error)
+ now func() time.Time
+ lastResult string // most recent search result description
+ effectiveBackend string // resolved backend: --backend flag > cfg.Backend > "geheim"
}
// New initialises all runtime dependencies (config, PIN, cipher, store, git,
-// clipboard, shell) and returns a ready-to-use CLI. cmd/foostore/main.go calls
-// New with a signal-cancellable context so that long-running operations (fzf,
-// external editors) are interrupted cleanly on SIGINT/SIGTERM.
-func New(ctx context.Context) (*CLI, error) {
- return newCLI(ctx)
+// clipboard, shell) and returns a ready-to-use CLI. argv (typically
+// os.Args[1:] after standard flags) is parsed for a --backend flag before
+// initialisation so the correct backend is instantiated from the start.
+// cmd/foostore/main.go calls New with a signal-cancellable context so that
+// long-running operations (fzf, external editors) are interrupted cleanly on
+// SIGINT/SIGTERM.
+func New(ctx context.Context, argv []string) (*CLI, error) {
+ backendName, _ := parseBackendFlag(argv)
+ return newCLI(ctx, backendName)
}
// Run dispatches argv (typically os.Args[1:]) to the appropriate handler or
-// enters the interactive shell loop. Returns an exit code suitable for
-// os.Exit. The caller is responsible for calling sh.Close() when done;
-// cmd/foostore/main.go does this via defer.
+// enters the interactive shell loop. The --backend flag is stripped from argv
+// before dispatch because it was already consumed by New. Returns an exit code
+// suitable for os.Exit. The caller is responsible for calling sh.Close() when
+// done; cmd/foostore/main.go does this via defer.
func (c *CLI) Run(ctx context.Context, argv []string) int {
defer c.sh.Close()
- return c.run(ctx, argv)
+ _, strippedArgv := parseBackendFlag(argv)
+ return c.run(ctx, strippedArgv)
}
-// newCLI initialises all dependencies: config, PIN, cipher, store, git,
-// clipboard, and interactive shell. Mirrors the Ruby CLI#initialize logic.
-func newCLI(ctx context.Context) (*CLI, error) {
- cfg := config.Load()
-
- pin, err := readPIN()
- if err != nil {
- return nil, fmt.Errorf("reading PIN: %w", err)
+// parseBackendFlag scans argv for a "--backend VALUE" pair and returns the
+// backend name and the remaining argv with that pair removed. Returns ("", argv)
+// when no --backend flag is present. The flag may appear anywhere in argv.
+//
+// Note: only the space-separated form "--backend VALUE" is supported.
+// The equals form "--backend=VALUE" is NOT parsed and will be silently ignored
+// (treated as an unknown argument that propagates to the command dispatcher).
+func parseBackendFlag(argv []string) (string, []string) {
+ for i, arg := range argv {
+ if arg == "--backend" && i+1 < len(argv) {
+ remaining := make([]string, 0, len(argv)-2)
+ remaining = append(remaining, argv[:i]...)
+ remaining = append(remaining, argv[i+2:]...)
+ return argv[i+1], remaining
+ }
}
+ return "", argv
+}
- ciph, err := crypto.NewCipher(cfg.KeyFile, cfg.KeyLength, pin, cfg.AddToIV)
- if err != nil {
- return nil, fmt.Errorf("initialising cipher: %w", err)
- }
+// newCLI initialises all dependencies: config, PIN/passphrase, cipher or
+// keepass credentials, store or keepass backend, git, clipboard, and
+// interactive shell. backendName overrides cfg.Backend when non-empty
+// (supplied from the --backend CLI flag); an empty string means "use
+// cfg.Backend". Mirrors the Ruby CLI#initialize logic.
+func newCLI(ctx context.Context, backendName string) (*CLI, error) {
+ cfg := config.Load()
- g := git.New(cfg.DataDir)
+ // Resolve the effective backend: flag overrides config, config defaults to "geheim".
+ effectiveBackend := resolveBackend(backendName, cfg.Backend)
- st, err := store.New(&cfg, ciph, g)
+ st, g, err := buildBackend(ctx, &cfg, effectiveBackend)
if err != nil {
- return nil, fmt.Errorf("initialising store: %w", err)
+ return nil, err
}
clip := clipboard.New(cfg.GnomeClipboardCmd, cfg.MacOSClipboardCmd)
c := &CLI{
- cfg: &cfg,
- st: st,
- g: g,
- clip: clip,
- openKDBX: OpenKDBXStore,
- now: time.Now,
+ cfg: &cfg,
+ st: st,
+ g: g,
+ clip: clip,
+ openKDBX: OpenKDBXStore,
+ now: time.Now,
+ effectiveBackend: effectiveBackend,
}
// Create the shell with a completion function that references the CLI.
@@ -130,6 +161,144 @@ func newCLI(ctx context.Context) (*CLI, error) {
return c, nil
}
+// resolveBackend returns the effective backend name given the flag override and
+// the config value. flag takes precedence; empty strings fall back to "geheim".
+func resolveBackend(flagValue, cfgValue string) string {
+ if flagValue != "" {
+ return flagValue
+ }
+ if cfgValue != "" {
+ return cfgValue
+ }
+ return "geheim"
+}
+
+// buildBackend constructs the Backend and its associated Gitter based on
+// effectiveBackend ("geheim" or "keepass"). Returns the Backend and git client
+// so the caller can wire them into the CLI struct.
+func buildBackend(ctx context.Context, cfg *config.Config, effectiveBackend string) (backend.Backend, git.Gitter, error) {
+ switch effectiveBackend {
+ case "keepass":
+ return buildKeepassBackend(ctx, cfg)
+ default:
+ return buildGeheimBackend(cfg)
+ }
+}
+
+// buildGeheimGit returns a *git.Git pointed at cfg.DataDir. The geheim data
+// directory is always a git repository (it is the store itself), so a real
+// git client is always appropriate here — unlike the keepass backend which
+// may live outside a repo and needs a NoOp fallback.
+func buildGeheimGit(cfg *config.Config) git.Gitter {
+ return git.New(cfg.DataDir)
+}
+
+// buildGeheimBackend initialises the original AES-encrypted geheim backend:
+// reads the PIN, builds the cipher, creates a *store.Store, and points git at
+// cfg.DataDir via buildGeheimGit.
+func buildGeheimBackend(cfg *config.Config) (backend.Backend, git.Gitter, error) {
+ pin, err := readPIN()
+ if err != nil {
+ return nil, nil, fmt.Errorf("reading PIN: %w", err)
+ }
+
+ ciph, err := crypto.NewCipher(cfg.KeyFile, cfg.KeyLength, pin, cfg.AddToIV)
+ if err != nil {
+ return nil, nil, fmt.Errorf("initialising cipher: %w", err)
+ }
+
+ g := buildGeheimGit(cfg)
+
+ st, err := store.New(cfg, ciph, g)
+ if err != nil {
+ return nil, nil, fmt.Errorf("initialising store: %w", err)
+ }
+
+ return st, g, nil
+}
+
+// buildKeepassBackend reads the KeePass passphrase and optional key-file bytes,
+// then instantiates a keepass.Backend. The git client is pointed at the
+// directory that contains the .kdbx file.
+//
+// If that directory is a git repository (detected via git rev-parse
+// --is-inside-work-tree), a real *git.Git is returned so that sync/status/
+// commit/reset operate normally against it. If the directory is not a git
+// repository, a *git.NoOp is returned instead — its methods print an
+// informational message ("kdbx file is not in a git repo; skipping") and return
+// nil, keeping the UX transparent without crashing.
+func buildKeepassBackend(ctx context.Context, cfg *config.Config) (backend.Backend, git.Gitter, error) {
+ passphrase, err := readKeepassPassphrase(cfg)
+ if err != nil {
+ return nil, nil, fmt.Errorf("reading keepass passphrase: %w", err)
+ }
+
+ keyFileData, err := readKeepassKeyFile(cfg)
+ if err != nil {
+ return nil, nil, fmt.Errorf("reading keepass key file: %w", err)
+ }
+
+ st, err := keepass.New(cfg, passphrase, keyFileData)
+ if err != nil {
+ return nil, nil, fmt.Errorf("initialising keepass backend: %w", err)
+ }
+
+ g := buildKeepassGit(cfg.KDBXPath)
+ return st, g, nil
+}
+
+// buildKeepassGit returns the appropriate Gitter for the directory containing
+// the kdbx file. When the directory is a git repository, a real *git.Git is
+// returned. Otherwise, a *git.NoOp is returned so that callers receive
+// informational messages rather than errors when running git commands.
+func buildKeepassGit(kdbxPath string) git.Gitter {
+ kdbxDir := filepath.Dir(kdbxPath)
+ if git.IsGitRepo(kdbxDir) {
+ return git.New(kdbxDir)
+ }
+ return git.NewNoOp()
+}
+
+// readKeepassPassphrase resolves the KeePass database passphrase using the
+// following priority: $PIN env var → cfg.KDBXPassFile → interactive prompt.
+// This mirrors the geheim readPIN() priority while accommodating the extra
+// KDBXPassFile option specific to the keepass backend.
+func readKeepassPassphrase(cfg *config.Config) (string, error) {
+ // 1. $PIN environment variable — same as geheim backend for symmetry.
+ if pin := os.Getenv("PIN"); pin != "" {
+ return pin, nil
+ }
+
+ // 2. KDBXPassFile — a file containing the password (trimmed of newlines).
+ if cfg.KDBXPassFile != "" {
+ pass, err := readPasswordFile(cfg.KDBXPassFile)
+ if err != nil {
+ return "", fmt.Errorf("reading KDBXPassFile: %w", err)
+ }
+ return pass, nil
+ }
+
+ // 3. Interactive prompt — same mechanism as geheim's PIN prompt.
+ pass, err := shell.ReadPassword("< KeePass passphrase: ")
+ if err != nil {
+ return "", fmt.Errorf("reading passphrase from terminal: %w", err)
+ }
+ return pass, nil
+}
+
+// readKeepassKeyFile reads the optional KeePass key-file bytes. Returns nil
+// when cfg.KDBXKeyFile is empty (password-only authentication).
+func readKeepassKeyFile(cfg *config.Config) ([]byte, error) {
+ if cfg.KDBXKeyFile == "" {
+ return nil, nil
+ }
+ data, err := os.ReadFile(cfg.KDBXKeyFile)
+ if err != nil {
+ return nil, fmt.Errorf("reading key file %q: %w", cfg.KDBXKeyFile, err)
+ }
+ return data, nil
+}
+
// readPIN returns the PIN string for encryption. If the $PIN environment
// variable is set, it is used directly (matching the Ruby ENV['PIN'] check).
// Otherwise the user is prompted with masked input via the shell package.
diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go
index 10712c1..3cf9503 100644
--- a/internal/cli/cli_test.go
+++ b/internal/cli/cli_test.go
@@ -741,3 +741,173 @@ func TestDispatch_migrateKDBX_writesBinaryAndSavesKDBX(t *testing.T) {
t.Fatalf("expected binary upsert record, got %v", fake.upserts)
}
}
+
+// ---- parseBackendFlag -------------------------------------------------------
+
+// TestParseBackendFlag covers the --backend flag extraction.
+func TestParseBackendFlag(t *testing.T) {
+ cases := []struct {
+ name string
+ argv []string
+ wantBackend string
+ wantArgv []string
+ }{
+ {
+ name: "no flag",
+ argv: []string{"ls"},
+ wantBackend: "",
+ wantArgv: []string{"ls"},
+ },
+ {
+ name: "flag at start",
+ argv: []string{"--backend", "keepass", "ls"},
+ wantBackend: "keepass",
+ wantArgv: []string{"ls"},
+ },
+ {
+ name: "flag at end",
+ argv: []string{"cat", "foo", "--backend", "geheim"},
+ wantBackend: "geheim",
+ wantArgv: []string{"cat", "foo"},
+ },
+ {
+ name: "flag alone",
+ argv: []string{"--backend", "keepass"},
+ wantBackend: "keepass",
+ wantArgv: []string{},
+ },
+ {
+ name: "backend flag without value (treated as no flag)",
+ argv: []string{"--backend"},
+ wantBackend: "",
+ wantArgv: []string{"--backend"},
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ gotBackend, gotArgv := parseBackendFlag(tc.argv)
+ if gotBackend != tc.wantBackend {
+ t.Errorf("backend = %q; want %q", gotBackend, tc.wantBackend)
+ }
+ if len(gotArgv) != len(tc.wantArgv) {
+ t.Fatalf("argv len = %d; want %d (%v vs %v)", len(gotArgv), len(tc.wantArgv), gotArgv, tc.wantArgv)
+ }
+ for i := range gotArgv {
+ if gotArgv[i] != tc.wantArgv[i] {
+ t.Errorf("argv[%d] = %q; want %q", i, gotArgv[i], tc.wantArgv[i])
+ }
+ }
+ })
+ }
+}
+
+// ---- resolveBackend ---------------------------------------------------------
+
+// TestResolveBackend covers the flag-over-config priority logic.
+func TestResolveBackend(t *testing.T) {
+ cases := []struct {
+ name string
+ flagValue string
+ cfgValue string
+ want string
+ }{
+ {name: "flag wins", flagValue: "keepass", cfgValue: "geheim", want: "keepass"},
+ {name: "config when no flag", flagValue: "", cfgValue: "keepass", want: "keepass"},
+ {name: "default when both empty", flagValue: "", cfgValue: "", want: "geheim"},
+ {name: "flag overrides empty config", flagValue: "geheim", cfgValue: "", want: "geheim"},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ got := resolveBackend(tc.flagValue, tc.cfgValue)
+ if got != tc.want {
+ t.Errorf("resolveBackend(%q, %q) = %q; want %q", tc.flagValue, tc.cfgValue, got, tc.want)
+ }
+ })
+ }
+}
+
+// ---- readKeepassPassphrase --------------------------------------------------
+
+// TestReadKeepassPassphrase_envVar verifies that $PIN takes precedence over
+// all other sources for the keepass passphrase.
+func TestReadKeepassPassphrase_envVar(t *testing.T) {
+ t.Setenv("PIN", "envpassword")
+ cfg := &config.Config{KDBXPassFile: "/should/not/be/read"}
+ got, err := readKeepassPassphrase(cfg)
+ if err != nil {
+ t.Fatalf("readKeepassPassphrase: %v", err)
+ }
+ if got != "envpassword" {
+ t.Errorf("readKeepassPassphrase = %q; want envpassword", got)
+ }
+}
+
+// TestReadKeepassPassphrase_passFile verifies that KDBXPassFile is read when
+// $PIN is unset.
+func TestReadKeepassPassphrase_passFile(t *testing.T) {
+ t.Setenv("PIN", "")
+ passFile := filepath.Join(t.TempDir(), "kp.pass")
+ if err := os.WriteFile(passFile, []byte("filepassword\n"), 0o600); err != nil {
+ t.Fatalf("write pass file: %v", err)
+ }
+ cfg := &config.Config{KDBXPassFile: passFile}
+ got, err := readKeepassPassphrase(cfg)
+ if err != nil {
+ t.Fatalf("readKeepassPassphrase: %v", err)
+ }
+ if got != "filepassword" {
+ t.Errorf("readKeepassPassphrase = %q; want filepassword", got)
+ }
+}
+
+// ---- readKeepassKeyFile -----------------------------------------------------
+
+// TestReadKeepassKeyFile_empty verifies that an empty KDBXKeyFile returns nil.
+func TestReadKeepassKeyFile_empty(t *testing.T) {
+ cfg := &config.Config{KDBXKeyFile: ""}
+ data, err := readKeepassKeyFile(cfg)
+ if err != nil {
+ t.Fatalf("readKeepassKeyFile: %v", err)
+ }
+ if data != nil {
+ t.Errorf("readKeepassKeyFile empty = %v; want nil", data)
+ }
+}
+
+// TestReadKeepassKeyFile_readsBytes verifies that a non-empty KDBXKeyFile
+// path returns its contents.
+func TestReadKeepassKeyFile_readsBytes(t *testing.T) {
+ keyFile := filepath.Join(t.TempDir(), "kp.key")
+ want := []byte{0xDE, 0xAD, 0xBE, 0xEF}
+ if err := os.WriteFile(keyFile, want, 0o600); err != nil {
+ t.Fatalf("write key file: %v", err)
+ }
+ cfg := &config.Config{KDBXKeyFile: keyFile}
+ got, err := readKeepassKeyFile(cfg)
+ if err != nil {
+ t.Fatalf("readKeepassKeyFile: %v", err)
+ }
+ if string(got) != string(want) {
+ t.Errorf("readKeepassKeyFile = %v; want %v", got, want)
+ }
+}
+
+// ---- migrate-kdbx with keepass backend guard --------------------------------
+
+// TestDispatch_migrateKDBX_blockedWithKeepassBackend verifies that
+// migrate-kdbx returns exit code 1 when the effective backend is "keepass".
+// The guard in cmdMigrateKDBX checks c.effectiveBackend (not c.cfg.Backend),
+// so we must set that field directly to exercise the actual guard path.
+func TestDispatch_migrateKDBX_blockedWithKeepassBackend(t *testing.T) {
+ c, _ := testCLI(t)
+ // Set effectiveBackend directly — cmdMigrateKDBX checks this field rather
+ // than cfg.Backend so that --backend flag overrides are also caught.
+ c.effectiveBackend = "keepass"
+
+ ec := c.dispatch(context.Background(), []string{"migrate-kdbx"})
+ if ec != 1 {
+ t.Errorf("dispatch(migrate-kdbx) with keepass effectiveBackend = %d; want 1", ec)
+ }
+}
diff --git a/internal/cli/migrate_kdbx.go b/internal/cli/migrate_kdbx.go
index bc98d96..64241ac 100644
--- a/internal/cli/migrate_kdbx.go
+++ b/internal/cli/migrate_kdbx.go
@@ -31,6 +31,17 @@ type migrateKDBXStats struct {
}
func (c *CLI) cmdMigrateKDBX(ctx context.Context, argv []string) int {
+ // migrate-kdbx only makes sense when the source is the geheim backend.
+ // Refuse early when the active backend is already keepass to avoid
+ // accidentally migrating keepass→keepass.
+ // We check c.effectiveBackend (which incorporates the --backend flag override)
+ // rather than c.cfg.Backend (config file only) so that "foostore --backend keepass
+ // migrate-kdbx" is correctly rejected even when cfg.Backend is empty.
+ if c.effectiveBackend == "keepass" {
+ warn("migrate-kdbx is not supported when the active backend is 'keepass'; it migrates geheim→keepass only")
+ return 1
+ }
+
opts, err := c.parseMigrateKDBXOptions(argv)
if err != nil {
warn(err.Error())
diff --git a/internal/git/git.go b/internal/git/git.go
index a1e6d6e..72d55af 100644
--- a/internal/git/git.go
+++ b/internal/git/git.go
@@ -1,6 +1,11 @@
// Package git wraps git operations used by foostore to manage the secret store.
// It mirrors the Git module from the original Ruby implementation (geheim.rb lines 79-123),
// running real git subprocesses rather than using a Go git library.
+//
+// The package exposes a Gitter interface so that callers can accept either a
+// real *Git (backed by a git repository) or a *NoOp stub (for directories that
+// are not git repositories). This avoids nil-pointer panics in the CLI dispatch
+// loop and keeps git-related decisions local to this package.
package git
import (
@@ -10,18 +15,60 @@ import (
"fmt"
"os/exec"
"path/filepath"
+ "strings"
)
+// Gitter is the interface that both *Git (real git operations) and *NoOp
+// (informational no-ops) implement. The CLI holds a Gitter so that it can be
+// freely swapped without changing any dispatch logic.
+type Gitter interface {
+ // Add stages a single file for the next commit.
+ Add(ctx context.Context, filePath string) error
+
+ // Remove stages a file deletion for the next commit.
+ Remove(ctx context.Context, filePath string) error
+
+ // Status prints the current git status of the working directory.
+ Status(ctx context.Context) error
+
+ // Commit records all staged changes with a generic commit message.
+ Commit(ctx context.Context) error
+
+ // Reset discards all uncommitted changes in the working directory.
+ Reset(ctx context.Context) error
+
+ // Sync pulls from and pushes to each configured remote repository.
+ Sync(ctx context.Context, syncRepos []string) error
+}
+
// Git provides git operations scoped to the secret store's data directory.
type Git struct {
dataDir string
}
+// Compile-time assertion: *Git must satisfy Gitter.
+var _ Gitter = (*Git)(nil)
+
// New creates a Git helper for the given data directory.
func New(dataDir string) *Git {
return &Git{dataDir: dataDir}
}
+// IsGitRepo reports whether the given directory is inside a git working tree.
+// It runs 'git rev-parse --is-inside-work-tree' in that directory and returns
+// true when the command exits with status 0 and outputs "true". This is the
+// canonical way to check for a git repo without inspecting the filesystem
+// structure directly (works with worktrees, submodules, etc.).
+func IsGitRepo(dir string) bool {
+ cmd := exec.Command("git", "rev-parse", "--is-inside-work-tree")
+ cmd.Dir = dir
+ out, err := cmd.Output()
+ if err != nil {
+ return false
+ }
+ return strings.TrimSpace(string(out)) == "true"
+}
+
// Add stages a single file for the next commit.
// It changes the working directory to the file's parent so that git add
// receives only the base name, matching the Ruby Dir.chdir pattern.
diff --git a/internal/git/git_test.go b/internal/git/git_test.go
index fa1f8f7..462c747 100644
--- a/internal/git/git_test.go
+++ b/internal/git/git_test.go
@@ -264,3 +264,49 @@ func TestSync_bad_remote(t *testing.T) {
t.Fatal("expected error when syncing with a nonexistent remote, got nil")
}
}
+
+// TestIsGitRepo_inside verifies that IsGitRepo returns true for a directory
+// that is a git repository.
+func TestIsGitRepo_inside(t *testing.T) {
+ dir := initRepo(t)
+ if !git.IsGitRepo(dir) {
+ t.Errorf("expected IsGitRepo(%q) = true for a git repo, got false", dir)
+ }
+}
+
+// TestIsGitRepo_outside verifies that IsGitRepo returns false for a plain
+// directory that is not a git repository.
+func TestIsGitRepo_outside(t *testing.T) {
+ dir := t.TempDir() // just a plain temp dir, not git-initialised
+ if git.IsGitRepo(dir) {
+ t.Errorf("expected IsGitRepo(%q) = false for a non-git dir, got true", dir)
+ }
+}
+
+// TestNoOp_satisfies_Gitter verifies that *git.NoOp compiles as a Gitter and
+// that all its methods return nil (no-op, no error) so they are safe to call
+// unconditionally from CLI dispatch.
+func TestNoOp_satisfies_Gitter(t *testing.T) {
+ var g git.Gitter = git.NewNoOp()
+ ctx := context.Background()
+
+ table := []struct {
+ name string
+ fn func() error
+ }{
+ {"Add", func() error { return g.Add(ctx, "/some/path") }},
+ {"Remove", func() error { return g.Remove(ctx, "/some/path") }},
+ {"Status", func() error { return g.Status(ctx) }},
+ {"Commit", func() error { return g.Commit(ctx) }},
+ {"Reset", func() error { return g.Reset(ctx) }},
+ {"Sync", func() error { return g.Sync(ctx, []string{"origin"}) }},
+ }
+
+ for _, tt := range table {
+ t.Run(tt.name, func(t *testing.T) {
+ if err := tt.fn(); err != nil {
+ t.Errorf("NoOp.%s returned error: %v", tt.name, err)
+ }
+ })
+ }
+}
diff --git a/internal/git/noop.go b/internal/git/noop.go
new file mode 100644
index 0000000..44dc68c
--- /dev/null
+++ b/internal/git/noop.go
@@ -0,0 +1,65 @@
+package git
+
+import (
+ "context"
+ "fmt"
+)
+
+// noOpMessage is the message printed whenever a git operation is skipped
+// because the kdbx file is not inside a git repository.
+const noOpMessage = "kdbx file is not in a git repo; skipping"
+
+// NoOp is a Gitter implementation whose every method prints an informational
+// message and returns nil. It is used when the KeePass database file lives
+// outside of a git repository so that sync/status/commit/reset commands remain
+// functional and transparent rather than crashing or returning errors.
+//
+// Keeping the no-op behaviour in its own type (rather than nil-checking in the
+// CLI dispatch) respects the Open/Closed Principle: the CLI is open for
+// extension (new backends, new git behaviours) without modification.
+type NoOp struct{}
+
+// Compile-time assertion: *NoOp must satisfy Gitter.
+var _ Gitter = (*NoOp)(nil)
+
+// NewNoOp returns a *NoOp that satisfies Gitter with all operations being
+// informational no-ops.
+func NewNoOp() *NoOp {
+ return &NoOp{}
+}
+
+// Add prints the no-op message and returns nil.
+func (n *NoOp) Add(_ context.Context, _ string) error {
+ fmt.Printf("> %s\n", noOpMessage)
+ return nil
+}
+
+// Remove prints the no-op message and returns nil.
+func (n *NoOp) Remove(_ context.Context, _ string) error {
+ fmt.Printf("> %s\n", noOpMessage)
+ return nil
+}
+
+// Status prints the no-op message and returns nil.
+func (n *NoOp) Status(_ context.Context) error {
+ fmt.Printf("> %s\n", noOpMessage)
+ return nil
+}
+
+// Commit prints the no-op message and returns nil.
+func (n *NoOp) Commit(_ context.Context) error {
+ fmt.Printf("> %s\n", noOpMessage)
+ return nil
+}
+
+// Reset prints the no-op message and returns nil.
+func (n *NoOp) Reset(_ context.Context) error {
+ fmt.Printf("> %s\n", noOpMessage)
+ return nil
+}
+
+// Sync prints the no-op message and returns nil.
+func (n *NoOp) Sync(_ context.Context, _ []string) error {
+ fmt.Printf("> %s\n", noOpMessage)
+ return nil
+}