github.com/ungtb10d/cli/v2@v2.0.0-20221110210412-98537dd9d6a1/git/client.go (about) 1 package git 2 3 import ( 4 "bytes" 5 "context" 6 "errors" 7 "fmt" 8 "io" 9 "net/url" 10 "os/exec" 11 "path" 12 "regexp" 13 "runtime" 14 "sort" 15 "strings" 16 "sync" 17 18 "github.com/cli/safeexec" 19 ) 20 21 var remoteRE = regexp.MustCompile(`(.+)\s+(.+)\s+\((push|fetch)\)`) 22 23 type Client struct { 24 GhPath string 25 RepoDir string 26 GitPath string 27 Stderr io.Writer 28 Stdin io.Reader 29 Stdout io.Writer 30 31 commandContext commandCtx 32 mu sync.Mutex 33 } 34 35 func (c *Client) Command(ctx context.Context, args ...string) (*gitCommand, error) { 36 if c.RepoDir != "" { 37 args = append([]string{"-C", c.RepoDir}, args...) 38 } 39 commandContext := exec.CommandContext 40 if c.commandContext != nil { 41 commandContext = c.commandContext 42 } 43 var err error 44 c.mu.Lock() 45 if c.GitPath == "" { 46 c.GitPath, err = resolveGitPath() 47 } 48 c.mu.Unlock() 49 if err != nil { 50 return nil, err 51 } 52 cmd := commandContext(ctx, c.GitPath, args...) 53 cmd.Stderr = c.Stderr 54 cmd.Stdin = c.Stdin 55 cmd.Stdout = c.Stdout 56 return &gitCommand{cmd}, nil 57 } 58 59 // AuthenticatedCommand is a wrapper around Command that included configuration to use gh 60 // as the credential helper for git. 61 func (c *Client) AuthenticatedCommand(ctx context.Context, args ...string) (*gitCommand, error) { 62 preArgs := []string{"-c", "credential.helper="} 63 if c.GhPath == "" { 64 // Assumes that gh is in PATH. 65 c.GhPath = "gh" 66 } 67 credHelper := fmt.Sprintf("!%q auth git-credential", c.GhPath) 68 preArgs = append(preArgs, "-c", fmt.Sprintf("credential.helper=%s", credHelper)) 69 args = append(preArgs, args...) 70 return c.Command(ctx, args...) 71 } 72 73 func (c *Client) Remotes(ctx context.Context) (RemoteSet, error) { 74 remoteArgs := []string{"remote", "-v"} 75 remoteCmd, err := c.Command(ctx, remoteArgs...) 76 if err != nil { 77 return nil, err 78 } 79 remoteOut, remoteErr := remoteCmd.Output() 80 if remoteErr != nil { 81 return nil, remoteErr 82 } 83 84 configArgs := []string{"config", "--get-regexp", `^remote\..*\.gh-resolved$`} 85 configCmd, err := c.Command(ctx, configArgs...) 86 if err != nil { 87 return nil, err 88 } 89 configOut, configErr := configCmd.Output() 90 if configErr != nil { 91 // Ignore exit code 1 as it means there are no resolved remotes. 92 var gitErr *GitError 93 if ok := errors.As(configErr, &gitErr); ok && gitErr.ExitCode != 1 { 94 return nil, gitErr 95 } 96 } 97 98 remotes := parseRemotes(outputLines(remoteOut)) 99 populateResolvedRemotes(remotes, outputLines(configOut)) 100 sort.Sort(remotes) 101 return remotes, nil 102 } 103 104 func (c *Client) UpdateRemoteURL(ctx context.Context, name, url string) error { 105 args := []string{"remote", "set-url", name, url} 106 cmd, err := c.Command(ctx, args...) 107 if err != nil { 108 return err 109 } 110 _, err = cmd.Output() 111 if err != nil { 112 return err 113 } 114 return nil 115 } 116 117 func (c *Client) SetRemoteResolution(ctx context.Context, name, resolution string) error { 118 args := []string{"config", "--add", fmt.Sprintf("remote.%s.gh-resolved", name), resolution} 119 cmd, err := c.Command(ctx, args...) 120 if err != nil { 121 return err 122 } 123 _, err = cmd.Output() 124 if err != nil { 125 return err 126 } 127 return nil 128 } 129 130 // CurrentBranch reads the checked-out branch for the git repository. 131 func (c *Client) CurrentBranch(ctx context.Context) (string, error) { 132 args := []string{"symbolic-ref", "--quiet", "HEAD"} 133 cmd, err := c.Command(ctx, args...) 134 if err != nil { 135 return "", err 136 } 137 out, err := cmd.Output() 138 if err != nil { 139 var gitErr *GitError 140 if ok := errors.As(err, &gitErr); ok && len(gitErr.Stderr) == 0 { 141 gitErr.Stderr = "not on any branch" 142 return "", gitErr 143 } 144 return "", err 145 } 146 branch := firstLine(out) 147 return strings.TrimPrefix(branch, "refs/heads/"), nil 148 } 149 150 // ShowRefs resolves fully-qualified refs to commit hashes. 151 func (c *Client) ShowRefs(ctx context.Context, refs []string) ([]Ref, error) { 152 args := append([]string{"show-ref", "--verify", "--"}, refs...) 153 cmd, err := c.Command(ctx, args...) 154 if err != nil { 155 return nil, err 156 } 157 // This functionality relies on parsing output from the git command despite 158 // an error status being returned from git. 159 out, err := cmd.Output() 160 var verified []Ref 161 for _, line := range outputLines(out) { 162 parts := strings.SplitN(line, " ", 2) 163 if len(parts) < 2 { 164 continue 165 } 166 verified = append(verified, Ref{ 167 Hash: parts[0], 168 Name: parts[1], 169 }) 170 } 171 return verified, err 172 } 173 174 func (c *Client) Config(ctx context.Context, name string) (string, error) { 175 args := []string{"config", name} 176 cmd, err := c.Command(ctx, args...) 177 if err != nil { 178 return "", err 179 } 180 out, err := cmd.Output() 181 if err != nil { 182 var gitErr *GitError 183 if ok := errors.As(err, &gitErr); ok && gitErr.ExitCode == 1 { 184 gitErr.Stderr = fmt.Sprintf("unknown config key %s", name) 185 return "", gitErr 186 } 187 return "", err 188 } 189 return firstLine(out), nil 190 } 191 192 func (c *Client) UncommittedChangeCount(ctx context.Context) (int, error) { 193 args := []string{"status", "--porcelain"} 194 cmd, err := c.Command(ctx, args...) 195 if err != nil { 196 return 0, err 197 } 198 out, err := cmd.Output() 199 if err != nil { 200 return 0, err 201 } 202 lines := strings.Split(string(out), "\n") 203 count := 0 204 for _, l := range lines { 205 if l != "" { 206 count++ 207 } 208 } 209 return count, nil 210 } 211 212 func (c *Client) Commits(ctx context.Context, baseRef, headRef string) ([]*Commit, error) { 213 args := []string{"-c", "log.ShowSignature=false", "log", "--pretty=format:%H,%s", "--cherry", fmt.Sprintf("%s...%s", baseRef, headRef)} 214 cmd, err := c.Command(ctx, args...) 215 if err != nil { 216 return nil, err 217 } 218 out, err := cmd.Output() 219 if err != nil { 220 return nil, err 221 } 222 commits := []*Commit{} 223 sha := 0 224 title := 1 225 for _, line := range outputLines(out) { 226 split := strings.SplitN(line, ",", 2) 227 if len(split) != 2 { 228 continue 229 } 230 commits = append(commits, &Commit{ 231 Sha: split[sha], 232 Title: split[title], 233 }) 234 } 235 if len(commits) == 0 { 236 return nil, fmt.Errorf("could not find any commits between %s and %s", baseRef, headRef) 237 } 238 return commits, nil 239 } 240 241 func (c *Client) LastCommit(ctx context.Context) (*Commit, error) { 242 output, err := c.lookupCommit(ctx, "HEAD", "%H,%s") 243 if err != nil { 244 return nil, err 245 } 246 idx := bytes.IndexByte(output, ',') 247 return &Commit{ 248 Sha: string(output[0:idx]), 249 Title: strings.TrimSpace(string(output[idx+1:])), 250 }, nil 251 } 252 253 func (c *Client) CommitBody(ctx context.Context, sha string) (string, error) { 254 output, err := c.lookupCommit(ctx, sha, "%b") 255 return string(output), err 256 } 257 258 func (c *Client) lookupCommit(ctx context.Context, sha, format string) ([]byte, error) { 259 args := []string{"-c", "log.ShowSignature=false", "show", "-s", "--pretty=format:" + format, sha} 260 cmd, err := c.Command(ctx, args...) 261 if err != nil { 262 return nil, err 263 } 264 out, err := cmd.Output() 265 if err != nil { 266 return nil, err 267 } 268 return out, nil 269 } 270 271 // ReadBranchConfig parses the `branch.BRANCH.(remote|merge)` part of git config. 272 func (c *Client) ReadBranchConfig(ctx context.Context, branch string) (cfg BranchConfig) { 273 prefix := regexp.QuoteMeta(fmt.Sprintf("branch.%s.", branch)) 274 args := []string{"config", "--get-regexp", fmt.Sprintf("^%s(remote|merge)$", prefix)} 275 cmd, err := c.Command(ctx, args...) 276 if err != nil { 277 return 278 } 279 out, err := cmd.Output() 280 if err != nil { 281 return 282 } 283 for _, line := range outputLines(out) { 284 parts := strings.SplitN(line, " ", 2) 285 if len(parts) < 2 { 286 continue 287 } 288 keys := strings.Split(parts[0], ".") 289 switch keys[len(keys)-1] { 290 case "remote": 291 if strings.Contains(parts[1], ":") { 292 u, err := ParseURL(parts[1]) 293 if err != nil { 294 continue 295 } 296 cfg.RemoteURL = u 297 } else if !isFilesystemPath(parts[1]) { 298 cfg.RemoteName = parts[1] 299 } 300 case "merge": 301 cfg.MergeRef = parts[1] 302 } 303 } 304 return 305 } 306 307 func (c *Client) DeleteLocalBranch(ctx context.Context, branch string) error { 308 args := []string{"branch", "-D", branch} 309 cmd, err := c.Command(ctx, args...) 310 if err != nil { 311 return err 312 } 313 _, err = cmd.Output() 314 if err != nil { 315 return err 316 } 317 return nil 318 } 319 320 func (c *Client) HasLocalBranch(ctx context.Context, branch string) bool { 321 args := []string{"rev-parse", "--verify", "refs/heads/" + branch} 322 cmd, err := c.Command(ctx, args...) 323 if err != nil { 324 return false 325 } 326 _, err = cmd.Output() 327 return err == nil 328 } 329 330 func (c *Client) CheckoutBranch(ctx context.Context, branch string) error { 331 args := []string{"checkout", branch} 332 cmd, err := c.Command(ctx, args...) 333 if err != nil { 334 return err 335 } 336 _, err = cmd.Output() 337 if err != nil { 338 return err 339 } 340 return nil 341 } 342 343 func (c *Client) CheckoutNewBranch(ctx context.Context, remoteName, branch string) error { 344 track := fmt.Sprintf("%s/%s", remoteName, branch) 345 args := []string{"checkout", "-b", branch, "--track", track} 346 cmd, err := c.Command(ctx, args...) 347 if err != nil { 348 return err 349 } 350 _, err = cmd.Output() 351 if err != nil { 352 return err 353 } 354 return nil 355 } 356 357 // ToplevelDir returns the top-level directory path of the current repository. 358 func (c *Client) ToplevelDir(ctx context.Context) (string, error) { 359 args := []string{"rev-parse", "--show-toplevel"} 360 cmd, err := c.Command(ctx, args...) 361 if err != nil { 362 return "", err 363 } 364 out, err := cmd.Output() 365 if err != nil { 366 return "", err 367 } 368 return firstLine(out), nil 369 } 370 371 func (c *Client) GitDir(ctx context.Context) (string, error) { 372 args := []string{"rev-parse", "--git-dir"} 373 cmd, err := c.Command(ctx, args...) 374 if err != nil { 375 return "", err 376 } 377 out, err := cmd.Output() 378 if err != nil { 379 return "", err 380 } 381 return firstLine(out), nil 382 } 383 384 // Show current directory relative to the top-level directory of repository. 385 func (c *Client) PathFromRoot(ctx context.Context) string { 386 args := []string{"rev-parse", "--show-prefix"} 387 cmd, err := c.Command(ctx, args...) 388 if err != nil { 389 return "" 390 } 391 out, err := cmd.Output() 392 if err != nil { 393 return "" 394 } 395 if path := firstLine(out); path != "" { 396 return path[:len(path)-1] 397 } 398 return "" 399 } 400 401 // Below are commands that make network calls and need authentication credentials supplied from gh. 402 403 func (c *Client) Fetch(ctx context.Context, remote string, refspec string, mods ...CommandModifier) error { 404 args := []string{"fetch", remote, refspec} 405 // TODO: Use AuthenticatedCommand 406 cmd, err := c.Command(ctx, args...) 407 if err != nil { 408 return err 409 } 410 for _, mod := range mods { 411 mod(cmd) 412 } 413 return cmd.Run() 414 } 415 416 func (c *Client) Pull(ctx context.Context, remote, branch string, mods ...CommandModifier) error { 417 args := []string{"pull", "--ff-only", remote, branch} 418 // TODO: Use AuthenticatedCommand 419 cmd, err := c.Command(ctx, args...) 420 if err != nil { 421 return err 422 } 423 for _, mod := range mods { 424 mod(cmd) 425 } 426 return cmd.Run() 427 } 428 429 func (c *Client) Push(ctx context.Context, remote string, ref string, mods ...CommandModifier) error { 430 args := []string{"push", "--set-upstream", remote, ref} 431 // TODO: Use AuthenticatedCommand 432 cmd, err := c.Command(ctx, args...) 433 if err != nil { 434 return err 435 } 436 for _, mod := range mods { 437 mod(cmd) 438 } 439 return cmd.Run() 440 } 441 442 func (c *Client) Clone(ctx context.Context, cloneURL string, args []string, mods ...CommandModifier) (string, error) { 443 cloneArgs, target := parseCloneArgs(args) 444 cloneArgs = append(cloneArgs, cloneURL) 445 // If the args contain an explicit target, pass it to clone otherwise, 446 // parse the URL to determine where git cloned it to so we can return it. 447 if target != "" { 448 cloneArgs = append(cloneArgs, target) 449 } else { 450 target = path.Base(strings.TrimSuffix(cloneURL, ".git")) 451 } 452 cloneArgs = append([]string{"clone"}, cloneArgs...) 453 // TODO: Use AuthenticatedCommand 454 cmd, err := c.Command(ctx, cloneArgs...) 455 if err != nil { 456 return "", err 457 } 458 for _, mod := range mods { 459 mod(cmd) 460 } 461 err = cmd.Run() 462 if err != nil { 463 return "", err 464 } 465 return target, nil 466 } 467 468 func (c *Client) AddRemote(ctx context.Context, name, urlStr string, trackingBranches []string, mods ...CommandModifier) (*Remote, error) { 469 args := []string{"remote", "add"} 470 for _, branch := range trackingBranches { 471 args = append(args, "-t", branch) 472 } 473 args = append(args, "-f", name, urlStr) 474 // TODO: Use AuthenticatedCommand 475 cmd, err := c.Command(ctx, args...) 476 if err != nil { 477 return nil, err 478 } 479 for _, mod := range mods { 480 mod(cmd) 481 } 482 if _, err := cmd.Output(); err != nil { 483 return nil, err 484 } 485 var urlParsed *url.URL 486 if strings.HasPrefix(urlStr, "https") { 487 urlParsed, err = url.Parse(urlStr) 488 if err != nil { 489 return nil, err 490 } 491 } else { 492 urlParsed, err = ParseURL(urlStr) 493 if err != nil { 494 return nil, err 495 } 496 } 497 remote := &Remote{ 498 Name: name, 499 FetchURL: urlParsed, 500 PushURL: urlParsed, 501 } 502 return remote, nil 503 } 504 505 func resolveGitPath() (string, error) { 506 path, err := safeexec.LookPath("git") 507 if err != nil { 508 if errors.Is(err, exec.ErrNotFound) { 509 programName := "git" 510 if runtime.GOOS == "windows" { 511 programName = "Git for Windows" 512 } 513 return "", &NotInstalled{ 514 message: fmt.Sprintf("unable to find git executable in PATH; please install %s before retrying", programName), 515 err: err, 516 } 517 } 518 return "", err 519 } 520 return path, nil 521 } 522 523 func isFilesystemPath(p string) bool { 524 return p == "." || strings.HasPrefix(p, "./") || strings.HasPrefix(p, "/") 525 } 526 527 func outputLines(output []byte) []string { 528 lines := strings.TrimSuffix(string(output), "\n") 529 return strings.Split(lines, "\n") 530 } 531 532 func firstLine(output []byte) string { 533 if i := bytes.IndexAny(output, "\n"); i >= 0 { 534 return string(output)[0:i] 535 } 536 return string(output) 537 } 538 539 func parseCloneArgs(extraArgs []string) (args []string, target string) { 540 args = extraArgs 541 if len(args) > 0 { 542 if !strings.HasPrefix(args[0], "-") { 543 target, args = args[0], args[1:] 544 } 545 } 546 return 547 } 548 549 func parseRemotes(remotesStr []string) RemoteSet { 550 remotes := RemoteSet{} 551 for _, r := range remotesStr { 552 match := remoteRE.FindStringSubmatch(r) 553 if match == nil { 554 continue 555 } 556 name := strings.TrimSpace(match[1]) 557 urlStr := strings.TrimSpace(match[2]) 558 urlType := strings.TrimSpace(match[3]) 559 560 url, err := ParseURL(urlStr) 561 if err != nil { 562 continue 563 } 564 565 var rem *Remote 566 if len(remotes) > 0 { 567 rem = remotes[len(remotes)-1] 568 if name != rem.Name { 569 rem = nil 570 } 571 } 572 if rem == nil { 573 rem = &Remote{Name: name} 574 remotes = append(remotes, rem) 575 } 576 577 switch urlType { 578 case "fetch": 579 rem.FetchURL = url 580 case "push": 581 rem.PushURL = url 582 } 583 } 584 return remotes 585 } 586 587 func populateResolvedRemotes(remotes RemoteSet, resolved []string) { 588 for _, l := range resolved { 589 parts := strings.SplitN(l, " ", 2) 590 if len(parts) < 2 { 591 continue 592 } 593 rp := strings.SplitN(parts[0], ".", 3) 594 if len(rp) < 2 { 595 continue 596 } 597 name := rp[1] 598 for _, r := range remotes { 599 if r.Name == name { 600 r.Resolved = parts[1] 601 break 602 } 603 } 604 } 605 }