From d79aa6e49f70f044a007dca8bd375c3b9d921a64 Mon Sep 17 00:00:00 2001 From: Ben Schellenberger <2601492+bschellenberger2600@users.noreply.github.com> Date: Tue, 16 Jun 2026 00:16:12 -0400 Subject: [PATCH] Delegate git primitives and safety redaction to git-harness. Follow-up to #19: remove duplicated operations helpers and internal/safety in favor of git-harness v0.3.1. Co-authored-by: Cursor --- cmd/root.go | 2 +- internal/git/operations.go | 140 ++++----------------------------- internal/git/safety_test.go | 30 +++++++ internal/safety/redact.go | 32 -------- internal/safety/redact_test.go | 84 -------------------- internal/sessionlog/logger.go | 2 +- 6 files changed, 47 insertions(+), 243 deletions(-) create mode 100644 internal/git/safety_test.go delete mode 100644 internal/safety/redact.go delete mode 100644 internal/safety/redact_test.go diff --git a/cmd/root.go b/cmd/root.go index 2b7c601..377d2f3 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -22,7 +22,7 @@ import ( "github.com/git-rain/git-rain/internal/config" "github.com/git-rain/git-rain/internal/git" "github.com/git-rain/git-rain/internal/registry" - "github.com/git-rain/git-rain/internal/safety" + "github.com/git-fire/git-harness/safety" "github.com/git-rain/git-rain/internal/sessionlog" "github.com/git-rain/git-rain/internal/ui" ) diff --git a/internal/git/operations.go b/internal/git/operations.go index 199b4a7..d9425e3 100644 --- a/internal/git/operations.go +++ b/internal/git/operations.go @@ -2,152 +2,42 @@ package git import ( "fmt" - "os/exec" "strings" - "github.com/git-rain/git-rain/internal/safety" + harnessgit "github.com/git-fire/git-harness/git" + harnessafety "github.com/git-fire/git-harness/safety" ) -// Worktree represents a git worktree -type Worktree struct { - Path string // Absolute path to worktree - Branch string // Current branch in this worktree - Head string // Current HEAD SHA - IsMain bool // True if this is the main worktree -} +// Worktree is a git worktree (delegates to git-harness). +type Worktree = harnessgit.Worktree -// getCommitSHA returns the SHA of a commit ref func getCommitSHA(repoPath, ref string) (string, error) { - cmd := exec.Command("git", "rev-parse", ref) - cmd.Dir = repoPath - - output, err := cmd.Output() - if err != nil { - return "", fmt.Errorf("git rev-parse failed for %s: %w", ref, err) - } - - sha := strings.TrimSpace(string(output)) - return sha, nil + return harnessgit.GetCommitSHA(repoPath, ref) } // GetCommitSHA returns the SHA for a ref in the repository. func GetCommitSHA(repoPath, ref string) (string, error) { - return getCommitSHA(repoPath, ref) + return harnessgit.GetCommitSHA(repoPath, ref) } -// GetCurrentBranch returns the currently checked out branch +// GetCurrentBranch returns the currently checked out branch. func GetCurrentBranch(repoPath string) (string, error) { - cmd := exec.Command("git", "branch", "--show-current") - cmd.Dir = repoPath - - output, err := cmd.Output() - if err != nil { - return "", fmt.Errorf("git branch --show-current failed: %w", err) - } - - branch := strings.TrimSpace(string(output)) - if branch == "" { - return "", fmt.Errorf("not on any branch (detached HEAD?)") - } - - return branch, nil + return harnessgit.GetCurrentBranch(repoPath) } -// HasStagedChanges checks if there are staged changes +// HasStagedChanges checks if there are staged changes. func HasStagedChanges(repoPath string) (bool, error) { - cmd := exec.Command("git", "diff", "--cached", "--quiet") - cmd.Dir = repoPath - - err := cmd.Run() - if err != nil { - if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 { - return true, nil - } - return false, fmt.Errorf("git diff --cached --quiet failed: %w", err) - } - - return false, nil + return harnessgit.HasStagedChanges(repoPath) } -// HasUnstagedChanges checks if there are unstaged changes (including untracked files) +// HasUnstagedChanges checks if there are unstaged changes (including untracked files). func HasUnstagedChanges(repoPath string) (bool, error) { - cmd := exec.Command("git", "diff", "--quiet") - cmd.Dir = repoPath - - err := cmd.Run() - hasModified := false - if err != nil { - if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 { - hasModified = true - } else { - return false, fmt.Errorf("git diff --quiet failed: %w", err) - } - } - - cmd = exec.Command("git", "ls-files", "--others", "--exclude-standard") - cmd.Dir = repoPath - output, err := cmd.Output() - if err != nil { - return false, fmt.Errorf("git ls-files failed: %w", err) - } - - hasUntracked := len(strings.TrimSpace(string(output))) > 0 - - return hasModified || hasUntracked, nil + return harnessgit.HasUnstagedChanges(repoPath) } -// ListWorktrees returns all worktrees for a repository +// ListWorktrees returns all worktrees for a repository. func ListWorktrees(repoPath string) ([]Worktree, error) { - cmd := exec.Command("git", "worktree", "list", "--porcelain") - cmd.Dir = repoPath - - output, err := cmd.Output() - if err != nil { - return nil, fmt.Errorf("git worktree list failed: %w", err) - } - - lines := strings.Split(string(output), "\n") - var worktrees []Worktree - var current Worktree - isFirst := true - - for _, line := range lines { - line = strings.TrimSpace(line) - if line == "" { - if current.Path != "" { - current.IsMain = isFirst - worktrees = append(worktrees, current) - current = Worktree{} - isFirst = false - } - continue - } - - parts := strings.SplitN(line, " ", 2) - if len(parts) < 2 { - continue - } - - key := parts[0] - value := parts[1] - - switch key { - case "worktree": - current.Path = value - case "HEAD": - current.Head = value - case "branch": - branch := strings.TrimPrefix(value, "refs/heads/") - current.Branch = branch - } - } - - if current.Path != "" { - current.IsMain = isFirst - worktrees = append(worktrees, current) - } - - return worktrees, nil + return harnessgit.ListWorktrees(repoPath) } func commandError(action string, err error, output []byte) error { @@ -155,5 +45,5 @@ func commandError(action string, err error, output []byte) error { if out == "" { return fmt.Errorf("%s failed: %w", action, err) } - return fmt.Errorf("%s failed: %w (stderr: %s)", action, err, safety.SanitizeText(out)) + return fmt.Errorf("%s failed: %w (stderr: %s)", action, err, harnessafety.SanitizeText(out)) } diff --git a/internal/git/safety_test.go b/internal/git/safety_test.go new file mode 100644 index 0000000..1cc37e5 --- /dev/null +++ b/internal/git/safety_test.go @@ -0,0 +1,30 @@ +package git_test + +import ( + "strings" + "testing" + + "github.com/git-fire/git-harness/safety" +) + +func TestHarnessSanitizeText_FreezeMessagesPreserved(t *testing.T) { + msgs := []string{ + "could not reach remote — check your network and try again", + "could not authenticate with remote — check your credentials and try again", + "fetch did not complete — try again when the remote is reachable", + } + for _, msg := range msgs { + got := safety.SanitizeText(msg) + if got != msg { + t.Errorf("freeze message was modified:\n input: %q\n got: %q", msg, got) + } + } +} + +func TestHarnessSanitizeText_RedactsCredentials(t *testing.T) { + input := "fatal: https://user:supersecret@github.com/org/repo.git" + got := safety.SanitizeText(input) + if strings.Contains(got, "supersecret") { + t.Errorf("password not redacted: %q", got) + } +} diff --git a/internal/safety/redact.go b/internal/safety/redact.go deleted file mode 100644 index a09c599..0000000 --- a/internal/safety/redact.go +++ /dev/null @@ -1,32 +0,0 @@ -package safety - -import ( - "regexp" - "strings" -) - -var redactPatterns = []*regexp.Regexp{ - regexp.MustCompile(`(?i)(https?://)[^:@/\s]+:[^@/\s]+@`), - regexp.MustCompile(`\bAKIA[0-9A-Z]{16}\b`), - regexp.MustCompile(`\b(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,}\b`), - regexp.MustCompile(`(?i)\b(glpat-[A-Za-z0-9\-_]{20,})\b`), - regexp.MustCompile(`(?i)(aws_secret_access_key|aws_secret|secret_key|secret)\s*[:=]\s*[A-Za-z0-9/+=]{40}\b`), - regexp.MustCompile(`(?i)(token|key|password|secret|passwd|api_key|apikey)\s*[:=]\s*\S+`), -} - -// SanitizeText masks common credential patterns before printing or logging. -func SanitizeText(s string) string { - out := s - for _, re := range redactPatterns { - out = re.ReplaceAllStringFunc(out, func(m string) string { - if strings.HasPrefix(strings.ToLower(m), "http") { - return re.ReplaceAllString(m, "${1}[REDACTED]@") - } - if idx := strings.IndexAny(m, ":="); idx >= 0 { - return m[:idx+1] + "[REDACTED]" - } - return "[REDACTED]" - }) - } - return out -} diff --git a/internal/safety/redact_test.go b/internal/safety/redact_test.go deleted file mode 100644 index aeb9c29..0000000 --- a/internal/safety/redact_test.go +++ /dev/null @@ -1,84 +0,0 @@ -package safety_test - -import ( - "strings" - "testing" - - "github.com/git-rain/git-rain/internal/safety" -) - -func TestSanitizeText_CleanInput(t *testing.T) { - input := "fetch did not complete — try again when the remote is reachable" - got := safety.SanitizeText(input) - if got != input { - t.Errorf("clean input was modified:\n got: %q\n want: %q", got, input) - } -} - -func TestSanitizeText_HTTPSCredentials(t *testing.T) { - input := "fatal: https://user:supersecret@github.com/org/repo.git" - got := safety.SanitizeText(input) - if strings.Contains(got, "supersecret") { - t.Errorf("password not redacted: %q", got) - } - if !strings.Contains(got, "[REDACTED]") { - t.Errorf("expected [REDACTED] marker in output: %q", got) - } -} - -func TestSanitizeText_GitHubPersonalAccessToken(t *testing.T) { - input := "token: ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdef1234" - got := safety.SanitizeText(input) - if strings.Contains(got, "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdef1234") { - t.Errorf("GitHub PAT not redacted: %q", got) - } -} - -func TestSanitizeText_AWSAccessKey(t *testing.T) { - input := "AKIAJSIE27OOBC3BZMAA is your key" - got := safety.SanitizeText(input) - if strings.Contains(got, "AKIAJSIE27OOBC3BZMAA") { - t.Errorf("AWS access key not redacted: %q", got) - } -} - -func TestSanitizeText_GenericPassword(t *testing.T) { - input := "password=hunter2abc" - got := safety.SanitizeText(input) - if strings.Contains(got, "hunter2abc") { - t.Errorf("password value not redacted: %q", got) - } - if !strings.Contains(got, "[REDACTED]") { - t.Errorf("expected [REDACTED] in output: %q", got) - } -} - -func TestSanitizeText_GitLabToken(t *testing.T) { - input := "glpat-abcdefghijklmnopqrstu" - got := safety.SanitizeText(input) - if strings.Contains(got, "abcdefghijklmnopqrstu") { - t.Errorf("GitLab token not redacted: %q", got) - } -} - -func TestSanitizeText_EmptyString(t *testing.T) { - got := safety.SanitizeText("") - if got != "" { - t.Errorf("empty input should return empty, got %q", got) - } -} - -func TestSanitizeText_FreezeMessage_Preserved(t *testing.T) { - // Freeze / network error messages should pass through unchanged - msgs := []string{ - "could not reach remote — check your network and try again", - "could not authenticate with remote — check your credentials and try again", - "fetch did not complete — try again when the remote is reachable", - } - for _, msg := range msgs { - got := safety.SanitizeText(msg) - if got != msg { - t.Errorf("freeze message was modified:\n input: %q\n got: %q", msg, got) - } - } -} diff --git a/internal/sessionlog/logger.go b/internal/sessionlog/logger.go index d016fe6..b3e370d 100644 --- a/internal/sessionlog/logger.go +++ b/internal/sessionlog/logger.go @@ -7,7 +7,7 @@ import ( "path/filepath" "time" - "github.com/git-rain/git-rain/internal/safety" + "github.com/git-fire/git-harness/safety" ) // LogEntry matches git-fire's structured session event shape.