-
Notifications
You must be signed in to change notification settings - Fork 1
/
Copy pathtoday.go
270 lines (216 loc) · 7.84 KB
/
today.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
package main
import (
"flag"
"fmt"
"os"
"path/filepath"
"strings"
"syscall"
"time"
"github.com/fatih/color"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing/object"
)
// Since is a flag used to control the amount of time to look back in a repository for commits.
// The provided time units must be parseable via time.ParseDuration and it defaults to 12 hours.
var since time.Duration
// Short is a flag for condensing larger messages, this will only display the first line of a commit message.
// This is ideal for repositories where commits may contain longer explanations or reasoning behind the change, but you are familiar with it already and only need a high-level overview.
var short bool
// Author is a 'contains' match on the author of a commit. For example, searching for 'John' will display all commits by the author name '*John*'.
var author string
// Colour will toggle a colourised output for the application.
var colour bool
var (
red *color.Color = color.New(color.FgRed)
green *color.Color = color.New(color.FgGreen)
)
// displayOutput is a convenience function for outputting to the console, whilst taking into
// consideration the colour flag. It is expected that the given message is already formatted in the appropriate way.
func displayOutput(msg string, outputColour *color.Color, colourEnabled bool) {
if colourEnabled {
color.Set()
outputColour.Print(msg)
color.Unset()
return
}
fmt.Print(msg)
}
// validatePaths is used to ensure that only directories that are tracked by git are passed into the application,
// as these directories are used to track the work which was been done, via commit messages.
func validatePaths(paths []string) error {
for _, p := range paths {
_, err := os.Stat(p)
if err != nil {
return fmt.Errorf("expected directory, but got %s\n", p)
}
// Use git to read commit logs for general purpose guide on work done for the day.
gitDir := fmt.Sprintf("%s/.git", p)
_, err = os.Stat(gitDir)
if err != nil {
return fmt.Errorf("%s is not tracked by git", p)
}
}
return nil
}
// openGitDir is used to open a validated directory which is tracked by git, this returns information
// about the repository that is being tracked.
func openGitDir(dir string) (*git.Repository, error) {
repo, err := git.PlainOpen(dir)
if err != nil {
return nil, err
}
return repo, nil
}
// getRepositories will return the git repository definition given a list of directory paths.
func getRepositories(dirs []string) ([]*git.Repository, error) {
var repos []*git.Repository
for _, dir := range dirs {
repo, err := openGitDir(dir)
if err != nil {
fmt.Printf("Unable to open local directory '%s': %s\n", dir, err)
return nil, err
}
repos = append(repos, repo)
}
return repos, nil
}
// containsAuthor will return whether the commit contains the provided author.
func containsAuthor(c *object.Commit, author string) bool {
return strings.Contains(c.Author.Name, author)
}
// getBaseDirectoryName is a simple wrapper for getting the base of the provided directory
// with added benefit of using the correct current directory when provided.
func getBaseDirectoryName(p string) (string, error) {
if p == "./" || p == "." {
currentDir, err := syscall.Getwd()
if err != nil {
return "", err
}
return filepath.Base(currentDir), nil
}
return filepath.Base(p), nil
}
// getCommitMessages is used to map together the repository to a list of valid messages, dependent on the flags that were passed.
func getCommitMessages(dirToRepo map[string]*git.Repository, author string, short bool, since time.Duration) (map[string][]string, error) {
msgs := make(map[string][]string)
for dir, repo := range dirToRepo {
sanitisedDir, err := getBaseDirectoryName(dir)
if err != nil {
return nil, err
}
// Initialise map before populating messages.
// This largely comes in handy when a directory is passed where there are no messages in the given 'since' range
// so it can be displayed as no messages, as opposed to no output whatsoever.
msgs[sanitisedDir] = []string{}
ref, err := repo.Head()
if err != nil {
return nil, err
}
cIter, err := repo.Log(&git.LogOptions{From: ref.Hash()})
if err != nil {
return nil, err
}
now := time.Now().UTC()
currentCommit, err := cIter.Next()
if err != nil {
return nil, err
}
commitTime := currentCommit.Author.When.UTC()
// The UTC time of now - the provided 'since' value.
// We use time.Add with a negative number to subtract here, rather than time.Sub, so that we produce a time.Time value to compare, not a time.Duration.
timeSince := now.Add(-since)
// Only iterate whilst we meet the criteria of the current commit being before our `since` value.
// Once we have reached the commit where this is not the case, we can stop as commits are in chronological order.
// Note: We are not accounting for any `--date` manipulation, this will simply use the timestamp it currently has,
// meaning that it can stop prematurely if it no longer matches the loop clause.
for commitTime.After(timeSince) {
// Get the next commit ready here so avoid needing to duplicate logic branches
// when needing to skip commits.
// TODO: Can we tidy this up in an elegant way?
nextCommit, err := cIter.Next()
if err != nil {
return nil, err
}
// Skip commits which do not contain the author name provided
if author != "" && !containsAuthor(currentCommit, author) {
currentCommit = nextCommit
commitTime = currentCommit.Author.When.UTC()
continue
}
if short {
// Multi-line commit messages span over newlines, taking the text before this is the main message and the rest can be discarded.
firstLine, _, _ := strings.Cut(currentCommit.Message, "\n")
msgs[sanitisedDir] = append(msgs[sanitisedDir], firstLine)
} else {
msgs[sanitisedDir] = append(msgs[sanitisedDir], currentCommit.Message)
}
currentCommit = nextCommit
commitTime = currentCommit.Author.When.UTC()
}
}
return msgs, nil
}
func printUsage() {
var executableName string
fullPath, err := os.Executable()
if err != nil {
executableName = "today"
} else {
executableName = filepath.Base(fullPath)
}
fmt.Fprintf(os.Stderr, "Usage: %s [options] git_directory...\n", executableName)
flag.PrintDefaults()
}
func main() {
flag.Usage = printUsage
flag.BoolVar(&short, "short", false, "display only the first line of commit messages")
flag.BoolVar(&colour, "colour", false, "Display colourised output")
flag.DurationVar(&since, "since", 12*time.Hour, "how far back to check for commits from now")
flag.StringVar(&author, "author", "", "display commits from a particular author")
flag.Parse()
if flag.NArg() == 0 {
fmt.Fprintln(os.Stderr, "Missing mandatory argument: git_directory")
printUsage()
os.Exit(1)
}
// Directories must be tracked by git so that we can read commit messages and use this
// as a guide on work done throughout a time period.
err := validatePaths(flag.Args())
if err != nil {
fmt.Println(err)
os.Exit(2)
}
dirs := flag.Args()
repos, err := getRepositories(dirs)
if err != nil {
fmt.Println(err)
return
}
dirToRepo := make(map[string]*git.Repository)
for i := 0; i < len(dirs); i++ {
dirToRepo[dirs[i]] = repos[i]
}
msgs, err := getCommitMessages(dirToRepo, author, short, since)
if err != nil {
fmt.Println(err)
return
}
for dir, commitMsgs := range msgs {
// When colour is enabled:
// Red = no commits to display for the directory
// Green = there are commits to display
if len(commitMsgs) == 0 {
displayOutput(dir+"\n", red, colour)
displayOutput("\tThere are no messages for this directory.\n\n", red, colour)
continue
}
displayOutput(dir+"\n", green, colour)
for _, msg := range commitMsgs {
formatMsg := fmt.Sprintf("\t%s\n", msg)
displayOutput(formatMsg, green, colour)
}
// Simple newline before the next entry.
fmt.Println()
}
}