github.com/purpleclay/gitz@v0.8.2-0.20240515052600-43f80eea2fe1/log.go (about) 1 package git 2 3 import ( 4 "bufio" 5 "fmt" 6 "strings" 7 8 "github.com/purpleclay/gitz/scan" 9 ) 10 11 // LogOption provides a way for setting specific options during a log operation. 12 // Each supported option can customize the way the log history of the current 13 // repository (working directory) is processed before retrieval 14 type LogOption func(*logOptions) 15 16 type logOptions struct { 17 RefRange string 18 LogPaths []string 19 SkipParse bool 20 SkipCount int 21 TakeCount int 22 Matches []string 23 InverseMatch bool 24 MatchAll bool 25 } 26 27 // WithRef provides a starting point other than HEAD (most recent commit) 28 // when retrieving the log history of the current repository (working 29 // directory). Typically a reference can be either a commit hash, branch 30 // name or tag. The output of this option will typically be a shorter, 31 // fine-tuned history. This option is mutually exclusive with 32 // [WithRefRange]. All leading and trailing whitespace are trimmed 33 // from the reference, allowing empty references to be ignored 34 func WithRef(ref string) LogOption { 35 return func(opts *logOptions) { 36 opts.RefRange = strings.TrimSpace(ref) 37 } 38 } 39 40 // WithRefRange provides both a start and end point when retrieving a 41 // focused snapshot of the log history from the current repository 42 // (working directory). Typically a reference can be either a commit 43 // hash, branch name or tag. The output of this option will be a shorter, 44 // fine-tuned history, for example, the history between two tags. 45 // This option is mutually exclusive with [WithRef]. All leading 46 // and trailing whitespace are trimmed from the references, allowing 47 // empty references to be ignored 48 func WithRefRange(fromRef string, toRef string) LogOption { 49 return func(opts *logOptions) { 50 from := strings.TrimSpace(fromRef) 51 if from == "" { 52 from = "HEAD" 53 } 54 55 to := strings.TrimSpace(toRef) 56 if to != "" { 57 to = fmt.Sprintf("...%s", to) 58 } 59 60 opts.RefRange = fmt.Sprintf("%s%s", from, to) 61 } 62 } 63 64 // WithPaths allows the log history to be retrieved for any number of 65 // files and folders within the current repository (working directory). 66 // Only commits that have had a direct impact on those files and folders 67 // will be retrieved. Paths to files and folders are relative to the 68 // root of the repository. All leading and trailing whitespace will be 69 // trimmed from the file paths, allowing empty paths to be ignored. 70 // 71 // A relative path can be resolved using [ToRelativePath]. 72 func WithPaths(paths ...string) LogOption { 73 return func(opts *logOptions) { 74 opts.LogPaths = trim(paths...) 75 } 76 } 77 78 // WithRawOnly ensures only the raw output from the git log of the current 79 // repository (working directory) is retrieved. No post-processing is 80 // carried out, resulting in an empty [Log.Commits] slice 81 func WithRawOnly() LogOption { 82 return func(opts *logOptions) { 83 opts.SkipParse = true 84 } 85 } 86 87 // WithSkip skips any number of most recent commits from within the log 88 // history. A positive number (greater than zero) is expected. Skipping 89 // more commits than exists, will result in no history being retrieved. 90 // Skipping zero commits, will retrieve the entire log. This option has 91 // a higher order of precedence than [git.WithTake] 92 func WithSkip(n int) LogOption { 93 return func(opts *logOptions) { 94 opts.SkipCount = n 95 } 96 } 97 98 // WithTake limits the number of commits that will be output within the 99 // log history. A positive number (greater than zero) is expected. Taking 100 // more commits than exists, has the same effect as retrieving the entire 101 // log. Taking zero commits, will retrieve an empty log. This option has 102 // a lower order of precedence than [git.WithSkip] 103 func WithTake(n int) LogOption { 104 return func(opts *logOptions) { 105 opts.TakeCount = n 106 } 107 } 108 109 // WithGrep limits the number of commits that will be output within the 110 // log history to any with a log message that contains one of the provided 111 // matches (regular expressions). All leading and trailing whitespace 112 // will be trimmed, allowing empty matches to be ignored 113 func WithGrep(matches ...string) LogOption { 114 return func(opts *logOptions) { 115 opts.Matches = trim(matches...) 116 } 117 } 118 119 // WithInvertGrep limits the number of commits that will be output within 120 // the log history to any with a log message that does not contain one of 121 // the provided matches (regular expressions). All leading and trailing 122 // whitespace will be trimmed, allowing empty matches to be ignored 123 func WithInvertGrep(matches ...string) LogOption { 124 return func(opts *logOptions) { 125 WithGrep(matches...)(opts) 126 opts.InverseMatch = true 127 } 128 } 129 130 // WithMatchAll when used in combination with [git.WithGrep] will limit 131 // the number of returned commits to those whose log message contains all 132 // of the provided matches (regular expressions) 133 func WithMatchAll() LogOption { 134 return func(opts *logOptions) { 135 opts.MatchAll = true 136 } 137 } 138 139 // Log represents a snapshot of commit history from a repository 140 type Log struct { 141 // Raw contains the raw commit log 142 Raw string 143 144 // Commits contains the optionally parsed commit log. By default 145 // the parsed history will always be present, unless the 146 // [WithRawOnly] option is provided during retrieval 147 Commits []LogEntry 148 } 149 150 // LogEntry represents a single parsed entry from within the commit 151 // history of a repository 152 type LogEntry struct { 153 // Hash contains the unique identifier associated with the commit 154 Hash string 155 156 // AbbrevHash contains the seven character abbreviated commit hash 157 AbbrevHash string 158 159 // Message contains the message associated with the commit 160 Message string 161 } 162 163 // Log retrieves the commit log of the current repository (working directory) 164 // in an easy-to-parse format. Options can be provided to customize log 165 // retrieval, creating a targeted snapshot. By default, the entire history 166 // from the repository HEAD (most recent commit) will be retrieved. The logs 167 // are generated using the default git options: 168 // 169 // git log --pretty='format:> %H %B%-N' --no-color 170 func (c *Client) Log(opts ...LogOption) (*Log, error) { 171 options := &logOptions{ 172 // Disable both counts by default 173 SkipCount: disabledNumericOption, 174 TakeCount: disabledNumericOption, 175 } 176 for _, opt := range opts { 177 opt(options) 178 } 179 180 // Build command based on the provided options 181 var logCmd strings.Builder 182 logCmd.WriteString("git log ") 183 184 if options.SkipCount > 0 { 185 logCmd.WriteString(" ") 186 logCmd.WriteString(fmt.Sprintf("--skip %d", options.SkipCount)) 187 } 188 189 if options.TakeCount > disabledNumericOption { 190 logCmd.WriteString(" ") 191 logCmd.WriteString(fmt.Sprintf("-n%d", options.TakeCount)) 192 } 193 194 if len(options.Matches) > 0 { 195 for _, match := range options.Matches { 196 logCmd.WriteString(" ") 197 logCmd.WriteString(fmt.Sprintf("--grep %s", match)) 198 } 199 } 200 201 if options.InverseMatch { 202 logCmd.WriteString(" --invert-grep") 203 } 204 205 if options.MatchAll { 206 logCmd.WriteString(" --all-match") 207 } 208 209 if options.RefRange != "" { 210 logCmd.WriteString(" ") 211 logCmd.WriteString(options.RefRange) 212 } 213 214 logCmd.WriteString(" --pretty='format:> %H %B%-N' --no-color") 215 216 if len(options.LogPaths) > 0 { 217 logCmd.WriteString(" --") 218 for _, path := range options.LogPaths { 219 logCmd.WriteString(fmt.Sprintf(" '%s'", path)) 220 } 221 } 222 223 out, err := c.exec(logCmd.String()) 224 if err != nil { 225 return nil, err 226 } 227 228 log := &Log{Raw: out} 229 // Support the option to skip parsing of the log into a structured format 230 if !options.SkipParse { 231 log.Commits = parseLog(out) 232 } 233 234 return log, nil 235 } 236 237 func parseLog(log string) []LogEntry { 238 var entries []LogEntry 239 240 scanner := bufio.NewScanner(strings.NewReader(log)) 241 scanner.Split(scan.PrefixedLines('>')) 242 243 for scanner.Scan() { 244 // Expected format of log from using the --online format is: <hash><space><message> 245 if hash, msg, found := strings.Cut(scanner.Text(), " "); found { 246 msg = cleanLineEndings(msg) 247 248 entries = append(entries, LogEntry{ 249 Hash: hash, 250 AbbrevHash: hash[:7], 251 Message: msg, 252 }) 253 } 254 } 255 256 return entries 257 }