// Copyright 2022 The Tint Authors. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Package git provides helpers for interfacing with the git tool package git import ( "context" "encoding/hex" "errors" "fmt" "net/url" "os" "os/exec" "path/filepath" "strings" "time" ) // Hash is a 20 byte, git object hash. type Hash [20]byte func (h Hash) String() string { return hex.EncodeToString(h[:]) } // IsZero returns true if the hash h is all zeros func (h Hash) IsZero() bool { zero := Hash{} return h == zero } // ParseHash returns a Hash from a hexadecimal string. func ParseHash(s string) (Hash, error) { b, err := hex.DecodeString(s) if err != nil { return Hash{}, fmt.Errorf("failed to parse hash '%v':\n %w", s, err) } h := Hash{} copy(h[:], b) return h, nil } // The timeout for git operations if no other timeout is specified var DefaultTimeout = time.Minute // Git wraps the 'git' executable type Git struct { // Path to the git executable exe string // Debug flag to print all command to the `git` executable LogAllActions bool } // New returns a new Git instance func New(exe string) (*Git, error) { if _, err := os.Stat(exe); err != nil { return nil, err } return &Git{exe: exe}, nil } // Credentials holds the user name and password used to perform git operations. type Credentials struct { Username string Password string } // Empty return true if there's no username or password for authentication func (a Credentials) Empty() bool { return a.Username == "" && a.Password == "" } // addToURL returns the url with the credentials appended func (c Credentials) addToURL(u string) (string, error) { if !c.Empty() { modified, err := url.Parse(u) if err != nil { return "", fmt.Errorf("failed to parse url '%v': %v", u, err) } modified.User = url.UserPassword(c.Username, c.Password) u = modified.String() } return u, nil } // ErrRepositoryDoesNotExist indicates that a repository does not exist var ErrRepositoryDoesNotExist = errors.New("repository does not exist") // Open opens an existing git repo at path. If the repository does not exist at // path then ErrRepositoryDoesNotExist is returned. func (g Git) Open(path string) (*Repository, error) { info, err := os.Stat(filepath.Join(path, ".git")) if err != nil || !info.IsDir() { return nil, ErrRepositoryDoesNotExist } return &Repository{g, path}, nil } // Optional settings for Git.Clone type CloneOptions struct { // If specified then the given branch will be cloned instead of the default Branch string // Timeout for the operation Timeout time.Duration // Authentication for the clone Credentials Credentials } // Clone performs a clone of the repository at url to path. func (g Git) Clone(path, url string, opt *CloneOptions) (*Repository, error) { if err := os.MkdirAll(path, 0777); err != nil { return nil, err } if opt == nil { opt = &CloneOptions{} } url, err := opt.Credentials.addToURL(url) if err != nil { return nil, err } r := &Repository{g, path} args := []string{"clone", url, "."} if opt.Branch != "" { args = append(args, "--branch", opt.Branch) } if _, err := r.run(nil, opt.Timeout, args...); err != nil { return nil, err } return r, nil } // Repository points to a git repository type Repository struct { // Path to the 'git' executable Git Git // Repo directory Path string } // Optional settings for Repository.Fetch type FetchOptions struct { // The remote name. Defaults to 'origin' Remote string // Timeout for the operation Timeout time.Duration // Git authentication for the remote Credentials Credentials } // Fetch performs a fetch of a reference from the remote, returning the Hash of // the fetched reference. func (r Repository) Fetch(ref string, opt *FetchOptions) (Hash, error) { if opt == nil { opt = &FetchOptions{} } if opt.Remote == "" { opt.Remote = "origin" } if _, err := r.run(nil, opt.Timeout, "fetch", opt.Remote, ref); err != nil { return Hash{}, err } out, err := r.run(nil, 0, "rev-parse", "FETCH_HEAD") if err != nil { return Hash{}, err } return ParseHash(out) } // Optional settings for Repository.Push type PushOptions struct { // The remote name. Defaults to 'origin' Remote string // Timeout for the operation Timeout time.Duration // Git authentication for the remote Credentials Credentials } // Push performs a push of the local reference to the remote reference. func (r Repository) Push(localRef, remoteRef string, opt *PushOptions) error { if opt == nil { opt = &PushOptions{} } if opt.Remote == "" { opt.Remote = "origin" } url, err := r.run(nil, opt.Timeout, "remote", "get-url", opt.Remote) if err != nil { return err } url, err = opt.Credentials.addToURL(url) if err != nil { return err } if _, err := r.run(nil, opt.Timeout, "push", url, localRef+":"+remoteRef); err != nil { return err } return nil } // Optional settings for Repository.Add type AddOptions struct { // Timeout for the operation Timeout time.Duration // Git authentication for the remote Credentials Credentials } // Add stages the listed files func (r Repository) Add(path string, opt *AddOptions) error { if opt == nil { opt = &AddOptions{} } if _, err := r.run(nil, opt.Timeout, "add", path); err != nil { return err } return nil } // Optional settings for Repository.Commit type CommitOptions struct { // Timeout for the operation Timeout time.Duration // Author name AuthorName string // Author email address AuthorEmail string // Amend last commit? Amend bool } // Commit commits the staged files with the given message, returning the hash of // commit func (r Repository) Commit(msg string, opt *CommitOptions) (Hash, error) { if opt == nil { opt = &CommitOptions{} } args := []string{"commit"} if opt.Amend { args = append(args, "--amend") } else { args = append(args, "-m", msg) } var env []string if opt.AuthorName != "" || opt.AuthorEmail != "" { env = []string{ fmt.Sprintf("GIT_AUTHOR_NAME=%v", opt.AuthorName), fmt.Sprintf("GIT_AUTHOR_EMAIL=%v", opt.AuthorEmail), fmt.Sprintf("GIT_COMMITTER_NAME=%v", opt.AuthorName), fmt.Sprintf("GIT_COMMITTER_EMAIL=%v", opt.AuthorEmail), } } if _, err := r.run(env, opt.Timeout, "commit", "-m", msg); err != nil { return Hash{}, err } out, err := r.run(nil, 0, "rev-parse", "HEAD") if err != nil { return Hash{}, err } return ParseHash(out) } // Optional settings for Repository.Checkout type CheckoutOptions struct { // Timeout for the operation Timeout time.Duration } // Checkout performs a checkout of a reference. func (r Repository) Checkout(ref string, opt *CheckoutOptions) error { if opt == nil { opt = &CheckoutOptions{} } if _, err := r.run(nil, opt.Timeout, "checkout", ref); err != nil { return err } return nil } // Optional settings for Repository.Log type LogOptions struct { // The git reference to the oldest commit in the range to query. From string // The git reference to the newest commit in the range to query. To string // Timeout for the operation Timeout time.Duration } // CommitInfo describes a single git commit type CommitInfo struct { Hash Hash Date time.Time Author string Subject string Description string } // Log returns the list of commits between two references (inclusive). // The first returned commit is the most recent. func (r Repository) Log(opt *LogOptions) ([]CommitInfo, error) { if opt == nil { opt = &LogOptions{} } args := []string{"log"} rng := "HEAD" if opt.To != "" { rng = opt.To } if opt.From != "" { rng = opt.From + "^.." + rng } args = append(args, rng, "--pretty=format:ǁ%Hǀ%cIǀ%an <%ae>ǀ%sǀ%b") out, err := r.run(nil, opt.Timeout, args...) if err != nil { return nil, err } return parseLog(out) } // Optional settings for Repository.ConfigOptions type ConfigOptions struct { // Timeout for the operation Timeout time.Duration } // Config returns the git configuration values for the repo func (r Repository) Config(opt *ConfigOptions) (map[string]string, error) { if opt == nil { opt = &ConfigOptions{} } text, err := r.run(nil, opt.Timeout, "config", "-l") if err != nil { return nil, err } lines := strings.Split(text, "\n") out := make(map[string]string, len(lines)) for _, line := range lines { idx := strings.Index(line, "=") if idx > 0 { key, value := line[:idx], line[idx+1:] out[key] = value } } return out, nil } func (r Repository) run(env []string, timeout time.Duration, args ...string) (string, error) { return r.Git.run(r.Path, env, timeout, args...) } func (g Git) run(dir string, env []string, timeout time.Duration, args ...string) (string, error) { if timeout == 0 { timeout = DefaultTimeout } ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() cmd := exec.CommandContext(ctx, g.exe, args...) cmd.Dir = dir if env != nil { // Godocs for exec.Cmd.Env: // "If Env contains duplicate environment keys, only the last value in // the slice for each duplicate key is used. cmd.Env = append(os.Environ(), env...) } if g.LogAllActions { fmt.Printf("%v> %v %v\n", dir, g.exe, strings.Join(args, " ")) } out, err := cmd.CombinedOutput() if g.LogAllActions { fmt.Println(string(out)) } if err != nil { return string(out), fmt.Errorf("%v> %v %v failed:\n %w\n%v", dir, g.exe, strings.Join(args, " "), err, string(out)) } return strings.TrimSpace(string(out)), nil } func parseLog(str string) ([]CommitInfo, error) { msgs := strings.Split(str, "ǁ") cls := make([]CommitInfo, 0, len(msgs)) for _, s := range msgs { if parts := strings.Split(s, "ǀ"); len(parts) == 5 { hash, err := ParseHash(parts[0]) if err != nil { return nil, err } date, err := time.Parse(time.RFC3339, parts[1]) if err != nil { return nil, err } cl := CommitInfo{ Hash: hash, Date: date, Author: strings.TrimSpace(parts[2]), Subject: strings.TrimSpace(parts[3]), Description: strings.TrimSpace(parts[4]), } cls = append(cls, cl) } } return cls, nil }