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 }