cli/azd/pkg/tools/git/git.go (231 lines of code) (raw):
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
package git
import (
"context"
"errors"
"fmt"
"log"
"regexp"
"runtime"
"strings"
"github.com/azure/azure-dev/cli/azd/pkg/exec"
"github.com/azure/azure-dev/cli/azd/pkg/tools"
"github.com/azure/azure-dev/cli/azd/pkg/tools/github"
"github.com/blang/semver/v4"
)
var _ tools.ExternalTool = (*Cli)(nil)
type Cli struct {
commandRunner exec.CommandRunner
}
func NewCli(commandRunner exec.CommandRunner) *Cli {
return &Cli{
commandRunner: commandRunner,
}
}
func (cli *Cli) versionInfo() tools.VersionInfo {
return tools.VersionInfo{
// Support version from 09-Dec-2018 08:40
// https://mirrors.edge.kernel.org/pub/software/scm/git/
// 4 years should cover most Linux out of the box version
MinimumVersion: semver.Version{
Major: 2,
Minor: 20,
Patch: 0},
UpdateCommand: "Visit https://git-scm.com/downloads to upgrade",
}
}
func (cli *Cli) CheckInstalled(ctx context.Context) error {
err := tools.ToolInPath("git")
if err != nil {
return err
}
gitRes, err := tools.ExecuteCommand(ctx, cli.commandRunner, "git", "--version")
if err != nil {
return fmt.Errorf("checking %s version: %w", cli.Name(), err)
}
log.Printf("git version: %s", gitRes)
gitSemver, err := tools.ExtractVersion(gitRes)
if err != nil {
return fmt.Errorf("converting to semver version fails: %w", err)
}
updateDetail := cli.versionInfo()
if gitSemver.LT(updateDetail.MinimumVersion) {
return &tools.ErrSemver{ToolName: cli.Name(), VersionInfo: updateDetail}
}
return nil
}
func (cli *Cli) InstallUrl() string {
return "https://git-scm.com/downloads"
}
func (cli *Cli) Name() string {
return "git CLI"
}
func (cli *Cli) ShallowClone(ctx context.Context, repositoryPath string, branch string, target string) error {
args := []string{"clone", "--depth", "1", repositoryPath}
if branch != "" {
args = append(args, "--branch", branch)
}
args = append(args, target)
// Do not call `newRunArgs()` here because we don't want to apply the codespaces special patch that removes
// default authentication. `git clone` should work for private repos within a codespace with default auth.
// See: https://github.com/Azure/azure-dev/issues/2582
runArgs := exec.NewRunArgs("git", args...)
_, err := cli.commandRunner.Run(ctx, runArgs)
if err != nil {
return fmt.Errorf("failed to clone repository %s: %w", repositoryPath, err)
}
return nil
}
var noSuchRemoteRegex = regexp.MustCompile("(fatal|error): No such remote")
var notGitRepositoryRegex = regexp.MustCompile("(fatal|error): not a git repository")
var ErrNoSuchRemote = errors.New("no such remote")
var ErrNotRepository = errors.New("not a git repository")
var gitUntrackedFileRegex = regexp.MustCompile("untracked files present|new file")
func (cli *Cli) GetRemoteUrl(ctx context.Context, repositoryPath string, remoteName string) (string, error) {
runArgs := newRunArgs("-C", repositoryPath, "remote", "get-url", remoteName)
res, err := cli.commandRunner.Run(ctx, runArgs)
if noSuchRemoteRegex.MatchString(res.Stderr) {
return "", ErrNoSuchRemote
} else if notGitRepositoryRegex.MatchString(res.Stderr) {
return "", ErrNotRepository
} else if err != nil {
return "", fmt.Errorf("failed to get remote url: %w", err)
}
return strings.TrimSpace(res.Stdout), nil
}
func (cli *Cli) GetCurrentBranch(ctx context.Context, repositoryPath string) (string, error) {
runArgs := newRunArgs("-C", repositoryPath, "branch", "--show-current")
res, err := cli.commandRunner.Run(ctx, runArgs)
if notGitRepositoryRegex.MatchString(res.Stderr) {
return "", ErrNotRepository
} else if err != nil {
return "", fmt.Errorf("failed to get current branch: %w", err)
}
return strings.TrimSpace(res.Stdout), nil
}
func (cli *Cli) GetRepoRoot(ctx context.Context, repositoryPath string) (string, error) {
runArgs := newRunArgs("-C", repositoryPath, "rev-parse", "--show-toplevel")
res, err := cli.commandRunner.Run(ctx, runArgs)
if notGitRepositoryRegex.MatchString(res.Stderr) {
return "", ErrNotRepository
} else if err != nil {
return "", fmt.Errorf("failed to get repository root: %w", err)
}
return strings.TrimSpace(res.Stdout), nil
}
func (cli *Cli) InitRepo(ctx context.Context, repositoryPath string) error {
runArgs := newRunArgs("-C", repositoryPath, "init")
_, err := cli.commandRunner.Run(ctx, runArgs)
if err != nil {
return fmt.Errorf("failed to init repository: %w", err)
}
// Set initial branch to main
runArgs = newRunArgs("-C", repositoryPath, "checkout", "-b", "main")
_, err = cli.commandRunner.Run(ctx, runArgs)
if err != nil {
return fmt.Errorf("failed to create main branch: %w", err)
}
return nil
}
func (cli *Cli) SetCredentialStore(ctx context.Context, repositoryPath string) error {
runArgs := newRunArgs("-C", repositoryPath, "config", "credential.helper", "store")
_, err := cli.commandRunner.Run(ctx, runArgs)
if err != nil {
return fmt.Errorf("failed to set credential store repository: %w", err)
}
return nil
}
func (cli *Cli) AddRemote(ctx context.Context, repositoryPath string, remoteName string, remoteUrl string) error {
runArgs := newRunArgs("-C", repositoryPath, "remote", "add", remoteName, remoteUrl)
_, err := cli.commandRunner.Run(ctx, runArgs)
if err != nil {
return fmt.Errorf("failed to add remote: %w", err)
}
return nil
}
func (cli *Cli) UpdateRemote(ctx context.Context, repositoryPath string, remoteName string, remoteUrl string) error {
runArgs := newRunArgs("-C", repositoryPath, "remote", "set-url", remoteName, remoteUrl)
_, err := cli.commandRunner.Run(ctx, runArgs)
if err != nil {
return fmt.Errorf("failed to add remote: %w", err)
}
return nil
}
func (cli *Cli) AddFile(ctx context.Context, repositoryPath string, filespec string) error {
runArgs := newRunArgs("-C", repositoryPath, "add", filespec)
_, err := cli.commandRunner.Run(ctx, runArgs)
if err != nil {
return fmt.Errorf("failed to add files: %w", err)
}
return nil
}
func (cli *Cli) Commit(ctx context.Context, repositoryPath string, message string) error {
runArgs := newRunArgs("-C", repositoryPath, "commit", "--allow-empty", "-m", message)
_, err := cli.commandRunner.Run(ctx, runArgs)
if err != nil {
return fmt.Errorf("failed to commit: %w", err)
}
return nil
}
func (cli *Cli) PushUpstream(ctx context.Context, repositoryPath string, origin string, branch string) error {
runArgs := newRunArgs("-C", repositoryPath, "push", "--set-upstream", "--quiet", origin, branch).
WithInteractive(true)
_, err := cli.commandRunner.Run(ctx, runArgs)
if err != nil {
return fmt.Errorf("failed to push: %w", err)
}
return nil
}
func (cli *Cli) ListStagedFiles(ctx context.Context, repositoryPath string) (string, error) {
runArgs := newRunArgs("-C", repositoryPath, "ls-files", "--stage")
res, err := cli.commandRunner.Run(ctx, runArgs)
if err != nil {
return "", fmt.Errorf("failed to list files: %w", err)
}
return res.Stdout, nil
}
func (cli *Cli) AddFileExecPermission(ctx context.Context, repositoryPath string, file string) error {
runArgs := newRunArgs("-C", repositoryPath, "update-index", "--add", "--chmod=+x", file)
_, err := cli.commandRunner.Run(ctx, runArgs)
if err != nil {
return fmt.Errorf("failed to add file exec permission: %w", err)
}
return nil
}
func (cli *Cli) IsUntrackedFile(ctx context.Context, repositoryPath string, filePath string) (bool, error) {
runArgs := newRunArgs("-C", repositoryPath, "status", filePath)
res, err := cli.commandRunner.Run(ctx, runArgs)
if err != nil {
return false, fmt.Errorf("failed to check status file: %w", err)
}
if gitUntrackedFileRegex.MatchString(res.Stdout) {
return true, nil
}
return false, nil
}
// SetGitHubAuthForRepo creates git config for the repositoryPath like
//
// [credential "https://github.com"] (when credential is equal to "https://github.com")
// helper =
// helper = !ghPath auth git-credential
//
// This way, git commands run from repositoryPath will use gh as auth credential.
// Note: Removes any previous configuration for the credential.
// Note: `helper = ` is intentional to break the chain of previously configured global helpers.
// See: https://github.com/cli/cli/issues/3796 for more about this strategy.
func (cli *Cli) SetGitHubAuthForRepo(ctx context.Context, repositoryPath, credential, ghPath string) error {
if err := setAuthCredentialHelper(
ctx, cli.commandRunner, repositoryPath, credential, "", "replace-all"); err != nil {
return err
}
// path needs to be quoted on windows
if runtime.GOOS == "windows" {
ghPath = fmt.Sprintf("'%s'", ghPath)
}
ghCredentialValue := fmt.Sprintf("!%s auth git-credential", ghPath)
if err := setAuthCredentialHelper(
ctx, cli.commandRunner, repositoryPath, credential, ghCredentialValue, "add"); err != nil {
return err
}
return nil
}
func setAuthCredentialHelper(
ctx context.Context, runner exec.CommandRunner, repositoryPath, credential, value, flag string) error {
runArgs := newRunArgs(
"-C", repositoryPath,
"config", "--local", fmt.Sprintf("--%s", flag),
fmt.Sprintf("credential.%s.helper", credential),
value)
if _, err := runner.Run(ctx, runArgs); err != nil {
return fmt.Errorf("failed to set credential helper: %s='%s': %w", credential, value, err)
}
return nil
}
func newRunArgs(args ...string) exec.RunArgs {
runArgs := exec.NewRunArgs("git", args...)
if github.RunningOnCodespaces() {
// azd running git in codespaces should not use the Codespaces token.
// As azd needs bigger access across repos. And the token in codespaces is mono-repo by default
runArgs = runArgs.WithEnv([]string{"GITHUB_TOKEN=", "GH_TOKEN="})
}
return runArgs
}