| // 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" | |
| "strconv" | |
| "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 exe == "" { | |
| g, err := exec.LookPath("git") | |
| if err != nil { | |
| return nil, fmt.Errorf("failed to find git: %v", err) | |
| } | |
| exe = g | |
| } | |
| 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 | |
| } | |
| const logPrettyFormatArg = "--pretty=format:ǁ%Hǀ%cIǀ%an <%ae>ǀ%sǀ%b" | |
| // 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, logPrettyFormatArg) | |
| out, err := r.run(nil, opt.Timeout, args...) | |
| if err != nil { | |
| return nil, err | |
| } | |
| return parseLog(out) | |
| } | |
| // Optional settings for Repository.LogBetween | |
| type LogBetweenOptions struct { | |
| // Timeout for the operation | |
| Timeout time.Duration | |
| } | |
| // LogBetween returns the list of commits between two timestamps | |
| // The first returned commit is the most recent. | |
| func (r Repository) LogBetween(since, until time.Time, opt *LogBetweenOptions) ([]CommitInfo, error) { | |
| if opt == nil { | |
| opt = &LogBetweenOptions{} | |
| } | |
| args := []string{"log", | |
| "--since", since.Format(time.RFC3339), | |
| "--until", until.Format(time.RFC3339), | |
| logPrettyFormatArg, | |
| } | |
| out, err := r.run(nil, opt.Timeout, args...) | |
| if err != nil { | |
| return nil, err | |
| } | |
| return parseLog(out) | |
| } | |
| // FileStats describes the changes to a given file in a commit | |
| type FileStats struct { | |
| Insertions int | |
| Deletions int | |
| } | |
| // CommitStats is a map of file to FileStats | |
| type CommitStats map[string]FileStats | |
| // Optional settings for Repository.Stats | |
| type StatsOptions struct { | |
| // Timeout for the operation | |
| Timeout time.Duration | |
| } | |
| // StatsOptions returns the statistics for a given change | |
| func (r Repository) Stats(commit CommitInfo, opt *StatsOptions) (CommitStats, error) { | |
| if opt == nil { | |
| opt = &StatsOptions{} | |
| } | |
| hash := commit.Hash.String() | |
| args := []string{"diff", "--numstat", hash, hash + "^"} | |
| out, err := r.run(nil, opt.Timeout, args...) | |
| if err != nil { | |
| return nil, err | |
| } | |
| stats := CommitStats{} | |
| for _, line := range strings.Split(out, "\n") { | |
| if out == "" { | |
| continue | |
| } | |
| parts := strings.Split(line, "\t") | |
| if len(parts) != 3 { | |
| return nil, fmt.Errorf("failed to parse stat line: '%v'", line) | |
| } | |
| insertions, deletions := 0, 0 | |
| if parts[0] != "-" { | |
| insertions, err = strconv.Atoi(parts[0]) | |
| if err != nil { | |
| return nil, fmt.Errorf("failed to stat insertions '%v': %w", parts[0], err) | |
| } | |
| } | |
| if parts[1] != "-" { | |
| deletions, err = strconv.Atoi(parts[1]) | |
| if err != nil { | |
| return nil, fmt.Errorf("failed to stat deletions '%v': %w", parts[1], err) | |
| } | |
| } | |
| file := parts[2] | |
| stats[file] = FileStats{Insertions: insertions, Deletions: deletions} | |
| } | |
| return stats, nil | |
| } | |
| // CommitInfo describes a single git commit | |
| type CommitInfo struct { | |
| Hash Hash | |
| Date time.Time | |
| Author string | |
| Subject string | |
| Description string | |
| } | |
| // 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 { | |
| msg := fmt.Sprintf("%v> %v %v failed:", dir, g.exe, strings.Join(args, " ")) | |
| if err := ctx.Err(); err != nil { | |
| msg += "\n" + err.Error() | |
| } | |
| return string(out), fmt.Errorf("%s\n %w\n%v", msg, 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 | |
| } |