...

Source file src/edge-infra.dev/pkg/lib/build/git/git.go

Documentation: edge-infra.dev/pkg/lib/build/git

     1  // Package git provides functionality for getting information about a local Git
     2  // repository and its HEAD commit.
     3  package git
     4  
     5  import (
     6  	"errors"
     7  	"fmt"
     8  	"io/fs"
     9  	"net/url"
    10  	"os"
    11  	"path/filepath"
    12  	"strconv"
    13  	"strings"
    14  	"time"
    15  
    16  	"edge-infra.dev/pkg/lib/cli/sh"
    17  )
    18  
    19  // Git allows executing common Git tasks via the `git` CLI.
    20  type Git struct {
    21  	sh *sh.Shell
    22  }
    23  
    24  // New returns a new Git instance
    25  func New() *Git {
    26  	return &Git{sh.New()}
    27  }
    28  
    29  // NewInDir creates a new Git instance at the specified path.
    30  func NewInDir(path string) *Git {
    31  	return &Git{sh.NewInDir(path)}
    32  }
    33  
    34  // Path returns the local checkout path for the repository closest to the
    35  // current working directory.
    36  func (g *Git) Path() (string, error) {
    37  	out, err := g.sh.Run("git rev-parse --show-toplevel")
    38  	if err != nil {
    39  		return "", fmt.Errorf("failed to get repository path: %w", err)
    40  	}
    41  	return strings.TrimSpace(out), nil
    42  }
    43  
    44  // Remote returns the URL of the git remote, e.g. github.com/...
    45  func (g *Git) Remote() (string, error) {
    46  	out, err := g.sh.Run("git config --get remote.origin.url")
    47  	if err != nil {
    48  		return "", fmt.Errorf("failed to get git remote origin URL: %w", err)
    49  	}
    50  	// remove the ".git" ending if present because github.com won't serve urls
    51  	// like github.com/org/repo.git/tree/hash
    52  	urlStr := strings.TrimSuffix(strings.TrimSpace(out), ".git")
    53  
    54  	// redact any basic auth userinfo that may have been set
    55  	url, err := url.Parse(urlStr)
    56  	if err != nil {
    57  		// remote is probably ssh, e.g. git@github.com:ncrvoyix-swt-retail/edge-infra.git,
    58  		// that wont parse, but also wont have basic auth credentials
    59  		return urlStr, nil
    60  	}
    61  	url.User = nil
    62  
    63  	return url.String(), nil
    64  }
    65  
    66  // Timestamp returns the timestamp from Git using the provided args with the
    67  // location set to UTC. Args are appended directly to the `git log` command,
    68  // allowing fetching the timestamp for specific files or revision ranges.
    69  //
    70  // We use this timestamp rather than the time of the build based on the
    71  // recommendation of https://reproducible-builds.org
    72  func (g *Git) Timestamp(args ...string) (time.Time, error) {
    73  	cmd := "git log -1 --format=%ct"
    74  	if len(args) > 0 {
    75  		cmd = cmd + " " + strings.Join(args, " ")
    76  	}
    77  
    78  	out, err := g.sh.Run(cmd)
    79  	if err != nil {
    80  		return time.Time{}, fmt.Errorf("failed to get timestamp of commit from git: %w", err)
    81  	}
    82  
    83  	timestamp, err := strconv.ParseInt(strings.TrimSpace(out), 10, 64)
    84  	if err != nil {
    85  		return time.Time{}, fmt.Errorf("failed to convert timestamp of commit to int: %w", err)
    86  	}
    87  
    88  	return time.Unix(timestamp, 0).UTC(), nil
    89  }
    90  
    91  // Commit returns the HEAD commit hash.
    92  func (g *Git) Commit() (string, error) {
    93  	out, err := g.sh.Run("git rev-parse HEAD")
    94  	if err != nil {
    95  		return "", fmt.Errorf("failed to get HEAD commit from git: %w", err)
    96  	}
    97  	return strings.TrimSpace(out), nil
    98  }
    99  
   100  // Branch returns the currently checked out branch.
   101  func (g *Git) Branch() (string, error) {
   102  	out, err := g.sh.Run("git rev-parse --abbrev-ref HEAD")
   103  	if err != nil {
   104  		return "", fmt.Errorf("failed to get current branch from git: %w", err)
   105  	}
   106  	return strings.TrimSpace(out), nil
   107  }
   108  
   109  // CommitForFile returns the HEAD commit hash for the specific file, which
   110  // corresponds to the commit the file was last changed.
   111  func (g *Git) CommitForFile(file string) (string, error) {
   112  	out, err := g.sh.Run("git log -n 1 --pretty=format:%H -- " + file)
   113  	if err != nil {
   114  		return "", fmt.Errorf("failed to get HEAD commit from git for %s: %w", file, err)
   115  	}
   116  	return strings.TrimSpace(out), nil
   117  }
   118  
   119  func (g *Git) UpdateRemotes(remotes ...string) error {
   120  	_, err := g.sh.Run(fmt.Sprintf("git remote update %s", strings.Join(remotes, " ")))
   121  	if err != nil {
   122  		return fmt.Errorf("failed to update remotes %s: %w", remotes, err)
   123  	}
   124  	return nil
   125  }
   126  
   127  // IsDirty checks for any modified files on the current branch. Untracked files
   128  // are not considered.
   129  func (g *Git) IsDirty() (bool, error) {
   130  	out, err := g.sh.Run("git status --porcelain --untracked=no")
   131  	if err != nil {
   132  		return false, fmt.Errorf("failed to check if current branch is dirty: %w", err)
   133  	}
   134  	return strings.TrimSpace(out) != "", nil
   135  }
   136  
   137  // IsRebaseInProgress checks for a currently in-progress `git rebase` operation.
   138  func (g *Git) IsRebaseInProgress() (bool, error) {
   139  	present, err := g.checkForPath("rebase-merge")
   140  	if err != nil {
   141  		return false, err
   142  	}
   143  	if present {
   144  		return true, nil
   145  	}
   146  
   147  	present, err = g.checkForPath("rebase-apply")
   148  	if err != nil {
   149  		return false, err
   150  	}
   151  	if present {
   152  		return true, nil
   153  	}
   154  
   155  	return false, nil
   156  }
   157  
   158  func (g *Git) checkForPath(p string) (bool, error) {
   159  	path, err := g.sh.Run(fmt.Sprintf("git rev-parse --git-path %s", p))
   160  	if err != nil {
   161  		return false, fmt.Errorf("failed to resolve git path %s: %w", p, err)
   162  	}
   163  	path = strings.TrimSpace(path)
   164  	_, err = os.Stat(filepath.Join(g.sh.Getwd(), path))
   165  	switch {
   166  	case err == nil:
   167  		return true, nil
   168  	case errors.Is(err, fs.ErrNotExist):
   169  		return false, nil
   170  	default:
   171  		return false, fmt.Errorf("failed to check for existence of '%s': %w", path, err)
   172  	}
   173  }
   174  

View as plain text