diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-17 09:04:29 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-17 09:04:29 +0300 |
| commit | 58349705d5adafa60b8a1dddd0f5c72bad568d3b (patch) | |
| tree | eefad461de05ad027936c44a3f73925ed1fce652 /internal | |
| parent | eb89e32c6675d4ed50f66580346e5ea8f3d7b5b2 (diff) | |
refactor: extract buildGeheimGit helper to keep buildGeheimBackend under 30 lines
Extracts a 3-line buildGeheimGit(cfg) helper from buildGeheimBackend,
mirroring the existing buildKeepassGit pattern and satisfying the project
guideline of keeping functions under 30 lines. Also includes the full
o4 backend-abstraction changeset: Gitter interface, git.NoOp, --backend
flag parsing, keepass backend wiring, and effectiveBackend guard for
migrate-kdbx.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/cli/cli.go | 245 | ||||
| -rw-r--r-- | internal/cli/cli_test.go | 170 | ||||
| -rw-r--r-- | internal/cli/migrate_kdbx.go | 11 | ||||
| -rw-r--r-- | internal/git/git.go | 47 | ||||
| -rw-r--r-- | internal/git/git_test.go | 46 | ||||
| -rw-r--r-- | internal/git/noop.go | 65 |
6 files changed, 546 insertions, 38 deletions
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 +} |
