From 7e366ff980799944092890f580adfc4bdf9ecb0c Mon Sep 17 00:00:00 2001 From: Pasha Sviderski Date: Fri, 31 Oct 2025 15:57:43 +1000 Subject: [PATCH] chore: add gitutils package to inspect local git repo --- internal/gitutil/state.go | 91 ++++++++++++++++++++ internal/gitutil/state_test.go | 152 +++++++++++++++++++++++++++++++++ 2 files changed, 243 insertions(+) create mode 100644 internal/gitutil/state.go create mode 100644 internal/gitutil/state_test.go diff --git a/internal/gitutil/state.go b/internal/gitutil/state.go new file mode 100644 index 00000000..b64dcf32 --- /dev/null +++ b/internal/gitutil/state.go @@ -0,0 +1,91 @@ +package gitutil + +import ( + "fmt" + "os/exec" + "strconv" + "strings" + "time" +) + +// GitState contains raw information about the Git repository state. +type GitState struct { + // IsDirty indicates whether there are uncommitted changes. + IsDirty bool + // IsRepo indicates whether the directory is a Git repository. + IsRepo bool + // SHA is the full SHA-1 (40 characters) of the current commit. + SHA string + // Time is the current commit datetime. + Time time.Time +} + +// InspectGitState inspects the Git repo state from the specified directory. +// If the directory is not a Git repository, returns GitState with IsRepo=false. +func InspectGitState(dir string) (GitState, error) { + state := GitState{ + IsRepo: isGitRepo(dir), + } + + if !state.IsRepo { + return state, nil + } + + // Get the current commit SHA. + sha, err := gitCommand(dir, "rev-parse", "HEAD") + if err != nil { + return state, fmt.Errorf("get current commit SHA: %w", err) + } + state.SHA = strings.TrimSpace(sha) + + // Get the current commit timestamp. + timestamp, err := gitCommand(dir, "log", "-1", "--format=%ct") + if err != nil { + return state, fmt.Errorf("get current commit timestamp: %w", err) + } + seconds, err := strconv.ParseInt(strings.TrimSpace(timestamp), 10, 64) + if err != nil { + return state, fmt.Errorf("parse current commit timestamp: %w", err) + } + state.Time = time.Unix(seconds, 0) + + // Check for uncommitted changes. + status, err := gitCommand(dir, "status", "--porcelain") + if err != nil { + return state, fmt.Errorf("check git status: %w", err) + } + state.IsDirty = strings.TrimSpace(status) != "" + + return state, nil +} + +// isGitRepo checks if the directory is a Git repository. +func isGitRepo(dir string) bool { + _, err := gitCommand(dir, "rev-parse", "--git-dir") + return err == nil +} + +// gitCommand runs a git command in the specified directory. +func gitCommand(dir string, args ...string) (string, error) { + cmd := exec.Command("git", args...) + cmd.Dir = dir + output, err := cmd.Output() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + return "", fmt.Errorf("git command failed: %s", exitErr.Stderr) + } + return "", err + } + + return string(output), nil +} + +// ShortSHA returns a truncated SHA with the specified length. +// If length is negative, zero, or greater than the SHA length, it returns the full SHA. +// Returns empty string if SHA is empty. +func (s *GitState) ShortSHA(length int) string { + if length <= 0 || length > len(s.SHA) { + length = len(s.SHA) + } + return s.SHA[:length] +} diff --git a/internal/gitutil/state_test.go b/internal/gitutil/state_test.go new file mode 100644 index 00000000..b5e7c767 --- /dev/null +++ b/internal/gitutil/state_test.go @@ -0,0 +1,152 @@ +package gitutil + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInspectGitState_NotARepo(t *testing.T) { + // Create a temporary directory that's not a git repo. + tmpDir := t.TempDir() + + state, err := InspectGitState(tmpDir) + require.NoError(t, err) + + // If tmpDir happens to be in a git repo (e.g., parent directory), skip this test. + // This is common when running tests from within the project itself. + if state.IsRepo { + t.Skip("tmpDir is within a git repository, skipping test") + } + + assert.False(t, state.IsRepo) + assert.Empty(t, state.SHA) + assert.Empty(t, state.ShortSHA(7)) + assert.True(t, state.Time.IsZero()) // Should have zero time if not a repo. + assert.False(t, state.IsDirty) +} + +func TestInspectGitState_CleanRepo(t *testing.T) { + // Create a temporary git repo. + tmpDir := t.TempDir() + initGitRepo(t, tmpDir) + + createAndCommitFile(t, tmpDir, "test.txt", "test content") + + state, err := InspectGitState(tmpDir) + require.NoError(t, err) + + assert.True(t, state.IsRepo) + assert.Len(t, state.SHA, 40) + assert.Equal(t, state.ShortSHA(7), state.SHA[:7]) + assert.False(t, state.Time.IsZero()) + assert.False(t, state.IsDirty) +} + +func TestInspectGitState_DirtyRepo(t *testing.T) { + // Create a temporary git repo. + tmpDir := t.TempDir() + initGitRepo(t, tmpDir) + + createAndCommitFile(t, tmpDir, "test.txt", "test content") + + // Modify the file to make the repo dirty. + err := os.WriteFile(filepath.Join(tmpDir, "test.txt"), []byte("modified content"), 0o644) + require.NoError(t, err) + + state, err := InspectGitState(tmpDir) + require.NoError(t, err) + + assert.True(t, state.IsRepo) + assert.True(t, state.IsDirty) +} + +func TestInspectGitState_UntrackedFiles(t *testing.T) { + // Create a temporary git repo. + tmpDir := t.TempDir() + initGitRepo(t, tmpDir) + + createAndCommitFile(t, tmpDir, "test.txt", "test content") + + // Add an untracked file. + err := os.WriteFile(filepath.Join(tmpDir, "untracked.txt"), []byte("untracked"), 0o644) + require.NoError(t, err) + + state, err := InspectGitState(tmpDir) + require.NoError(t, err) + + assert.True(t, state.IsRepo) + assert.True(t, state.IsDirty) // Untracked files make the repo dirty. +} + +func TestGitState_ShortSHA(t *testing.T) { + state := &GitState{ + SHA: "1234567890abcdef1234567890abcdef12345678", + } + + tests := []struct { + name string + length int + expected string + }{ + {"short 7", 7, "1234567"}, + {"short 10", 10, "1234567890"}, + {"full SHA", 40, "1234567890abcdef1234567890abcdef12345678"}, + {"longer than SHA", 50, "1234567890abcdef1234567890abcdef12345678"}, + {"negative", -42, "1234567890abcdef1234567890abcdef12345678"}, + {"zero", 0, "1234567890abcdef1234567890abcdef12345678"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := state.ShortSHA(tt.length) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestGitState_ShortSHA_Empty(t *testing.T) { + state := &GitState{ + SHA: "", + } + + result := state.ShortSHA(7) + assert.Empty(t, result) +} + +// Helper functions. + +func initGitRepo(t *testing.T, dir string) { + t.Helper() + + // Initialize git repo. + runGitCommand(t, dir, "init") + runGitCommand(t, dir, "config", "user.email", "test@example.com") + runGitCommand(t, dir, "config", "user.name", "Test User") +} + +func createAndCommitFile(t *testing.T, dir, filename, content string) { + t.Helper() + + // Create file. + path := filepath.Join(dir, filename) + err := os.WriteFile(path, []byte(content), 0o644) + require.NoError(t, err) + + // Commit file. + runGitCommand(t, dir, "add", filename) + runGitCommand(t, dir, "commit", "-m", "Add "+filename) +} + +func runGitCommand(t *testing.T, dir string, args ...string) { + t.Helper() + + cmd := exec.Command("git", args...) + cmd.Dir = dir + output, err := cmd.CombinedOutput() + require.NoError(t, err, "git command failed: %s", output) +}