snapshot of eee7b5cfc0776c68816d7fdaa8af9d7a0e0da2e6 Annotations about the code that implements koment.

internal/provenance/provenance.go

1 // Package provenance captures where an annotation came from: the git state it
2 // was written against, and who wrote it.
3 package provenance
4
5 import (
6 "errors"
7 "os/exec"
8 "strings"
9
10 "github.com/janpuc/koment/internal/store"
11 )
12
13 // ErrNoGit means git could not answer, which is a normal outcome rather than a
14 // failure: koment works in a directory that is not a repository.
15 var ErrNoGit = errors.New("no usable git context")
16
17 // Capture records the commit an annotation was written against. It reports
18 // ErrNoGit rather than guessing when git cannot answer, because a fabricated
19 // commit reference is worse than an absent one.
20 func Capture(root, file string, line, endLine int) (*store.GitContext, error) {
21 commit, err := git(root, "rev-parse", "HEAD")
22 if err != nil {
23 return nil, ErrNoGit
24 }
25 if tracked, err := git(root, "ls-files", "--error-unmatch", file); err != nil || tracked == "" {
26 return nil, ErrNoGit
27 }
28
29 return &store.GitContext{
30 Commit: commit,
31 Path: file,
32 Line: line,
33 EndLine: endLineOrZero(line, endLine),
34 }, nil
35 }
36
37 func endLineOrZero(line, endLine int) int {
38 if endLine == line {
39 return 0
40 }
41 return endLine
42 }
43
44 // HeadCommit is the abbreviated commit a snapshot was taken at. It reports
45 // ErrNoGit rather than an empty string, so a caller that needs to name the
46 // commit cannot mistake "not a repository" for "no commit".
47 func HeadCommit(root string) (string, error) {
48 commit, err := git(root, "rev-parse", "--short", "HEAD")
49 if err != nil || commit == "" {
50 return "", ErrNoGit
51 }
52 return commit, nil
53 }
54
55 // WorktreeIsDirty reports whether the file has uncommitted changes, which makes
56 // the captured commit describe something other than what was annotated.
57 func WorktreeIsDirty(root, file string) bool {
58 changed, err := git(root, "status", "--porcelain", "--", file)
59 return err == nil && changed != ""
60 }
61
62 // TreeIsDirty is the same question asked of the whole repository, which is what
63 // a snapshot of every file has to ask before naming a commit.
64 func TreeIsDirty(root string) bool {
65 changed, err := git(root, "status", "--porcelain")
66 return err == nil && changed != ""
67 }
68
69 // IdentityFromGit reads the committer identity git would use. It is a claim,
70 // not a proof, and says so through its source.
71 func IdentityFromGit(root string) (*store.Author, error) {
72 name, err := git(root, "config", "user.name")
73 if err != nil || name == "" {
74 return nil, errors.New("no git user.name configured; set one or pass --author")
75 }
76 email, emailErr := git(root, "config", "user.email")
77 if emailErr != nil {
78 email = ""
79 }
80
81 return &store.Author{
82 Name: name,
83 Email: email,
84 Kind: store.AuthorHuman,
85 Source: store.FromGitConfig,
86 }, nil
87 }
88
89 // ParseAuthor reads an explicit "Name <email>" identity.
90 func ParseAuthor(text string, kind store.AuthorKind) (*store.Author, error) {
91 name, email, found := strings.Cut(strings.TrimSpace(text), "<")
92 author := &store.Author{
93 Name: strings.TrimSpace(name),
94 Kind: kind,
95 Source: store.FromExplicit,
96 }
97 if found {
98 author.Email = strings.TrimSpace(strings.TrimSuffix(email, ">"))
99 }
100 if author.Name == "" {
101 return nil, errors.New(`--author must look like "Name" or "Name <email>"`)
102 }
103 return author, nil
104 }
105
106 func git(root string, args ...string) (string, error) {
107 command := exec.Command("git", args...)
108 command.Dir = root
109 output, err := command.Output()
110 if err != nil {
111 return "", err
112 }
113 return strings.TrimSpace(string(output)), nil
114 }

Find an annotation

Search file paths, rationale, kinds, and authors.

moveEnter openEsc close