22package git
33
44import (
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.
1318var 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.
1635type 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.
3594func (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.
41100func (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.
46105func (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.
51110func (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).
56115func (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.
65124func (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.
75134func (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.
80139func (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.
92148func (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.
101153func (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.
123171func (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.
131176func (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.
139181func (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.
158200func (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.
176215func (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