From 66d824193dc63eeea5f59bc86238f5f6106ae02b Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Sun, 22 Feb 2026 09:39:01 +0200 Subject: Implement git package (task 355) --- internal/git/git.go | 91 +++++++++++++++++++++ internal/git/git_test.go | 200 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 291 insertions(+) create mode 100644 internal/git/git_test.go 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") + } +} -- cgit v1.2.3