github.com/abdfnx/gh-api@v0.0.0-20210414084727-f5432eec23b8/git/git.go (about) 1 package git 2 3 import ( 4 "bytes" 5 "errors" 6 "fmt" 7 "io" 8 "net/url" 9 "os" 10 "os/exec" 11 "path" 12 "regexp" 13 "runtime" 14 "strings" 15 16 "github.com/abdfnx/gh-api/internal/run" 17 "github.com/cli/safeexec" 18 ) 19 20 // ErrNotOnAnyBranch indicates that the user is in detached HEAD state 21 var ErrNotOnAnyBranch = errors.New("git: not on any branch") 22 23 // Ref represents a git commit reference 24 type Ref struct { 25 Hash string 26 Name string 27 } 28 29 // TrackingRef represents a ref for a remote tracking branch 30 type TrackingRef struct { 31 RemoteName string 32 BranchName string 33 } 34 35 func (r TrackingRef) String() string { 36 return "refs/remotes/" + r.RemoteName + "/" + r.BranchName 37 } 38 39 // ShowRefs resolves fully-qualified refs to commit hashes 40 func ShowRefs(ref ...string) ([]Ref, error) { 41 args := append([]string{"show-ref", "--verify", "--"}, ref...) 42 showRef, err := GitCommand(args...) 43 if err != nil { 44 return nil, err 45 } 46 output, err := run.PrepareCmd(showRef).Output() 47 48 var refs []Ref 49 for _, line := range outputLines(output) { 50 parts := strings.SplitN(line, " ", 2) 51 if len(parts) < 2 { 52 continue 53 } 54 refs = append(refs, Ref{ 55 Hash: parts[0], 56 Name: parts[1], 57 }) 58 } 59 60 return refs, err 61 } 62 63 // CurrentBranch reads the checked-out branch for the git repository 64 func CurrentBranch() (string, error) { 65 refCmd, err := GitCommand("symbolic-ref", "--quiet", "HEAD") 66 if err != nil { 67 return "", err 68 } 69 70 stderr := bytes.Buffer{} 71 refCmd.Stderr = &stderr 72 73 output, err := run.PrepareCmd(refCmd).Output() 74 if err == nil { 75 // Found the branch name 76 return getBranchShortName(output), nil 77 } 78 79 if stderr.Len() == 0 { 80 // Detached head 81 return "", ErrNotOnAnyBranch 82 } 83 84 return "", fmt.Errorf("%sgit: %s", stderr.String(), err) 85 } 86 87 func listRemotes() ([]string, error) { 88 remoteCmd, err := GitCommand("remote", "-v") 89 if err != nil { 90 return nil, err 91 } 92 output, err := run.PrepareCmd(remoteCmd).Output() 93 return outputLines(output), err 94 } 95 96 func Config(name string) (string, error) { 97 configCmd, err := GitCommand("config", name) 98 if err != nil { 99 return "", err 100 } 101 output, err := run.PrepareCmd(configCmd).Output() 102 if err != nil { 103 return "", fmt.Errorf("unknown config key: %s", name) 104 } 105 106 return firstLine(output), nil 107 108 } 109 110 var GitCommand = func(args ...string) (*exec.Cmd, error) { 111 gitExe, err := safeexec.LookPath("git") 112 if err != nil { 113 programName := "git" 114 if runtime.GOOS == "windows" { 115 programName = "Git for Windows" 116 } 117 return nil, fmt.Errorf("unable to find git executable in PATH; please install %s before retrying", programName) 118 } 119 return exec.Command(gitExe, args...), nil 120 } 121 122 func UncommittedChangeCount() (int, error) { 123 statusCmd, err := GitCommand("status", "--porcelain") 124 if err != nil { 125 return 0, err 126 } 127 output, err := run.PrepareCmd(statusCmd).Output() 128 if err != nil { 129 return 0, err 130 } 131 lines := strings.Split(string(output), "\n") 132 133 count := 0 134 135 for _, l := range lines { 136 if l != "" { 137 count++ 138 } 139 } 140 141 return count, nil 142 } 143 144 type Commit struct { 145 Sha string 146 Title string 147 } 148 149 func Commits(baseRef, headRef string) ([]*Commit, error) { 150 logCmd, err := GitCommand( 151 "-c", "log.ShowSignature=false", 152 "log", "--pretty=format:%H,%s", 153 "--cherry", fmt.Sprintf("%s...%s", baseRef, headRef)) 154 if err != nil { 155 return nil, err 156 } 157 output, err := run.PrepareCmd(logCmd).Output() 158 if err != nil { 159 return []*Commit{}, err 160 } 161 162 commits := []*Commit{} 163 sha := 0 164 title := 1 165 for _, line := range outputLines(output) { 166 split := strings.SplitN(line, ",", 2) 167 if len(split) != 2 { 168 continue 169 } 170 commits = append(commits, &Commit{ 171 Sha: split[sha], 172 Title: split[title], 173 }) 174 } 175 176 if len(commits) == 0 { 177 return commits, fmt.Errorf("could not find any commits between %s and %s", baseRef, headRef) 178 } 179 180 return commits, nil 181 } 182 183 func lookupCommit(sha, format string) ([]byte, error) { 184 logCmd, err := GitCommand("-c", "log.ShowSignature=false", "show", "-s", "--pretty=format:"+format, sha) 185 if err != nil { 186 return nil, err 187 } 188 return run.PrepareCmd(logCmd).Output() 189 } 190 191 func LastCommit() (*Commit, error) { 192 output, err := lookupCommit("HEAD", "%H,%s") 193 if err != nil { 194 return nil, err 195 } 196 197 idx := bytes.IndexByte(output, ',') 198 return &Commit{ 199 Sha: string(output[0:idx]), 200 Title: strings.TrimSpace(string(output[idx+1:])), 201 }, nil 202 } 203 204 func CommitBody(sha string) (string, error) { 205 output, err := lookupCommit(sha, "%b") 206 return string(output), err 207 } 208 209 // Push publishes a git ref to a remote and sets up upstream configuration 210 func Push(remote string, ref string, cmdOut, cmdErr io.Writer) error { 211 pushCmd, err := GitCommand("push", "--set-upstream", remote, ref) 212 if err != nil { 213 return err 214 } 215 pushCmd.Stdout = cmdOut 216 pushCmd.Stderr = cmdErr 217 return run.PrepareCmd(pushCmd).Run() 218 } 219 220 type BranchConfig struct { 221 RemoteName string 222 RemoteURL *url.URL 223 MergeRef string 224 } 225 226 // ReadBranchConfig parses the `branch.BRANCH.(remote|merge)` part of git config 227 func ReadBranchConfig(branch string) (cfg BranchConfig) { 228 prefix := regexp.QuoteMeta(fmt.Sprintf("branch.%s.", branch)) 229 configCmd, err := GitCommand("config", "--get-regexp", fmt.Sprintf("^%s(remote|merge)$", prefix)) 230 if err != nil { 231 return 232 } 233 output, err := run.PrepareCmd(configCmd).Output() 234 if err != nil { 235 return 236 } 237 for _, line := range outputLines(output) { 238 parts := strings.SplitN(line, " ", 2) 239 if len(parts) < 2 { 240 continue 241 } 242 keys := strings.Split(parts[0], ".") 243 switch keys[len(keys)-1] { 244 case "remote": 245 if strings.Contains(parts[1], ":") { 246 u, err := ParseURL(parts[1]) 247 if err != nil { 248 continue 249 } 250 cfg.RemoteURL = u 251 } else if !isFilesystemPath(parts[1]) { 252 cfg.RemoteName = parts[1] 253 } 254 case "merge": 255 cfg.MergeRef = parts[1] 256 } 257 } 258 return 259 } 260 261 func DeleteLocalBranch(branch string) error { 262 branchCmd, err := GitCommand("branch", "-D", branch) 263 if err != nil { 264 return err 265 } 266 return run.PrepareCmd(branchCmd).Run() 267 } 268 269 func HasLocalBranch(branch string) bool { 270 configCmd, err := GitCommand("rev-parse", "--verify", "refs/heads/"+branch) 271 if err != nil { 272 return false 273 } 274 _, err = run.PrepareCmd(configCmd).Output() 275 return err == nil 276 } 277 278 func CheckoutBranch(branch string) error { 279 configCmd, err := GitCommand("checkout", branch) 280 if err != nil { 281 return err 282 } 283 return run.PrepareCmd(configCmd).Run() 284 } 285 286 func parseCloneArgs(extraArgs []string) (args []string, target string) { 287 args = extraArgs 288 289 if len(args) > 0 { 290 if !strings.HasPrefix(args[0], "-") { 291 target, args = args[0], args[1:] 292 } 293 } 294 return 295 } 296 297 func RunClone(cloneURL string, args []string) (target string, err error) { 298 cloneArgs, target := parseCloneArgs(args) 299 300 cloneArgs = append(cloneArgs, cloneURL) 301 302 // If the args contain an explicit target, pass it to clone 303 // otherwise, parse the URL to determine where git cloned it to so we can return it 304 if target != "" { 305 cloneArgs = append(cloneArgs, target) 306 } else { 307 target = path.Base(strings.TrimSuffix(cloneURL, ".git")) 308 } 309 310 cloneArgs = append([]string{"clone"}, cloneArgs...) 311 312 cloneCmd, err := GitCommand(cloneArgs...) 313 if err != nil { 314 return "", err 315 } 316 cloneCmd.Stdin = os.Stdin 317 cloneCmd.Stdout = os.Stdout 318 cloneCmd.Stderr = os.Stderr 319 320 err = run.PrepareCmd(cloneCmd).Run() 321 return 322 } 323 324 func AddUpstreamRemote(upstreamURL, cloneDir string, branches []string) error { 325 args := []string{"-C", cloneDir, "remote", "add"} 326 for _, branch := range branches { 327 args = append(args, "-t", branch) 328 } 329 args = append(args, "-f", "upstream", upstreamURL) 330 cloneCmd, err := GitCommand(args...) 331 if err != nil { 332 return err 333 } 334 cloneCmd.Stdout = os.Stdout 335 cloneCmd.Stderr = os.Stderr 336 return run.PrepareCmd(cloneCmd).Run() 337 } 338 339 func isFilesystemPath(p string) bool { 340 return p == "." || strings.HasPrefix(p, "./") || strings.HasPrefix(p, "/") 341 } 342 343 // ToplevelDir returns the top-level directory path of the current repository 344 func ToplevelDir() (string, error) { 345 showCmd, err := GitCommand("rev-parse", "--show-toplevel") 346 if err != nil { 347 return "", err 348 } 349 output, err := run.PrepareCmd(showCmd).Output() 350 return firstLine(output), err 351 352 } 353 354 func outputLines(output []byte) []string { 355 lines := strings.TrimSuffix(string(output), "\n") 356 return strings.Split(lines, "\n") 357 358 } 359 360 func firstLine(output []byte) string { 361 if i := bytes.IndexAny(output, "\n"); i >= 0 { 362 return string(output)[0:i] 363 } 364 return string(output) 365 } 366 367 func getBranchShortName(output []byte) string { 368 branch := firstLine(output) 369 return strings.TrimPrefix(branch, "refs/heads/") 370 }