Skip to content

Commit 3f38e87

Browse files
committed
refactor(git): use safeexec for secure git path lookup
Mimic the go-gh pattern for invoking git: - Use safeexec.LookPath to prevent PATH injection attacks - Cache the git path with sync.Once for efficiency - Add helper methods (run, runInteractive, runSilent) to reduce duplication - Capture stderr for better error messages
1 parent a4987c1 commit 3f38e87

1 file changed

Lines changed: 82 additions & 43 deletions

File tree

internal/git/git.go

Lines changed: 82 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,35 @@
22
package git
33

44
import (
5+
"bytes"
56
"errors"
7+
"fmt"
68
"os"
79
"os/exec"
810
"path/filepath"
911
"strings"
12+
"sync"
13+
14+
"github.com/cli/safeexec"
1015
)
1116

1217
// ErrDirtyWorkTree is returned when the working tree has uncommitted changes.
1318
var ErrDirtyWorkTree = errors.New("working tree has uncommitted changes")
1419

20+
var (
21+
gitPath string
22+
gitPathOnce sync.Once
23+
gitPathErr error
24+
)
25+
26+
// resolveGitPath finds the git executable using safeexec to prevent PATH injection.
27+
func resolveGitPath() (string, error) {
28+
gitPathOnce.Do(func() {
29+
gitPath, gitPathErr = safeexec.LookPath("git")
30+
})
31+
return gitPath, gitPathErr
32+
}
33+
1534
// Git provides git operations for a repository.
1635
type Git struct {
1736
repoPath string
@@ -22,48 +41,88 @@ func New(repoPath string) *Git {
2241
return &Git{repoPath: repoPath}
2342
}
2443

25-
// CurrentBranch returns the name of the current branch.
26-
func (g *Git) CurrentBranch() (string, error) {
27-
out, err := exec.Command("git", "-C", g.repoPath, "rev-parse", "--abbrev-ref", "HEAD").Output()
44+
// run executes a git command and returns stdout. Stderr is captured for error messages.
45+
func (g *Git) run(args ...string) (string, error) {
46+
gitBin, err := resolveGitPath()
2847
if err != nil {
29-
return "", err
48+
return "", fmt.Errorf("failed to find git: %w", err)
49+
}
50+
51+
fullArgs := append([]string{"-C", g.repoPath}, args...)
52+
cmd := exec.Command(gitBin, fullArgs...)
53+
54+
var stdout, stderr bytes.Buffer
55+
cmd.Stdout = &stdout
56+
cmd.Stderr = &stderr
57+
58+
if err := cmd.Run(); err != nil {
59+
if stderr.Len() > 0 {
60+
return "", fmt.Errorf("git %s: %s", args[0], strings.TrimSpace(stderr.String()))
61+
}
62+
return "", fmt.Errorf("git %s: %w", args[0], err)
3063
}
31-
return strings.TrimSpace(string(out)), nil
64+
65+
return strings.TrimSpace(stdout.String()), nil
66+
}
67+
68+
// runInteractive executes a git command with stdout/stderr connected to the terminal.
69+
func (g *Git) runInteractive(args ...string) error {
70+
gitBin, err := resolveGitPath()
71+
if err != nil {
72+
return fmt.Errorf("failed to find git: %w", err)
73+
}
74+
75+
fullArgs := append([]string{"-C", g.repoPath}, args...)
76+
cmd := exec.Command(gitBin, fullArgs...)
77+
cmd.Stdout = os.Stdout
78+
cmd.Stderr = os.Stderr
79+
return cmd.Run()
80+
}
81+
82+
// runSilent executes a git command and discards output, returning only success/failure.
83+
func (g *Git) runSilent(args ...string) error {
84+
_, err := g.run(args...)
85+
return err
86+
}
87+
88+
// CurrentBranch returns the name of the current branch.
89+
func (g *Git) CurrentBranch() (string, error) {
90+
return g.run("rev-parse", "--abbrev-ref", "HEAD")
3291
}
3392

3493
// BranchExists checks if a branch exists.
3594
func (g *Git) BranchExists(branch string) bool {
36-
err := exec.Command("git", "-C", g.repoPath, "rev-parse", "--verify", "refs/heads/"+branch).Run()
95+
err := g.runSilent("rev-parse", "--verify", "refs/heads/"+branch)
3796
return err == nil
3897
}
3998

4099
// CreateBranch creates a new branch at the current HEAD.
41100
func (g *Git) CreateBranch(name string) error {
42-
return exec.Command("git", "-C", g.repoPath, "branch", name).Run()
101+
return g.runSilent("branch", name)
43102
}
44103

45104
// Checkout switches to the specified branch.
46105
func (g *Git) Checkout(branch string) error {
47-
return exec.Command("git", "-C", g.repoPath, "checkout", branch).Run()
106+
return g.runSilent("checkout", branch)
48107
}
49108

50109
// CreateAndCheckout creates a new branch and switches to it.
51110
func (g *Git) CreateAndCheckout(name string) error {
52-
return exec.Command("git", "-C", g.repoPath, "checkout", "-b", name).Run()
111+
return g.runSilent("checkout", "-b", name)
53112
}
54113

55114
// IsDirty returns true if there are uncommitted changes (staged or unstaged).
56115
func (g *Git) IsDirty() (bool, error) {
57-
out, err := exec.Command("git", "-C", g.repoPath, "status", "--porcelain").Output()
116+
out, err := g.run("status", "--porcelain")
58117
if err != nil {
59118
return false, err
60119
}
61-
return len(strings.TrimSpace(string(out))) > 0, nil
120+
return len(out) > 0, nil
62121
}
63122

64123
// HasStagedChanges returns true if there are staged changes.
65124
func (g *Git) HasStagedChanges() (bool, error) {
66-
err := exec.Command("git", "-C", g.repoPath, "diff", "--cached", "--quiet").Run()
125+
err := g.runSilent("diff", "--cached", "--quiet")
67126
if err != nil {
68127
// Exit code 1 means there are differences
69128
return true, nil
@@ -73,37 +132,26 @@ func (g *Git) HasStagedChanges() (bool, error) {
73132

74133
// Commit creates a commit with the given message.
75134
func (g *Git) Commit(message string) error {
76-
return exec.Command("git", "-C", g.repoPath, "commit", "-m", message).Run()
135+
return g.runSilent("commit", "-m", message)
77136
}
78137

79138
// Push force-pushes a branch to origin with lease.
80139
func (g *Git) Push(branch string, force bool) error {
81-
args := []string{"-C", g.repoPath, "push", "origin", branch}
140+
args := []string{"push", "origin", branch}
82141
if force {
83142
args = append(args, "--force-with-lease")
84143
}
85-
cmd := exec.Command("git", args...)
86-
cmd.Stdout = os.Stdout
87-
cmd.Stderr = os.Stderr
88-
return cmd.Run()
144+
return g.runInteractive(args...)
89145
}
90146

91147
// GetMergeBase returns the merge base of two branches.
92148
func (g *Git) GetMergeBase(a, b string) (string, error) {
93-
out, err := exec.Command("git", "-C", g.repoPath, "merge-base", a, b).Output()
94-
if err != nil {
95-
return "", err
96-
}
97-
return strings.TrimSpace(string(out)), nil
149+
return g.run("merge-base", a, b)
98150
}
99151

100152
// GetTip returns the commit SHA at the tip of a branch.
101153
func (g *Git) GetTip(branch string) (string, error) {
102-
out, err := exec.Command("git", "-C", g.repoPath, "rev-parse", branch).Output()
103-
if err != nil {
104-
return "", err
105-
}
106-
return strings.TrimSpace(string(out)), nil
154+
return g.run("rev-parse", branch)
107155
}
108156

109157
// NeedsRebase returns true if branch needs to be rebased onto parent.
@@ -121,23 +169,17 @@ func (g *Git) NeedsRebase(branch, parent string) (bool, error) {
121169

122170
// Rebase rebases the current branch onto target.
123171
func (g *Git) Rebase(onto string) error {
124-
cmd := exec.Command("git", "-C", g.repoPath, "rebase", onto)
125-
cmd.Stdout = os.Stdout
126-
cmd.Stderr = os.Stderr
127-
return cmd.Run()
172+
return g.runInteractive("rebase", onto)
128173
}
129174

130175
// RebaseContinue continues an in-progress rebase.
131176
func (g *Git) RebaseContinue() error {
132-
cmd := exec.Command("git", "-C", g.repoPath, "rebase", "--continue")
133-
cmd.Stdout = os.Stdout
134-
cmd.Stderr = os.Stderr
135-
return cmd.Run()
177+
return g.runInteractive("rebase", "--continue")
136178
}
137179

138180
// RebaseAbort aborts an in-progress rebase.
139181
func (g *Git) RebaseAbort() error {
140-
return exec.Command("git", "-C", g.repoPath, "rebase", "--abort").Run()
182+
return g.runSilent("rebase", "--abort")
141183
}
142184

143185
// IsRebaseInProgress checks if a rebase is in progress.
@@ -156,10 +198,7 @@ func (g *Git) GetGitDir() string {
156198

157199
// Fetch fetches from origin.
158200
func (g *Git) Fetch() error {
159-
cmd := exec.Command("git", "-C", g.repoPath, "fetch", "origin")
160-
cmd.Stdout = os.Stdout
161-
cmd.Stderr = os.Stderr
162-
return cmd.Run()
201+
return g.runInteractive("fetch", "origin")
163202
}
164203

165204
// FastForward fast-forwards a branch to its remote tracking branch.
@@ -169,10 +208,10 @@ func (g *Git) FastForward(branch string) error {
169208
return err
170209
}
171210
// Then merge with fast-forward only
172-
return exec.Command("git", "-C", g.repoPath, "merge", "--ff-only", "origin/"+branch).Run()
211+
return g.runSilent("merge", "--ff-only", "origin/"+branch)
173212
}
174213

175214
// DeleteBranch deletes a local branch.
176215
func (g *Git) DeleteBranch(branch string) error {
177-
return exec.Command("git", "-C", g.repoPath, "branch", "-D", branch).Run()
216+
return g.runSilent("branch", "-D", branch)
178217
}

0 commit comments

Comments
 (0)