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
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
|
package main
import (
"bufio"
"bytes"
"context"
"fmt"
"io"
"log"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"sync/atomic"
"time"
tea "github.com/charmbracelet/bubbletea"
)
// ============================================================================
// Message Types - Results returned by git operations
// ============================================================================
// BlameUpdateMsg is sent by git blame --incremental goroutine with batches of blame data.
// Sent periodically (every 50 lines) to update the UI incrementally as blame data arrives.
type BlameUpdateMsg struct {
Updates []struct {
LineNumber int // Line number in file (1-based)
CommitHash string // Full commit SHA
Author string // Author name
AuthorTime time.Time // Commit timestamp
}
}
// BlameCompleteMsg signals that git blame has finished processing all lines successfully.
type BlameCompleteMsg struct{}
// BlameErrorMsg indicates git blame encountered an error (malformed output, command failure, etc.).
type BlameErrorMsg struct {
Err error // Error from git blame command or parsing
}
// BlameUntrackedMsg signals that the file is untracked (not in HEAD) so all lines should be marked as uncommitted.
type BlameUntrackedMsg struct{}
// FileLoadErrorMsg indicates file loading failed (file not found, not a git repo, etc.).
type FileLoadErrorMsg struct {
Err error // Error from file reading or git repository detection
}
// DirectoryLoadedMsg signals directory files have been loaded from git log.
type DirectoryLoadedMsg struct {
FileEntries []FileEntry // Files with their last modification metadata
Ctx context.Context
RepoRoot string // Git repository root for running git commands
}
// extendedMsgResult contains the result of checking whether a commit has an extended message body.
// Sent by background git log tasks to update commitExtendedInfo cache.
type extendedMsgResult struct {
commitHash string // Full commit SHA that was checked
subject string // Commit subject line (first line of commit message)
hasExtended bool // Whether commit has body text beyond subject and git attributes
}
// ============================================================================
// Git Repository Operations
// ============================================================================
// getGitRepoRoot finds the git repository root for a given file or directory path.
// Uses "git rev-parse --show-toplevel" to find the repository root directory.
func getGitRepoRoot(filePath string) (string, error) {
// Get directory containing the file (or the directory itself if filePath is a directory)
absPath, err := filepath.Abs(filePath)
if err != nil {
return "", fmt.Errorf("failed to get absolute path: %w", err)
}
// Check if path is a directory
dir := absPath
fileInfo, err := os.Stat(absPath)
if err == nil && !fileInfo.IsDir() {
// It's a file, use its parent directory
dir = filepath.Dir(absPath)
}
// If stat failed or it's a directory, use absPath as-is
cmd := exec.Command("git", "-C", dir, "rev-parse", "--show-toplevel")
output, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("not a git repository: %w", err)
}
return strings.TrimSpace(string(output)), nil
}
// getLatestCommitForFile returns the hash of the most recent commit that modified the file
func getLatestCommitForFile(repoRoot, filename string) (string, error) {
// Convert filename to absolute path first
absPath, err := filepath.Abs(filename)
if err != nil {
return "", fmt.Errorf("failed to get absolute path: %w", err)
}
relPath, err := filepath.Rel(repoRoot, absPath)
if err != nil {
return "", fmt.Errorf("failed to get relative path: %w", err)
}
cmd := exec.Command("git", "-C", repoRoot, "log", "-1", "--format=%H", "--", relPath)
output, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("git log failed: %w", err)
}
return strings.TrimSpace(string(output)), nil
}
// getRelativePathFromRepoRoot converts an absolute file path to a path relative to the git repository root.
// Used for passing file paths to git commands which expect repo-relative paths.
func getRelativePathFromRepoRoot(filePath, repoRoot string) (string, error) {
absPath, err := filepath.Abs(filePath)
if err != nil {
return "", fmt.Errorf("failed to get absolute path: %w", err)
}
relPath, err := filepath.Rel(repoRoot, absPath)
if err != nil {
return "", fmt.Errorf("failed to get relative path: %w", err)
}
return relPath, nil
}
// getCommitDetails fetches full commit details and syntax highlights them.
// Uses "git show --no-patch" to get commit metadata and message, then colorizes with bat.
func getCommitDetails(repoRoot, commitHash string) (ColorizedText, error) {
// Get commit details from git
gitCmd := exec.Command("git", "-C", repoRoot, "show", "--no-patch", "--pretty=format:%H%n%an <%ae>%n%ad%n%n%B", commitHash)
gitOutput, err := gitCmd.Output()
if err != nil {
return ColorizedText{}, err
}
// Try to colorize with bat using gitlog language
colorized, err := ColorizeTextWithBat(string(gitOutput), "gitlog")
if err != nil {
// If bat fails, return plain git output
return NewColorizedText(string(gitOutput)), nil
}
return colorized, nil
}
// ============================================================================
// Git Blame Operations
// ============================================================================
// startIncrementalBlame starts git blame --incremental and returns a channel of messages.
// Runs git blame in a goroutine, parsing output and sending BlameUpdateMsg batches.
// Sends BlameCompleteMsg when done or BlameErrorMsg on error.
func startIncrementalBlame(ctx context.Context, filename string) chan tea.Msg {
msgChan := make(chan tea.Msg, 100)
go func() {
defer close(msgChan)
// Get git repository root
repoRoot, err := getGitRepoRoot(filename)
if err != nil {
msgChan <- BlameErrorMsg{Err: fmt.Errorf("failed to find git repository: %w", err)}
return
}
log.Printf("Git repo root: %s", repoRoot)
// Get relative path from repo root
relPath, err := getRelativePathFromRepoRoot(filename, repoRoot)
if err != nil {
msgChan <- BlameErrorMsg{Err: fmt.Errorf("failed to get relative path: %w", err)}
return
}
log.Printf("Relative path: %s", relPath)
// Run git blame --incremental from the repository root
cmdArgs := []string{"-C", repoRoot, "blame", "--incremental", relPath}
log.Printf("Running: git %s", strings.Join(cmdArgs, " "))
cmd := exec.CommandContext(ctx, "git", cmdArgs...)
stdout, err := cmd.StdoutPipe()
if err != nil {
msgChan <- BlameErrorMsg{Err: fmt.Errorf("failed to create stdout pipe: %w", err)}
return
}
// Capture stderr to a buffer
var stderrBuf bytes.Buffer
cmd.Stderr = &stderrBuf
if err := cmd.Start(); err != nil {
msgChan <- BlameErrorMsg{Err: fmt.Errorf("failed to start git blame: %w", err)}
return
}
// Parse incremental output and send updates
// Only send complete message if command completed successfully (not cancelled)
if parseIncrementalBlame(ctx, stdout, &stderrBuf, cmd, msgChan) {
msgChan <- BlameCompleteMsg{}
}
}()
return msgChan
}
// parseIncrementalBlame parses git blame --incremental output and sends messages.
// Batches updates (50 lines at a time OR 250ms elapsed) for UI responsiveness. Caches commit metadata
// to avoid redundant parsing. Returns true if completed successfully, false if cancelled.
func parseIncrementalBlame(ctx context.Context, stdout io.ReadCloser, stderr *bytes.Buffer, cmd *exec.Cmd, msgChan chan tea.Msg) bool {
scanner := bufio.NewScanner(stdout)
commitCache := make(map[string]struct {
Author string
AuthorTime time.Time
})
batch := BlameUpdateMsg{}
lastSendTime := time.Now() // Track when we last sent a batch for time-based flushing
for scanner.Scan() {
line := scanner.Text()
// Parse blame entry start: <sha> <source-line> <result-line> <num-lines>
fields := strings.Fields(line)
if len(fields) >= 4 && len(fields[0]) == 40 {
commitHash := fields[0]
resultLine, err := strconv.Atoi(fields[2])
if err != nil {
continue
}
numLines, err := strconv.Atoi(fields[3])
if err != nil {
continue
}
var author string
var authorTime time.Time
// Check if we've seen this commit before
if cached, ok := commitCache[commitHash]; ok {
author = cached.Author
authorTime = cached.AuthorTime
} else {
// New commit, read metadata
for scanner.Scan() {
metaLine := scanner.Text()
if after, ok0 := strings.CutPrefix(metaLine, "author "); ok0 {
author = after
} else if after, ok0 := strings.CutPrefix(metaLine, "author-time "); ok0 {
timeStr := after
timestamp, err := strconv.ParseInt(timeStr, 10, 64)
if err == nil {
authorTime = time.Unix(timestamp, 0)
}
} else if strings.HasPrefix(metaLine, "filename ") {
// End of entry
break
}
}
// Cache this commit
commitCache[commitHash] = struct {
Author string
AuthorTime time.Time
}{author, authorTime}
}
// Add updates for all lines in this group to batch
for i := range numLines {
batch.Updates = append(batch.Updates, struct {
LineNumber int
CommitHash string
Author string
AuthorTime time.Time
}{
LineNumber: resultLine + i,
CommitHash: commitHash,
Author: author,
AuthorTime: authorTime,
})
}
// Send batch every 50 lines or 250ms for responsiveness
if len(batch.Updates) > 0 && (len(batch.Updates) >= 50 || time.Since(lastSendTime) >= 250*time.Millisecond) {
msgChan <- batch
batch = BlameUpdateMsg{}
lastSendTime = time.Now()
}
}
}
// Send remaining updates
if len(batch.Updates) > 0 {
msgChan <- batch
}
// Check if command completed successfully
if err := cmd.Wait(); err != nil {
// Check if this was due to context cancellation (expected) or an actual error
if ctx.Err() == nil {
// Actual error - get stderr message
stderrMsg := stderr.String()
// Check if this is an untracked file error (file not in HEAD)
isUntrackedFile := strings.Contains(stderrMsg, "no such path") && strings.Contains(stderrMsg, "in HEAD")
if isUntrackedFile {
// Send special message to mark all lines as untracked/uncommitted
log.Printf("File is untracked (not in HEAD), marking all lines as uncommitted")
msgChan <- BlameUntrackedMsg{}
} else {
// Send error message to UI for other types of errors
if stderrMsg != "" {
msgChan <- BlameErrorMsg{Err: fmt.Errorf("git blame failed: %s", stderrMsg)}
} else {
msgChan <- BlameErrorMsg{Err: fmt.Errorf("git blame failed: %w", err)}
}
}
}
// If context was cancelled, it's expected - don't send error
return false
}
return true
}
// ============================================================================
// Git Directory Operations
// ============================================================================
// startDirectoryLoad loads directory files asynchronously using git log.
// Streams git log output and parses file entries until we have enough or hit limits.
// Returns DirectoryLoadedMsg on success or FileLoadErrorMsg on failure.
func startDirectoryLoad(ctx context.Context, dirpath string, maxFiles int) tea.Cmd {
return func() tea.Msg {
// Check if context is already cancelled
select {
case <-ctx.Done():
return nil
default:
}
// Get git repository root
repoRoot, err := getGitRepoRoot(dirpath)
if err != nil {
return FileLoadErrorMsg{Err: fmt.Errorf("failed to find git repository: %w", err)}
}
// Get relative path from repo root
relPath, err := getRelativePathFromRepoRoot(dirpath, repoRoot)
if err != nil {
return FileLoadErrorMsg{Err: fmt.Errorf("failed to get relative path: %w", err)}
}
// Run git log with machine-readable format
// Format: %at (timestamp) %H (full hash) %an (author name)
// --name-status: Shows M/A/D status and filename
cmdArgs := []string{"-C", repoRoot, "log", "--format=%at %H %an", "--name-status", "-n", "1000", "--", relPath}
log.Printf("Running: git %s", strings.Join(cmdArgs, " "))
cmd := exec.CommandContext(ctx, "git", cmdArgs...)
stdout, err := cmd.StdoutPipe()
if err != nil {
return FileLoadErrorMsg{Err: fmt.Errorf("failed to create stdout pipe: %w", err)}
}
if err := cmd.Start(); err != nil {
return FileLoadErrorMsg{Err: fmt.Errorf("failed to start git log: %w", err)}
}
// Parse streaming output
fileEntries := parseGitLogForFiles(ctx, stdout, maxFiles, 10000)
// Wait for command to finish (or context cancellation)
if err := cmd.Wait(); err != nil {
if ctx.Err() == nil {
// Not cancelled, actual error
log.Printf("git log error: %v", err)
}
}
return DirectoryLoadedMsg{
FileEntries: fileEntries,
Ctx: ctx,
RepoRoot: repoRoot,
}
}
}
// parseGitLogForFiles parses git log output and extracts file entries.
// Stops when: uniqueFiles >= maxFiles OR linesRead > maxLines OR EOF.
// Returns deduplicated file entries sorted by timestamp (most recent first).
func parseGitLogForFiles(ctx context.Context, stdout io.ReadCloser, maxFiles int, maxLines int) []FileEntry {
scanner := bufio.NewScanner(stdout)
fileMap := make(map[string]FileEntry) // Deduplicate by path
var currentTimestamp time.Time
var currentHash string
var currentAuthor string
linesRead := 0
for scanner.Scan() {
select {
case <-ctx.Done():
return fileEntriesToSlice(fileMap)
default:
}
line := scanner.Text()
linesRead++
// Check line limit
if linesRead > maxLines {
log.Printf("Reached max lines (%d), stopping git log parse", maxLines)
break
}
// Empty line separates commits
if line == "" {
continue
}
// Check if this is a format line (timestamp hash author)
// Format lines have exactly 3 fields when split by space (assuming author has no spaces, or we take everything after hash as author)
fields := strings.Fields(line)
if len(fields) >= 3 {
// Try to parse as timestamp
timestamp, err := strconv.ParseInt(fields[0], 10, 64)
if err == nil && len(fields[1]) == 40 {
// This is a format line
currentTimestamp = time.Unix(timestamp, 0)
currentHash = fields[1]
currentAuthor = strings.Join(fields[2:], " ") // Author may have spaces
continue
}
}
// Check if this is a status line (M/A/D <tab> filename)
if len(fields) >= 2 && len(fields[0]) == 1 {
status := fields[0]
// The filename is everything after the status and whitespace
// Use tab as separator since git log --name-status uses tab
parts := strings.SplitN(line, "\t", 2)
if len(parts) != 2 {
continue
}
filepath := parts[1]
// Only add if we haven't seen this file yet (keep most recent)
if _, exists := fileMap[filepath]; !exists {
fileMap[filepath] = FileEntry{
Path: filepath,
CommitHash: currentHash,
Author: currentAuthor,
AuthorTime: currentTimestamp,
Status: status,
}
// Check if we have enough files
if len(fileMap) >= maxFiles {
log.Printf("Reached max files (%d), stopping git log parse", maxFiles)
return fileEntriesToSlice(fileMap)
}
}
}
}
return fileEntriesToSlice(fileMap)
}
// fileEntriesToSlice converts file map to sorted slice (most recent first).
func fileEntriesToSlice(fileMap map[string]FileEntry) []FileEntry {
entries := make([]FileEntry, 0, len(fileMap))
for _, entry := range fileMap {
entries = append(entries, entry)
}
// Sort by timestamp (most recent first)
sort.Slice(entries, func(i, j int) bool {
return entries[i].AuthorTime.After(entries[j].AuthorTime)
})
return entries
}
// ============================================================================
// Git Commit Message Operations
// ============================================================================
// checkExtendedMsgCmd returns a Cmd that checks if a commit has an extended message and fetches the subject.
// Runs "git log" to get subject and body, filters out git attributes (Signed-off-by, etc).
// Increments/decrements runningTasks counter for concurrency throttling.
func checkExtendedMsgCmd(repoRoot, commitHash string, runningTasks *atomic.Int32) tea.Cmd {
return func() tea.Msg {
// Track task lifecycle
runningTasks.Add(1)
defer runningTasks.Add(-1)
// Get commit subject and body in one command
gitCmd := exec.Command("git", "-C", repoRoot, "log", "-1", "--format=%s%n---SUBJECT-BODY-SEP---%n%b", commitHash)
gitOutput, err := gitCmd.Output()
if err != nil {
// If we can't check, assume no extended message
return extendedMsgResult{commitHash: commitHash, subject: "", hasExtended: false}
}
// Parse subject and body
parts := strings.SplitN(string(gitOutput), "\n---SUBJECT-BODY-SEP---\n", 2)
subject := ""
body := ""
if len(parts) > 0 {
subject = strings.TrimSpace(parts[0])
}
if len(parts) > 1 {
body = strings.TrimSpace(parts[1])
}
// Check if body has extended message (non-attribute lines)
// Filter out git attribute lines like "Signed-off-by:", "Co-Authored-By:", etc.
hasExtended := false
if len(body) > 0 {
// Pattern matches lines that are git attributes: word chars/hyphens/underscores followed by colon
attributePattern := regexp.MustCompile(`^[a-zA-Z0-9_-]+:\s*`)
lines := strings.SplitSeq(body, "\n")
for line := range lines {
trimmed := strings.TrimSpace(line)
// Skip empty lines
if trimmed == "" {
continue
}
// If line doesn't match attribute pattern, it's extended content
if !attributePattern.MatchString(trimmed) {
hasExtended = true
break
}
}
}
return extendedMsgResult{commitHash: commitHash, subject: subject, hasExtended: hasExtended}
}
}
|