github.com/purpleclay/gitz@v0.8.2-0.20240515052600-43f80eea2fe1/client.go (about)

     1  package git
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	"errors"
     7  	"fmt"
     8  	"os"
     9  	"path/filepath"
    10  	"strings"
    11  
    12  	"mvdan.cc/sh/v3/interp"
    13  	"mvdan.cc/sh/v3/syntax"
    14  )
    15  
    16  const (
    17  	disabledNumericOption = -1
    18  
    19  	// RelativeAtRoot can be used to compare if a path is equivalent to the
    20  	// root of a current git repository working directory
    21  	RelativeAtRoot = "."
    22  
    23  	// HeadRef is a pointer to the latest commit within a git repository
    24  	HeadRef = "HEAD"
    25  )
    26  
    27  // ErrGitMissing is raised when no git client was identified
    28  // within the PATH environment variable on the current OS
    29  type ErrGitMissing struct {
    30  	// PathEnv contains the value of the PATH environment variable
    31  	PathEnv string
    32  }
    33  
    34  // Error returns a friendly formatted message of the current error
    35  func (e ErrGitMissing) Error() string {
    36  	return fmt.Sprintf("git is not installed under the PATH environment variable. PATH resolves to %s", e.PathEnv)
    37  }
    38  
    39  // ErrGitExecCommand is raised when a git command fails to execute
    40  type ErrGitExecCommand struct {
    41  	// Cmd contains the command that caused the git client to error
    42  	Cmd string
    43  
    44  	// Out contains any raw output from the git client as a result
    45  	// of the error
    46  	Out string
    47  }
    48  
    49  // Error returns a friendly formatted message of the current error
    50  func (e ErrGitExecCommand) Error() string {
    51  	return fmt.Sprintf(`failed to execute git command: %s
    52  
    53  %s`, e.Cmd, e.Out)
    54  }
    55  
    56  // ErrGitNonRelativePath is raised when attempting to resolve a path
    57  // within a git repository that isn't relative to the root of the
    58  // working directory
    59  type ErrGitNonRelativePath struct {
    60  	// RootDir contains the root working directory of the repository
    61  	RootDir string
    62  
    63  	// TargetPath contains the path that was resolved against the
    64  	// root working directory of the repository
    65  	TargetPath string
    66  
    67  	// RelativePath contains the resolved relative path which raised
    68  	// the error
    69  	RelativePath string
    70  }
    71  
    72  // Error returns a friendly formatted message of the current error
    73  func (e ErrGitNonRelativePath) Error() string {
    74  	return fmt.Sprintf("%s is not relative to the git repository working directory %s as it produces path %s",
    75  		e.TargetPath, e.RootDir, e.RelativePath)
    76  }
    77  
    78  // Repository provides a snapshot of the current state of a repository
    79  // (working directory)
    80  type Repository struct {
    81  	// DetachedHead is true if the current repository HEAD points to a
    82  	// specific commit, rather than a branch
    83  	DetachedHead bool
    84  
    85  	// DefaultBranch is the initial branch that is checked out when
    86  	// a repository is cloned
    87  	DefaultBranch string
    88  
    89  	// Origin contains the URL of the remote which this repository
    90  	// was cloned from
    91  	Origin string
    92  
    93  	// Remotes will contain all of the remotes and their URLs as
    94  	// configured for this repository
    95  	Remotes map[string]string
    96  
    97  	// RootDir contains the path to the cloned directory
    98  	RootDir string
    99  
   100  	// ShallowClone is true if the current repository has been cloned
   101  	// to a specified depth without the entire commit history
   102  	ShallowClone bool
   103  }
   104  
   105  // Client provides a way of performing fluent operations against git.
   106  // Any git operation exposed by this client are effectively handed-off
   107  // to an installed git client on the current OS. Git operations will be
   108  // mapped as closely as possible to the official Git specification
   109  type Client struct {
   110  	gitVersion string
   111  }
   112  
   113  // NewClient returns a new instance of the git client
   114  func NewClient() (*Client, error) {
   115  	c := &Client{}
   116  
   117  	if _, err := c.exec("type git"); err != nil {
   118  		return nil, ErrGitMissing{PathEnv: os.Getenv("PATH")}
   119  	}
   120  
   121  	c.gitVersion, _ = c.exec("git --version")
   122  	return c, nil
   123  }
   124  
   125  // Version of git used by the client
   126  func (c *Client) Version() string {
   127  	return c.gitVersion
   128  }
   129  
   130  // Repository captures and returns a snapshot of the current repository
   131  // (working directory) state
   132  func (c *Client) Repository() (Repository, error) {
   133  	isRepo, _ := c.exec("git rev-parse --is-inside-work-tree")
   134  	if strings.TrimSpace(isRepo) != "true" {
   135  		return Repository{}, errors.New("current working directory is not a git repository")
   136  	}
   137  
   138  	isShallow, _ := c.exec("git rev-parse --is-shallow-repository")
   139  	isDetached, _ := c.exec("git branch --show-current")
   140  	defaultBranch, _ := c.exec("git rev-parse --abbrev-ref remotes/origin/HEAD")
   141  	rootDir, _ := c.rootDir()
   142  
   143  	// Identify all remotes associated with this repository. If this is a new
   144  	// locally initialized repository, this could be empty
   145  	rmts, _ := c.exec("git remote")
   146  	remotes := map[string]string{}
   147  	for _, remote := range strings.Split(rmts, "\n") {
   148  		remoteURL, _ := c.exec("git remote get-url " + remote)
   149  		remotes[remote] = filepath.ToSlash(remoteURL)
   150  	}
   151  
   152  	origin := ""
   153  	if orig, found := remotes["origin"]; found {
   154  		origin = orig
   155  	}
   156  
   157  	return Repository{
   158  		DetachedHead:  strings.TrimSpace(isDetached) == "",
   159  		DefaultBranch: strings.TrimPrefix(defaultBranch, "origin/"),
   160  		Origin:        origin,
   161  		Remotes:       remotes,
   162  		RootDir:       rootDir,
   163  		ShallowClone:  strings.TrimSpace(isShallow) == "true",
   164  	}, nil
   165  }
   166  
   167  func (*Client) exec(cmd string) (string, error) {
   168  	p, _ := syntax.NewParser().Parse(strings.NewReader(cmd), "")
   169  
   170  	var buf bytes.Buffer
   171  	r, _ := interp.New(
   172  		interp.StdIO(os.Stdin, &buf, &buf),
   173  	)
   174  
   175  	if err := r.Run(context.Background(), p); err != nil {
   176  		return "", ErrGitExecCommand{
   177  			Cmd: cmd,
   178  			Out: strings.TrimSuffix(buf.String(), "\n"),
   179  		}
   180  	}
   181  
   182  	return strings.TrimSuffix(buf.String(), "\n"), nil
   183  }
   184  
   185  func (c *Client) rootDir() (string, error) {
   186  	return c.exec("git rev-parse --show-toplevel")
   187  }
   188  
   189  // ToRelativePath determines if a path is relative to the
   190  // working directory of the repository and returns the resolved
   191  // relative path. A [ErrGitNonRelativePath] error will be returned
   192  // if the path exists outside of the working directory.
   193  // [RelativeAtRoot] is returned if the path and working directory
   194  // are equivalent
   195  func (c *Client) ToRelativePath(path string) (string, error) {
   196  	root, err := c.rootDir()
   197  	if err != nil {
   198  		return "", err
   199  	}
   200  
   201  	rel, err := filepath.Rel(root, path)
   202  	if err != nil {
   203  		return "", err
   204  	}
   205  
   206  	// Ensure slashes are OS agnostic
   207  	rel = filepath.ToSlash(rel)
   208  
   209  	// Reject any paths that are not located within the root repository directory
   210  	if strings.HasPrefix(rel, "../") {
   211  		return "", ErrGitNonRelativePath{
   212  			RootDir:      root,
   213  			TargetPath:   path,
   214  			RelativePath: rel,
   215  		}
   216  	}
   217  
   218  	return rel, nil
   219  }