summaryrefslogtreecommitdiff
path: root/internal/git
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-17 09:04:29 +0300
committerPaul Buetow <paul@buetow.org>2026-04-17 09:04:29 +0300
commit58349705d5adafa60b8a1dddd0f5c72bad568d3b (patch)
treeeefad461de05ad027936c44a3f73925ed1fce652 /internal/git
parenteb89e32c6675d4ed50f66580346e5ea8f3d7b5b2 (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/git')
-rw-r--r--internal/git/git.go47
-rw-r--r--internal/git/git_test.go46
-rw-r--r--internal/git/noop.go65
3 files changed, 158 insertions, 0 deletions
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
+}