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  }