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
5
import (
6
"errors"
7
"os/exec"
8
"strings"
10
"github.com/koment-dev/koment/internal/store"
11
)
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")
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 := Head(root)
22
if err != nil {
23
return nil, err
24
}
25
if tracked, err := git(root, "ls-files", "--error-unmatch", "--", file); err != nil || tracked == "" {
26
return nil, ErrNoGit
27
}
29
return &store.GitContext{
30
Commit: commit,
31
Path: file,
32
Line: line,
33
EndLine: endLineOrZero(line, endLine),
34
}, nil
35
}
37
func endLineOrZero(line, endLine int) int {
38
if endLine == line {
39
return 0
40
}
41
return endLine
42
}
44
// Head is the full commit a record is stamped against. A record never stores
45
// the abbreviated form, because an abbreviation stops being unique as history
46
// grows.
47
func Head(root string) (string, error) {
48
commit, err := git(root, "rev-parse", "HEAD")
49
if err != nil || commit == "" {
50
return "", ErrNoGit
51
}
52
return commit, nil
53
}
55
// HeadCommit is the abbreviated commit a snapshot was taken at. It reports
56
// ErrNoGit rather than an empty string, so a caller that needs to name the
57
// commit cannot mistake "not a repository" for "no commit".
58
func HeadCommit(root string) (string, error) {
59
commit, err := git(root, "rev-parse", "--short", "HEAD")
60
if err != nil || commit == "" {
61
return "", ErrNoGit
62
}
63
return commit, nil
64
}
66
// WorktreeIsDirty reports whether the file has uncommitted changes, which makes
67
// the captured commit describe something other than what was annotated.
68
func WorktreeIsDirty(root, file string) bool {
69
changed, err := git(root, "status", "--porcelain", "--", file)
70
return err == nil && changed != ""
71
}
73
// TreeIsDirty is the same question asked of the whole repository, which is what
74
// a snapshot of every file has to ask before naming a commit.
75
func TreeIsDirty(root string) bool {
76
changed, err := git(root, "status", "--porcelain")
77
return err == nil && changed != ""
78
}
80
// IdentityFromGit reads the committer identity git would use. It is a claim,
81
// not a proof, and says so through its source.
82
func IdentityFromGit(root string) (*store.Author, error) {
83
name, err := git(root, "config", "user.name")
84
if err != nil || name == "" {
85
return nil, errors.New("no git user.name configured; set one or pass --author")
86
}
87
email, emailErr := git(root, "config", "user.email")
88
if emailErr != nil {
89
email = ""
90
}
92
return &store.Author{
93
Name: name,
94
Email: email,
95
Kind: store.AuthorHuman,
96
Source: store.FromGitConfig,
97
}, nil
98
}
100
// ParseAuthor reads an explicit "Name <email>" identity.
101
func ParseAuthor(text string, kind store.AuthorKind) (*store.Author, error) {
102
name, email, found := strings.Cut(strings.TrimSpace(text), "<")
103
author := &store.Author{
104
Name: strings.TrimSpace(name),
105
Kind: kind,
106
Source: store.FromExplicit,
107
}
108
if found {
109
author.Email = strings.TrimSpace(strings.TrimSuffix(email, ">"))
110
}
111
if author.Name == "" {
112
return nil, errors.New(`--author must look like "Name" or "Name <email>"`)
113
}
114
return author, nil
115
}
117
func git(root string, args ...string) (string, error) {
118
command := exec.Command("git", args...)
119
command.Dir = root
120
output, err := command.Output()
121
if err != nil {
122
return "", err
123
}
124
return strings.TrimSpace(string(output)), nil
125
}