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

     1  package git
     2  
     3  import (
     4  	"fmt"
     5  	"strings"
     6  )
     7  
     8  // ErrMissingTagCommitRef is raised when a git tag is missing an
     9  // associated commit hash
    10  type ErrMissingTagCommitRef struct {
    11  	// Tag reference
    12  	Tag string
    13  }
    14  
    15  // Error returns a friendly formatted message of the current error
    16  func (e ErrMissingTagCommitRef) Error() string {
    17  	return fmt.Sprintf("tag commit ref mismatch. tag: %s is missing a corresponding commit ref", e.Tag)
    18  }
    19  
    20  // SortKey represents a structured [field name] that can be used as a sort key
    21  // when analysing referenced objects such as tags
    22  //
    23  // [field name]: https://git-scm.com/docs/git-for-each-ref#_field_names
    24  type SortKey string
    25  
    26  const (
    27  	// CreatorDate sorts the reference in ascending order by the creation date
    28  	// of the underlying commit
    29  	CreatorDate SortKey = "creatordate"
    30  
    31  	// CreatorDateDesc sorts the reference in descending order by the creation date
    32  	// of the underlying commit
    33  	CreatorDateDesc SortKey = "-creatordate"
    34  
    35  	// RefName sorts the reference by its name in ascending lexicographic order
    36  	RefName SortKey = "refname"
    37  
    38  	// RefNameDesc sorts the reference by its name in descending lexicographic order
    39  	RefNameDesc SortKey = "-refname"
    40  
    41  	// TaggerDate sorts the reference in ascending order by its tag creation date
    42  	TaggerDate SortKey = "taggerdate"
    43  
    44  	// TaggerDateDesc sorts the reference in descending order by its tag
    45  	// creation date
    46  	TaggerDateDesc SortKey = "-taggerdate"
    47  
    48  	// Version interpolates the references as a version number and sorts in
    49  	// ascending order
    50  	Version SortKey = "version:refname"
    51  
    52  	// VersionDesc interpolates the references as a version number and sorts in
    53  	// descending order
    54  	VersionDesc SortKey = "-version:refname"
    55  )
    56  
    57  // String converts the sort key from an enum into its string counterpart
    58  func (k SortKey) String() string {
    59  	return string(k)
    60  }
    61  
    62  // CreateTagOption provides a way for setting specific options during a tag
    63  // creation operation. Each supported option can customize the way the tag is
    64  // created against the current repository (working directory)
    65  type CreateTagOption func(*createTagOptions)
    66  
    67  type createTagOptions struct {
    68  	Annotation    string
    69  	CommitRef     string
    70  	Config        []string
    71  	ForceNoSigned bool
    72  	LocalOnly     bool
    73  	Signed        bool
    74  	SigningKey    string
    75  }
    76  
    77  // WithAnnotation ensures the created tag is annotated with the provided
    78  // message. This ultimately converts the standard lightweight tag into
    79  // an annotated tag which is stored as a full object within the git
    80  // database. Any leading and trailing whitespace will automatically be
    81  // trimmed from the message. This allows empty messages to be ignored
    82  func WithAnnotation(message string) CreateTagOption {
    83  	return func(opts *createTagOptions) {
    84  		opts.Annotation = strings.TrimSpace(message)
    85  	}
    86  }
    87  
    88  // WithCommitRef ensures the created tag points to a specific commit
    89  // within the history of the repository. This changes the default behavior
    90  // of creating a tag against the HEAD (or latest commit) within the repository
    91  func WithCommitRef(ref string) CreateTagOption {
    92  	return func(opts *createTagOptions) {
    93  		opts.CommitRef = strings.TrimSpace(ref)
    94  	}
    95  }
    96  
    97  // WithLocalOnly ensures the created tag will not be pushed back to
    98  // the remote and be kept as a local tag only
    99  func WithLocalOnly() CreateTagOption {
   100  	return func(opts *createTagOptions) {
   101  		opts.LocalOnly = true
   102  	}
   103  }
   104  
   105  // WithTagConfig allows temporary git config to be set during the
   106  // creation of a tag. Config set using this approach will override
   107  // any config defined within existing git config files. Config must be
   108  // provided as key value pairs, mismatched config will result in an
   109  // [ErrMissingConfigValue] error. Any invalid paths will result in an
   110  // [ErrInvalidConfigPath] error
   111  func WithTagConfig(kv ...string) CreateTagOption {
   112  	return func(opts *createTagOptions) {
   113  		opts.Config = trim(kv...)
   114  	}
   115  }
   116  
   117  // WithSigned will create a GPG-signed tag using the GPG key associated
   118  // with the taggers email address. Overriding this behavior is possible
   119  // through the user.signingkey config setting. This option does not need
   120  // to be explicitly called if the tag.gpgSign config setting is set to
   121  // true. An annotated tag is mandatory when signing. A default annotation
   122  // will be assigned, unless overridden with the [WithAnnotation] option:
   123  //
   124  //	created tag 0.1.0
   125  func WithSigned() CreateTagOption {
   126  	return func(opts *createTagOptions) {
   127  		opts.Signed = true
   128  	}
   129  }
   130  
   131  // WithSigningKey will create a GPG-signed tag using the provided GPG
   132  // key ID, overridding any default GPG key set by the user.signingKey
   133  // config setting. An annotated tag is mandatory when signing. A default
   134  // annotation will be assigned, unless overridden with the [WithAnnotation]
   135  // option:
   136  //
   137  //	created tag 0.1.0
   138  func WithSigningKey(key string) CreateTagOption {
   139  	return func(opts *createTagOptions) {
   140  		opts.Signed = true
   141  		opts.SigningKey = strings.TrimSpace(key)
   142  	}
   143  }
   144  
   145  // WithSkipSigning ensures the created tag will not be GPG signed
   146  // regardless of the value assigned to the repositories tag.gpgSign
   147  // git config setting
   148  func WithSkipSigning() CreateTagOption {
   149  	return func(opts *createTagOptions) {
   150  		opts.ForceNoSigned = true
   151  	}
   152  }
   153  
   154  // Tag a specific point within a repositories history and push it to the
   155  // configured remote. Tagging comes in two flavours:
   156  //   - A lightweight tag, which points to a specific commit within
   157  //     the history and marks a specific point in time
   158  //   - An annotated tag, which is treated as a full object within
   159  //     git, and must include a tagging message (or annotation)
   160  //
   161  // By default, a lightweight tag will be created, unless specific tag
   162  // options are provided
   163  func (c *Client) Tag(tag string, opts ...CreateTagOption) (string, error) {
   164  	options := &createTagOptions{}
   165  	for _, opt := range opts {
   166  		opt(options)
   167  	}
   168  
   169  	cfg, err := ToInlineConfig(options.Config...)
   170  	if err != nil {
   171  		return "", err
   172  	}
   173  
   174  	// Build command based on the provided options
   175  	var buf strings.Builder
   176  	buf.WriteString("git")
   177  
   178  	if len(cfg) > 0 {
   179  		buf.WriteString(" ")
   180  		buf.WriteString(strings.Join(cfg, " "))
   181  	}
   182  	buf.WriteString(" tag")
   183  
   184  	if options.Signed {
   185  		if options.Annotation == "" {
   186  			options.Annotation = "created tag " + tag
   187  		}
   188  		buf.WriteString(" -s")
   189  	}
   190  
   191  	if options.SigningKey != "" {
   192  		buf.WriteString(" -u " + options.SigningKey)
   193  	}
   194  
   195  	if options.ForceNoSigned {
   196  		buf.WriteString(" --no-sign")
   197  	}
   198  
   199  	if options.Annotation != "" {
   200  		buf.WriteString(fmt.Sprintf(" -a -m '%s'", options.Annotation))
   201  	}
   202  	buf.WriteString(fmt.Sprintf(" '%s'", tag))
   203  
   204  	if options.CommitRef != "" {
   205  		buf.WriteString(" " + options.CommitRef)
   206  	}
   207  
   208  	out, err := c.exec(buf.String())
   209  	if err != nil {
   210  		return out, err
   211  	}
   212  
   213  	if options.LocalOnly {
   214  		return out, nil
   215  	}
   216  
   217  	return c.exec(fmt.Sprintf("git push origin '%s'", tag))
   218  }
   219  
   220  // TagBatch attempts to create a batch of tags against a specific point within
   221  // a repositories history. All tags are created locally and then pushed in
   222  // a single transaction to the remote. This behavior is enforced by explicitly
   223  // enabling the [WithLocalOnly] option
   224  func (c *Client) TagBatch(tags []string, opts ...CreateTagOption) (string, error) {
   225  	if len(tags) == 0 {
   226  		return "", nil
   227  	}
   228  
   229  	opts = append(opts, WithLocalOnly())
   230  	for _, tag := range tags {
   231  		c.Tag(tag, opts...)
   232  	}
   233  
   234  	return c.Push(WithRefSpecs(tags...))
   235  }
   236  
   237  // TagBatchAt attempts to create a batch of tags that target specific commits
   238  // within a repositories history. Any number of pairs consisting of a tag and
   239  // commit hash must be provided.
   240  //
   241  //	TagBatchAt([]string{"0.1.0", "740a8b9", "0.2.0", "9e7dfbb"})
   242  //
   243  // All tags are created locally and then pushed in a single transaction to the
   244  // remote. This behavior is enforced by explicitly enabling the [WithLocalOnly]
   245  // option
   246  func (c *Client) TagBatchAt(pairs []string, opts ...CreateTagOption) (string, error) {
   247  	if len(pairs) == 0 {
   248  		return "", nil
   249  	}
   250  
   251  	if len(pairs)%2 != 0 {
   252  		return "", ErrMissingTagCommitRef{Tag: pairs[len(pairs)-1]}
   253  	}
   254  
   255  	opts = append(opts, WithLocalOnly())
   256  	var refs []string
   257  	for i := 0; i < len(pairs); i += 2 {
   258  		c.Tag(pairs[i], append(opts, WithCommitRef(pairs[i+1]))...)
   259  		refs = append(refs, pairs[i])
   260  	}
   261  
   262  	return c.Push(WithRefSpecs(refs...))
   263  }
   264  
   265  // ListTagsOption provides a way for setting specific options during a list
   266  // tags operation. Each supported option can customize the way in which the
   267  // tags are queried and returned from the current repository (workng directory)
   268  type ListTagsOption func(*listTagsOptions)
   269  
   270  type listTagsOptions struct {
   271  	Count        int
   272  	Filters      []TagFilter
   273  	ShellGlobs   []string
   274  	SemanticSort bool
   275  	SortBy       []string
   276  }
   277  
   278  // TagFilter allows a tag to be filtered based on any user-defined
   279  // criteria. If the filter returns true, the tag will be included
   280  // within the filtered results:
   281  //
   282  //	componentFilter := func(tag string) bool {
   283  //		return strings.HasPrefix(tag, "component/")
   284  //	}
   285  type TagFilter func(tag string) bool
   286  
   287  // WithCount limits the number of tags that are returned after all
   288  // processing and filtering has been applied the retrieved list
   289  func WithCount(n int) ListTagsOption {
   290  	return func(opts *listTagsOptions) {
   291  		opts.Count = n
   292  	}
   293  }
   294  
   295  // WithFilters allows the retrieved list of tags to be processed
   296  // with a set of user-defined filters. Each filter is applied in
   297  // turn to the working set. Nil filters are ignored
   298  func WithFilters(filters ...TagFilter) ListTagsOption {
   299  	return func(opts *listTagsOptions) {
   300  		opts.Filters = make([]TagFilter, 0, len(filters))
   301  		for _, filter := range filters {
   302  			if filter == nil {
   303  				continue
   304  			}
   305  
   306  			opts.Filters = append(opts.Filters, filter)
   307  		}
   308  	}
   309  }
   310  
   311  // WithShellGlob limits the number of tags that will be retrieved, by only
   312  // returning tags that match a given [Shell Glob] pattern. If multiple
   313  // patterns are provided, tags will be retrieved if they match against
   314  // a single pattern. All leading and trailing whitespace will be trimmed,
   315  // allowing empty patterns to be ignored
   316  //
   317  // [Shell Glob]: https://tldp.org/LDP/GNU-Linux-Tools-Summary/html/x11655.htm
   318  func WithShellGlob(patterns ...string) ListTagsOption {
   319  	return func(opts *listTagsOptions) {
   320  		opts.ShellGlobs = trimAndPrefix("refs/tags/", patterns...)
   321  	}
   322  }
   323  
   324  // WithSortBy allows the retrieved order of tags to be changed by sorting
   325  // against a reserved [field name]. By default, sorting will always be in
   326  // ascending order. To change this behaviour, prefix a field name with a
   327  // hyphen (-<fieldname>). You can sort tags against multiple fields, but
   328  // this does change the expected behavior. The last field name is treated
   329  // as the primary key for the entire sort. All leading and trailing whitespace
   330  // will be trimmed, allowing empty field names to be ignored
   331  //
   332  // [field name]: https://git-scm.com/docs/git-for-each-ref#_field_names
   333  func WithSortBy(keys ...SortKey) ListTagsOption {
   334  	return func(opts *listTagsOptions) {
   335  		converted := make([]string, len(keys))
   336  		for _, key := range keys {
   337  			if key == Version || key == VersionDesc {
   338  				// Ensure semantic versioning tags are going to be sorted correctly
   339  				opts.SemanticSort = true
   340  			}
   341  
   342  			converted = append(converted, key.String())
   343  		}
   344  
   345  		opts.SortBy = trimAndPrefix("--sort=", converted...)
   346  	}
   347  }
   348  
   349  // Tags retrieves all local tags from the current repository (working directory).
   350  // By default, all tags are retrieved in ascending lexicographic order as implied
   351  // through the [RefName] sort key. Options can be provided to customize retrieval
   352  func (c *Client) Tags(opts ...ListTagsOption) ([]string, error) {
   353  	options := &listTagsOptions{
   354  		Count: disabledNumericOption,
   355  	}
   356  	for _, opt := range opts {
   357  		opt(options)
   358  	}
   359  
   360  	if len(options.ShellGlobs) == 0 {
   361  		options.ShellGlobs = append(options.ShellGlobs, "refs/tags/**")
   362  	}
   363  
   364  	var config string
   365  	if options.SemanticSort {
   366  		config = "-c versionsort.suffix=-"
   367  	}
   368  
   369  	tags, err := c.exec(fmt.Sprintf("git %s for-each-ref %s --format='%%(refname:lstrip=2)' %s --color=never",
   370  		config,
   371  		strings.Join(options.SortBy, " "),
   372  		strings.Join(options.ShellGlobs, " ")))
   373  	if err != nil {
   374  		return nil, err
   375  	}
   376  
   377  	if tags == "" {
   378  		return nil, nil
   379  	}
   380  
   381  	splitTags := strings.Split(tags, "\n")
   382  	splitTags = filterTags(splitTags, options.Filters)
   383  
   384  	if options.Count > disabledNumericOption && options.Count <= len(splitTags) {
   385  		return splitTags[:options.Count], nil
   386  	}
   387  
   388  	return splitTags, nil
   389  }
   390  
   391  func filterTags(tags []string, filters []TagFilter) []string {
   392  	filtered := tags
   393  	for _, filter := range filters {
   394  		keep := make([]string, 0, len(filtered))
   395  		for _, tag := range filtered {
   396  			if filter(tag) {
   397  				keep = append(keep, tag)
   398  			}
   399  		}
   400  
   401  		filtered = keep
   402  	}
   403  
   404  	return filtered
   405  }
   406  
   407  const (
   408  	fingerprintPrefix = "using RSA key "
   409  	signedByPrefix    = "Good signature from \""
   410  )
   411  
   412  // TagVerification contains details about a GPG signed tag
   413  type TagVerification struct {
   414  	// Annotation contains the annotated message associated with
   415  	// the tag
   416  	Annotation string
   417  
   418  	// Ref contains the unique identifier associated with the tag
   419  	Ref string
   420  
   421  	// Signature contains details of the verified GPG signature
   422  	Signature *Signature
   423  
   424  	// Tagger represents a person who created the tag
   425  	Tagger Person
   426  }
   427  
   428  // Signature contains details about a GPG signature
   429  type Signature struct {
   430  	// Fingerprint contains the fingerprint of the private key used
   431  	// during key verification
   432  	Fingerprint string
   433  
   434  	// Author represents the person associated with the private key
   435  	Author *Person
   436  }
   437  
   438  func parsePerson(str string) Person {
   439  	name, email, found := strings.Cut(str, "<")
   440  	if !found {
   441  		return Person{}
   442  	}
   443  	_, email = until(">")(email)
   444  
   445  	return Person{
   446  		Name:  strings.TrimSpace(name),
   447  		Email: email,
   448  	}
   449  }
   450  
   451  func parseSignature(str string) *Signature {
   452  	fingerprint := chompCRLF(str[strings.Index(str, fingerprintPrefix)+len(fingerprintPrefix):])
   453  
   454  	var signedByAuthor *Person
   455  	if strings.Contains(str, signedByPrefix) {
   456  		signedBy := chompUntil(str[strings.Index(str, signedByPrefix)+len(signedByPrefix):], '"')
   457  		author := parsePerson(signedBy)
   458  		signedByAuthor = &author
   459  	}
   460  
   461  	return &Signature{Fingerprint: fingerprint, Author: signedByAuthor}
   462  }
   463  
   464  // VerifyTag validates that a given tag has a valid GPG signature
   465  // and returns details about that signature
   466  func (c *Client) VerifyTag(ref string) (*TagVerification, error) {
   467  	out, err := c.exec("git tag -v " + ref)
   468  	if err != nil {
   469  		return nil, err
   470  	}
   471  
   472  	out, _ = until("tagger ")(out)
   473  
   474  	out, pair := separatedPair(tag("tagger "), ws(), takeUntil(lineEnding))(out)
   475  	tagger := parsePerson(pair[1])
   476  	out, _ = line()(out)
   477  
   478  	out, message := until("gpg: ")(out)
   479  
   480  	return &TagVerification{
   481  		Ref:        ref,
   482  		Tagger:     tagger,
   483  		Annotation: strings.TrimSpace(message),
   484  		Signature:  parseSignature(out),
   485  	}, nil
   486  }
   487  
   488  func chompCRLF(str string) string {
   489  	if idx := strings.Index(str, "\r"); idx > 1 {
   490  		return str[:idx]
   491  	}
   492  
   493  	if idx := strings.Index(str, "\n"); idx > 1 {
   494  		return str[:idx]
   495  	}
   496  	return str
   497  }
   498  
   499  func chompIndent(indent, str string) string {
   500  	return strings.ReplaceAll(str, indent, "")
   501  }
   502  
   503  func chompUntil(str string, until byte) string {
   504  	if idx := strings.IndexByte(str, until); idx > -1 {
   505  		return str[:idx]
   506  	}
   507  	return str
   508  }
   509  
   510  // DeleteTagsOption provides a way for setting specific options during
   511  // a tag deletion operation
   512  type DeleteTagsOption func(*deleteTagsOptions)
   513  
   514  type deleteTagsOptions struct {
   515  	LocalOnly bool
   516  }
   517  
   518  // WithLocalDelete ensures the reference to the tag is deleted from
   519  // the local index only and is not pushed back to the remote. Useful
   520  // if working with temporary tags that need to be removed
   521  func WithLocalDelete() DeleteTagsOption {
   522  	return func(opts *deleteTagsOptions) {
   523  		opts.LocalOnly = true
   524  	}
   525  }
   526  
   527  // DeleteTag a tag both locally and from the remote origin
   528  func (c *Client) DeleteTag(tag string, opts ...DeleteTagsOption) (string, error) {
   529  	return c.DeleteTags([]string{tag}, opts...)
   530  }
   531  
   532  // DeleteTags will attempt to delete a series of tags from the current
   533  // repository and push those deletions back to the remote
   534  func (c *Client) DeleteTags(tags []string, opts ...DeleteTagsOption) (string, error) {
   535  	if len(tags) == 0 {
   536  		return "", nil
   537  	}
   538  
   539  	options := &deleteTagsOptions{}
   540  	for _, opt := range opts {
   541  		opt(options)
   542  	}
   543  
   544  	for _, tag := range tags {
   545  		if _, err := c.exec("git tag -d " + tag); err != nil {
   546  			return "", err
   547  		}
   548  	}
   549  
   550  	if options.LocalOnly {
   551  		return "", nil
   552  	}
   553  
   554  	return c.Push(WithDeleteRefSpecs(tags...))
   555  }