| // Copyright 2022 The Dawn & Tint Authors |
| // |
| // Redistribution and use in source and binary forms, with or without |
| // modification, are permitted provided that the following conditions are met: |
| // |
| // 1. Redistributions of source code must retain the above copyright notice, this |
| // list of conditions and the following disclaimer. |
| // |
| // 2. Redistributions in binary form must reproduce the above copyright notice, |
| // this list of conditions and the following disclaimer in the documentation |
| // and/or other materials provided with the distribution. |
| // |
| // 3. Neither the name of the copyright holder nor the names of its |
| // contributors may be used to endorse or promote products derived from |
| // this software without specific prior written permission. |
| // |
| // THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" |
| // AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE |
| // IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE |
| // DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE |
| // FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL |
| // DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR |
| // SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER |
| // CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, |
| // OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE |
| // OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. |
| |
| // 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.Clean |
| type CleanOptions struct { |
| // Timeout for the operation |
| Timeout time.Duration |
| } |
| |
| // Clean deletes all the untracked files in the repo |
| func (r Repository) Clean(opt *CleanOptions) error { |
| if opt == nil { |
| opt = &CleanOptions{} |
| } |
| if _, err := r.run(nil, opt.Timeout, "clean", "-fd"); err != nil { |
| return err |
| } |
| return nil |
| } |
| |
| // 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 |
| } |