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/git | |
| 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/git')
| -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 |
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 +} |
