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/janpuc/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 := 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
}
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
// 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
}
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
}
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
}
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
}
81
return &store.Author{
82
Name: name,
83
Email: email,
84
Kind: store.AuthorHuman,
85
Source: store.FromGitConfig,
86
}, nil
87
}
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
}
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
}