summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-02-22 09:39:01 +0200
committerPaul Buetow <paul@buetow.org>2026-02-22 09:39:01 +0200
commit66d824193dc63eeea5f59bc86238f5f6106ae02b (patch)
treec79e5020ee638646abef2ed1975abc79883af7bb /internal
parentd3702993f2111e2e6e7c2702b12f10095b2b3519 (diff)
Implement git package (task 355)
Diffstat (limited to 'internal')
-rw-r--r--internal/git/git.go91
-rw-r--r--internal/git/git_test.go200
2 files changed, 291 insertions, 0 deletions
diff --git a/internal/git/git.go b/internal/git/git.go
index 6d29fda..1680561 100644
--- a/internal/git/git.go
+++ b/internal/git/git.go
@@ -1,2 +1,93 @@
// Package git wraps git operations used by geheim 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.
package git
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "fmt"
+ "os/exec"
+ "path/filepath"
+)
+
+// Git provides git operations scoped to the secret store's data directory.
+type Git struct {
+ dataDir string
+}
+
+// New creates a Git helper for the given data directory.
+func New(dataDir string) *Git {
+ return &Git{dataDir: dataDir}
+}
+
+// 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.
+func (g *Git) Add(ctx context.Context, filePath string) error {
+ return run(ctx, filepath.Dir(filePath), "git", "add", filepath.Base(filePath))
+}
+
+// Remove stages a file deletion for the next commit using git rm.
+// Like Add, it operates in the file's parent directory.
+func (g *Git) Remove(ctx context.Context, filePath string) error {
+ return run(ctx, filepath.Dir(filePath), "git", "rm", filepath.Base(filePath))
+}
+
+// Status prints the current git status of the data directory.
+func (g *Git) Status(ctx context.Context) error {
+ return run(ctx, g.dataDir, "git", "status")
+}
+
+// Commit records all staged changes with a deliberately vague commit message
+// so that secret names are not exposed in commit history.
+func (g *Git) Commit(ctx context.Context) error {
+ return run(ctx, g.dataDir, "git", "commit", "-a", "-m",
+ "Changing stuff, not telling what in commit history")
+}
+
+// Reset discards all uncommitted changes in the data directory.
+func (g *Git) Reset(ctx context.Context) error {
+ return run(ctx, g.dataDir, "git", "reset", "--hard")
+}
+
+// Sync pulls from and pushes to each configured remote repository in order,
+// then prints the final status. This keeps multiple machines in sync.
+func (g *Git) Sync(ctx context.Context, syncRepos []string) error {
+ fmt.Printf("> Synchronising %s\n", g.dataDir)
+
+ for _, repo := range syncRepos {
+ if err := run(ctx, g.dataDir, "git", "pull", repo, "master"); err != nil {
+ return err
+ }
+ if err := run(ctx, g.dataDir, "git", "push", repo, "master"); err != nil {
+ return err
+ }
+ }
+
+ return run(ctx, g.dataDir, "git", "status")
+}
+
+// run executes a git command in the given directory, printing each line of
+// combined stdout+stderr with a "> " prefix so the user can follow progress.
+// It returns an error if the command exits with a non-zero status.
+func run(ctx context.Context, dir string, args ...string) error {
+ cmd := exec.CommandContext(ctx, args[0], args[1:]...)
+ cmd.Dir = dir
+
+ var buf bytes.Buffer
+ cmd.Stdout = &buf
+ cmd.Stderr = &buf
+
+ err := cmd.Run()
+
+ // Print all output lines regardless of whether the command succeeded,
+ // so the user sees error messages from git itself.
+ scanner := bufio.NewScanner(&buf)
+ for scanner.Scan() {
+ fmt.Printf("> %s\n", scanner.Text())
+ }
+
+ return err
+}
diff --git a/internal/git/git_test.go b/internal/git/git_test.go
new file mode 100644
index 0000000..1fb531c
--- /dev/null
+++ b/internal/git/git_test.go
@@ -0,0 +1,200 @@
+package git_test
+
+import (
+ "context"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "codeberg.org/snonux/geheim/internal/git"
+)
+
+// initRepo creates a temporary git repository with a minimal config so that
+// git commit works without requiring the user's global identity to be set.
+func initRepo(t *testing.T) string {
+ t.Helper()
+ dir := t.TempDir()
+
+ for _, args := range [][]string{
+ {"git", "init", dir},
+ {"git", "-C", dir, "config", "user.email", "test@geheim.test"},
+ {"git", "-C", dir, "config", "user.name", "Geheim Test"},
+ } {
+ out, err := exec.Command(args[0], args[1:]...).CombinedOutput()
+ if err != nil {
+ t.Fatalf("setup %v: %v\n%s", args, err, out)
+ }
+ }
+
+ return dir
+}
+
+// writeFile creates a file with the given content inside dir.
+func writeFile(t *testing.T, dir, name, content string) string {
+ t.Helper()
+ path := filepath.Join(dir, name)
+ if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
+ t.Fatalf("writeFile %s: %v", path, err)
+ }
+ return path
+}
+
+// gitOutput runs a raw git command in dir and returns trimmed stdout+stderr.
+func gitOutput(t *testing.T, dir string, args ...string) string {
+ t.Helper()
+ args = append([]string{"-C", dir}, args...)
+ out, err := exec.Command("git", args...).CombinedOutput()
+ if err != nil {
+ t.Fatalf("git %v: %v\n%s", args, err, out)
+ }
+ return strings.TrimSpace(string(out))
+}
+
+// commitAll stages everything in dir and creates a commit so subsequent
+// operations have a baseline history to work against.
+func commitAll(t *testing.T, dir, msg string) {
+ t.Helper()
+ for _, args := range [][]string{
+ {"git", "-C", dir, "add", "."},
+ {"git", "-C", dir, "commit", "-m", msg},
+ } {
+ out, err := exec.Command(args[0], args[1:]...).CombinedOutput()
+ if err != nil {
+ t.Fatalf("commitAll %v: %v\n%s", args, err, out)
+ }
+ }
+}
+
+// TestAdd verifies that Add stages a file so git status reports it as a new file.
+func TestAdd(t *testing.T) {
+ dir := initRepo(t)
+ g := git.New(dir)
+ ctx := context.Background()
+
+ path := writeFile(t, dir, "secret.age", "encrypted data")
+
+ if err := g.Add(ctx, path); err != nil {
+ t.Fatalf("Add: %v", err)
+ }
+
+ status := gitOutput(t, dir, "status", "--short")
+ if !strings.Contains(status, "A secret.age") {
+ t.Errorf("expected 'A secret.age' in status, got: %q", status)
+ }
+}
+
+// TestRemove verifies that Remove stages a file deletion (git rm) so the file
+// disappears from the index after a committed baseline.
+func TestRemove(t *testing.T) {
+ dir := initRepo(t)
+ g := git.New(dir)
+ ctx := context.Background()
+
+ path := writeFile(t, dir, "secret.age", "encrypted data")
+ commitAll(t, dir, "initial commit")
+
+ if err := g.Remove(ctx, path); err != nil {
+ t.Fatalf("Remove: %v", err)
+ }
+
+ status := gitOutput(t, dir, "status", "--short")
+ if !strings.Contains(status, "D secret.age") {
+ t.Errorf("expected 'D secret.age' in status, got: %q", status)
+ }
+}
+
+// TestCommit verifies that Commit records changes with the hardcoded message.
+func TestCommit(t *testing.T) {
+ dir := initRepo(t)
+ g := git.New(dir)
+ ctx := context.Background()
+
+ writeFile(t, dir, "secret.age", "encrypted data")
+ // Stage the file so there is something to commit.
+ if out, err := exec.Command("git", "-C", dir, "add", ".").CombinedOutput(); err != nil {
+ t.Fatalf("git add: %v\n%s", err, out)
+ }
+
+ if err := g.Commit(ctx); err != nil {
+ t.Fatalf("Commit: %v", err)
+ }
+
+ log := gitOutput(t, dir, "log", "--oneline", "-1")
+ const want = "Changing stuff, not telling what in commit history"
+ if !strings.Contains(log, want) {
+ t.Errorf("expected commit message %q in log, got: %q", want, log)
+ }
+}
+
+// TestStatus verifies that Status runs without error on a clean repository.
+func TestStatus(t *testing.T) {
+ dir := initRepo(t)
+ g := git.New(dir)
+ ctx := context.Background()
+
+ if err := g.Status(ctx); err != nil {
+ t.Fatalf("Status: %v", err)
+ }
+}
+
+// TestReset verifies that Reset discards uncommitted working-tree changes.
+func TestReset(t *testing.T) {
+ dir := initRepo(t)
+ g := git.New(dir)
+ ctx := context.Background()
+
+ path := writeFile(t, dir, "secret.age", "original")
+ commitAll(t, dir, "initial commit")
+
+ // Overwrite the file so there is a dirty working tree.
+ if err := os.WriteFile(path, []byte("modified"), 0o600); err != nil {
+ t.Fatalf("overwrite: %v", err)
+ }
+
+ if err := g.Reset(ctx); err != nil {
+ t.Fatalf("Reset: %v", err)
+ }
+
+ got, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("ReadFile after Reset: %v", err)
+ }
+ if string(got) != "original" {
+ t.Errorf("expected file content 'original' after reset, got: %q", got)
+ }
+}
+
+// TestAdd_nonexistent_file verifies that Add returns an error when the target
+// file does not exist, because git add will fail.
+func TestAdd_nonexistent_file(t *testing.T) {
+ dir := initRepo(t)
+ g := git.New(dir)
+ ctx := context.Background()
+
+ err := g.Add(ctx, filepath.Join(dir, "does-not-exist.age"))
+ if err == nil {
+ t.Fatal("expected error when adding a nonexistent file, got nil")
+ }
+}
+
+// TestCommit_nothing_to_commit verifies that Commit returns an error (exit 1
+// from git) when there is nothing staged, rather than panicking or crashing.
+// Callers are expected to guard against this case, but the package must not hide
+// the error entirely.
+func TestCommit_nothing_to_commit(t *testing.T) {
+ dir := initRepo(t)
+ g := git.New(dir)
+ ctx := context.Background()
+
+ // Create and commit a file so HEAD exists; index is now clean.
+ writeFile(t, dir, "secret.age", "data")
+ commitAll(t, dir, "initial commit")
+
+ // Nothing staged — git commit should exit non-zero.
+ err := g.Commit(ctx)
+ if err == nil {
+ t.Fatal("expected error when committing with nothing to commit, got nil")
+ }
+}