// Package git provides functionality for getting information about a local Git // repository and its HEAD commit. package git import ( "errors" "fmt" "io/fs" "net/url" "os" "path/filepath" "strconv" "strings" "time" "edge-infra.dev/pkg/lib/cli/sh" ) // Git allows executing common Git tasks via the `git` CLI. type Git struct { sh *sh.Shell } // New returns a new Git instance func New() *Git { return &Git{sh.New()} } // NewInDir creates a new Git instance at the specified path. func NewInDir(path string) *Git { return &Git{sh.NewInDir(path)} } // Path returns the local checkout path for the repository closest to the // current working directory. func (g *Git) Path() (string, error) { out, err := g.sh.Run("git rev-parse --show-toplevel") if err != nil { return "", fmt.Errorf("failed to get repository path: %w", err) } return strings.TrimSpace(out), nil } // Remote returns the URL of the git remote, e.g. github.com/... func (g *Git) Remote() (string, error) { out, err := g.sh.Run("git config --get remote.origin.url") if err != nil { return "", fmt.Errorf("failed to get git remote origin URL: %w", err) } // remove the ".git" ending if present because github.com won't serve urls // like github.com/org/repo.git/tree/hash urlStr := strings.TrimSuffix(strings.TrimSpace(out), ".git") // redact any basic auth userinfo that may have been set url, err := url.Parse(urlStr) if err != nil { // remote is probably ssh, e.g. git@github.com:ncrvoyix-swt-retail/edge-infra.git, // that wont parse, but also wont have basic auth credentials return urlStr, nil } url.User = nil return url.String(), nil } // Timestamp returns the timestamp from Git using the provided args with the // location set to UTC. Args are appended directly to the `git log` command, // allowing fetching the timestamp for specific files or revision ranges. // // We use this timestamp rather than the time of the build based on the // recommendation of https://reproducible-builds.org func (g *Git) Timestamp(args ...string) (time.Time, error) { cmd := "git log -1 --format=%ct" if len(args) > 0 { cmd = cmd + " " + strings.Join(args, " ") } out, err := g.sh.Run(cmd) if err != nil { return time.Time{}, fmt.Errorf("failed to get timestamp of commit from git: %w", err) } timestamp, err := strconv.ParseInt(strings.TrimSpace(out), 10, 64) if err != nil { return time.Time{}, fmt.Errorf("failed to convert timestamp of commit to int: %w", err) } return time.Unix(timestamp, 0).UTC(), nil } // Commit returns the HEAD commit hash. func (g *Git) Commit() (string, error) { out, err := g.sh.Run("git rev-parse HEAD") if err != nil { return "", fmt.Errorf("failed to get HEAD commit from git: %w", err) } return strings.TrimSpace(out), nil } // Branch returns the currently checked out branch. func (g *Git) Branch() (string, error) { out, err := g.sh.Run("git rev-parse --abbrev-ref HEAD") if err != nil { return "", fmt.Errorf("failed to get current branch from git: %w", err) } return strings.TrimSpace(out), nil } // CommitForFile returns the HEAD commit hash for the specific file, which // corresponds to the commit the file was last changed. func (g *Git) CommitForFile(file string) (string, error) { out, err := g.sh.Run("git log -n 1 --pretty=format:%H -- " + file) if err != nil { return "", fmt.Errorf("failed to get HEAD commit from git for %s: %w", file, err) } return strings.TrimSpace(out), nil } func (g *Git) UpdateRemotes(remotes ...string) error { _, err := g.sh.Run(fmt.Sprintf("git remote update %s", strings.Join(remotes, " "))) if err != nil { return fmt.Errorf("failed to update remotes %s: %w", remotes, err) } return nil } // IsDirty checks for any modified files on the current branch. Untracked files // are not considered. func (g *Git) IsDirty() (bool, error) { out, err := g.sh.Run("git status --porcelain --untracked=no") if err != nil { return false, fmt.Errorf("failed to check if current branch is dirty: %w", err) } return strings.TrimSpace(out) != "", nil } // IsRebaseInProgress checks for a currently in-progress `git rebase` operation. func (g *Git) IsRebaseInProgress() (bool, error) { present, err := g.checkForPath("rebase-merge") if err != nil { return false, err } if present { return true, nil } present, err = g.checkForPath("rebase-apply") if err != nil { return false, err } if present { return true, nil } return false, nil } func (g *Git) checkForPath(p string) (bool, error) { path, err := g.sh.Run(fmt.Sprintf("git rev-parse --git-path %s", p)) if err != nil { return false, fmt.Errorf("failed to resolve git path %s: %w", p, err) } path = strings.TrimSpace(path) _, err = os.Stat(filepath.Join(g.sh.Getwd(), path)) switch { case err == nil: return true, nil case errors.Is(err, fs.ErrNotExist): return false, nil default: return false, fmt.Errorf("failed to check for existence of '%s': %w", path, err) } }