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 }