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
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
|
package main
import (
"bufio"
"context"
"fmt"
"io"
"os"
"os/exec"
"strconv"
"strings"
"sync"
"time"
tea "github.com/charmbracelet/bubbletea"
)
// Patch-ID caching
var (
patchIdCache sync.Map // Global cache: commit hash -> patch-id (string)
patchIdPending sync.Map // Pending computations: commit hash -> chan string
)
// Nested timing infrastructure using context
type timingContextKey struct{}
// TimingSpan represents a timed operation with parent/child relationships
type TimingSpan struct {
Name string
Start time.Time
Duration time.Duration
Parent *TimingSpan
Children []*TimingSpan
Depth int
}
// NewRootSpan creates a new root timing span
func NewRootSpan(name string) *TimingSpan {
return &TimingSpan{
Name: name,
Start: time.Now(),
Parent: nil,
Children: make([]*TimingSpan, 0),
Depth: 0,
}
}
// WithRootSpan adds a root timing span to the context
func WithRootSpan(ctx context.Context, root *TimingSpan) context.Context {
return context.WithValue(ctx, timingContextKey{}, root)
}
// GetCurrentSpan retrieves the current timing span from context (may be nil)
func GetCurrentSpan(ctx context.Context) *TimingSpan {
if span, ok := ctx.Value(timingContextKey{}).(*TimingSpan); ok {
return span
}
return nil
}
// StartSpan creates a new timing span as a child of the current span
// Returns a new context with this span and a done function to call when finished
func StartSpan(ctx context.Context, name string) (context.Context, func()) {
parent := GetCurrentSpan(ctx)
// If no parent exists, this becomes an orphan span (shouldn't happen in practice)
if parent == nil {
// Create a standalone span (will be lost, but prevents crashes)
span := &TimingSpan{
Name: name,
Start: time.Now(),
Parent: nil,
Children: make([]*TimingSpan, 0),
Depth: 0,
}
newCtx := context.WithValue(ctx, timingContextKey{}, span)
done := func() {
span.Duration = time.Since(span.Start)
}
return newCtx, done
}
// Create child span
span := &TimingSpan{
Name: name,
Start: time.Now(),
Parent: parent,
Children: make([]*TimingSpan, 0),
Depth: parent.Depth + 1,
}
// Add to parent's children
parent.Children = append(parent.Children, span)
// Create new context with this span as current
newCtx := context.WithValue(ctx, timingContextKey{}, span)
// Return done function that records duration and restores parent context
done := func() {
span.Duration = time.Since(span.Start)
}
return newCtx, done
}
// printNestedTimingStats prints timing statistics in a nested tree format
func printNestedTimingStats(root *TimingSpan) {
if root == nil {
return
}
// Finish root span if not already finished
if root.Duration == 0 {
root.Duration = time.Since(root.Start)
}
fmt.Fprintf(os.Stderr, "\n=== NESTED TIMING ===\n")
printSpanTree(root, root.Duration)
}
// aggregatedSpan represents aggregated statistics for spans with the same name
type aggregatedSpan struct {
Name string
TotalDuration time.Duration
Count int
Depth int
RepresentativeSpan *TimingSpan // First span for recursing into children
}
// aggregateChildren aggregates child spans by name and returns the aggregated results
func aggregateChildren(children []*TimingSpan) map[string]*aggregatedSpan {
childAggregates := make(map[string]*aggregatedSpan)
for _, child := range children {
if agg, exists := childAggregates[child.Name]; exists {
agg.TotalDuration += child.Duration
agg.Count++
} else {
childAggregates[child.Name] = &aggregatedSpan{
Name: child.Name,
TotalDuration: child.Duration,
Count: 1,
Depth: child.Depth,
RepresentativeSpan: child,
}
}
}
return childAggregates
}
// printAggregatedChildren prints aggregated child spans with proper formatting and recursion
func printAggregatedChildren(childAggregates map[string]*aggregatedSpan, parentSpan *TimingSpan, rootDuration time.Duration) {
// Sort aggregates by name for consistent output
var sortedNames []string
for name := range childAggregates {
sortedNames = append(sortedNames, name)
}
// Sort alphabetically for deterministic output
for i := 0; i < len(sortedNames)-1; i++ {
for j := i + 1; j < len(sortedNames); j++ {
if sortedNames[i] > sortedNames[j] {
sortedNames[i], sortedNames[j] = sortedNames[j], sortedNames[i]
}
}
}
for _, name := range sortedNames {
agg := childAggregates[name]
childIndent := strings.Repeat(" ", agg.Depth)
childMs := float64(agg.TotalDuration.Microseconds()) / 1000.0
childPercent := 100.0 * float64(agg.TotalDuration) / float64(rootDuration)
childCallInfo := ""
if agg.Count > 1 {
childCallInfo = fmt.Sprintf(" [%dx]", agg.Count)
}
// Check if representative child has children
if len(agg.RepresentativeSpan.Children) > 0 {
// Calculate total exclusive time across all calls
totalExclusive := time.Duration(0)
for _, child := range parentSpan.Children {
if child.Name == agg.Name {
childExclusive := child.Duration
for _, grandchild := range child.Children {
childExclusive -= grandchild.Duration
}
totalExclusive += childExclusive
}
}
exclusiveMs := float64(totalExclusive.Microseconds()) / 1000.0
fmt.Fprintf(os.Stderr, "%s├─ %s: %.2fms (%.1f%%) [excl: %.2fms]%s\n",
childIndent, agg.Name, childMs, childPercent, exclusiveMs, childCallInfo)
// Recursively print children
printSpanTreeChildren(agg.RepresentativeSpan, rootDuration)
} else {
// Leaf node
fmt.Fprintf(os.Stderr, "%s├─ %s: %.2fms (%.1f%%)%s\n",
childIndent, agg.Name, childMs, childPercent, childCallInfo)
}
}
}
// printSpanTree recursively prints a span and its children with aggregation
func printSpanTree(span *TimingSpan, rootDuration time.Duration) {
indent := strings.Repeat(" ", span.Depth)
prefix := ""
if span.Depth > 0 {
prefix = "├─ "
}
ms := float64(span.Duration.Microseconds()) / 1000.0
percent := 100.0 * float64(span.Duration) / float64(rootDuration)
// Aggregate children by name
childAggregates := aggregateChildren(span.Children)
// Calculate exclusive time (time not spent in children)
exclusiveTime := span.Duration
for _, child := range span.Children {
exclusiveTime -= child.Duration
}
exclusiveMs := float64(exclusiveTime.Microseconds()) / 1000.0
// Count how many times this operation was called (number of siblings with same name)
callCount := 0
if span.Parent != nil {
for _, sibling := range span.Parent.Children {
if sibling.Name == span.Name {
callCount++
}
}
}
callInfo := ""
if callCount > 1 {
callInfo = fmt.Sprintf(" [%dx]", callCount)
}
if len(span.Children) > 0 {
// Show both inclusive and exclusive time
fmt.Fprintf(os.Stderr, "%s%s%s: %.2fms (%.1f%%) [excl: %.2fms]%s\n",
indent, prefix, span.Name, ms, percent, exclusiveMs, callInfo)
} else {
// Leaf node - just show total time
fmt.Fprintf(os.Stderr, "%s%s%s: %.2fms (%.1f%%)%s\n",
indent, prefix, span.Name, ms, percent, callInfo)
}
// Print aggregated children
printAggregatedChildren(childAggregates, span, rootDuration)
}
// printSpanTreeChildren prints just the children of a span (helper for aggregation)
func printSpanTreeChildren(span *TimingSpan, rootDuration time.Duration) {
// Aggregate children by name
childAggregates := aggregateChildren(span.Children)
// Print aggregated children
printAggregatedChildren(childAggregates, span, rootDuration)
}
// loadBranches loads all local branches asynchronously
func loadBranches(ctx context.Context) tea.Cmd {
return func() tea.Msg {
branches, err := getBranches(ctx)
if err != nil {
return branchesErrorMsg{err: err}
}
return branchesLoadedMsg{branches: branches}
}
}
// loadBranchDetails loads detailed information (related branches) for a single branch
func loadBranchDetails(ctx context.Context, branch BranchInfo, allBranches []BranchInfo, patchIdCache map[string][]string) tea.Cmd {
return func() tea.Msg {
enriched, updatedCache, err := enrichSingleBranchWithRelated(ctx, branch, allBranches, patchIdCache)
if err != nil {
return branchDetailErrorMsg{err: err}
}
// Fetch recent commits for the branch
recentCommits, err := getRecentCommits(ctx, branch.Name, 10)
if err != nil {
return branchDetailErrorMsg{err: err}
}
// Fetch stashes and attach them to their parent commits
stashMap, err := getStashes(ctx)
if err != nil {
return branchDetailErrorMsg{err: err}
}
// Attach stashes to their corresponding commits
for i := range recentCommits {
if stashes, ok := stashMap[recentCommits[i].Hash]; ok {
recentCommits[i].Stashes = stashes
}
}
enriched.RecentCommits = recentCommits
return branchDetailLoadedMsg{
branch: enriched,
patchIdCache: updatedCache,
}
}
}
// getBranches retrieves all local and remote git branches
func getBranches(ctx context.Context) ([]BranchInfo, error) {
ctx, done := StartSpan(ctx, "getBranches")
defer done()
// Get current branch
currentBranch, err := getCurrentBranch(ctx)
if err != nil {
return nil, fmt.Errorf("getting current branch: %w", err)
}
// Get all branches (local and remote) with metadata
cmd := exec.CommandContext(ctx, "git", "for-each-ref",
"refs/heads/",
"refs/remotes/",
"--format=%(refname:short)|%(objectname)|%(upstream:short)|%(committerdate:iso8601)|%(subject)|%(authorname)")
output, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("running git for-each-ref: %w", err)
}
var branches []BranchInfo
scanner := bufio.NewScanner(strings.NewReader(string(output)))
for scanner.Scan() {
line := scanner.Text()
if line == "" {
continue
}
parts := strings.Split(line, "|")
if len(parts) != 6 {
continue
}
name := parts[0]
commitHash := parts[1]
upstream := parts[2]
dateStr := parts[3]
subject := parts[4]
author := parts[5]
// Parse date
commitDate, err := time.Parse("2006-01-02 15:04:05 -0700", dateStr)
if err != nil {
commitDate = time.Time{}
}
// Detect if this is a remote branch
isRemote := strings.Contains(name, "/")
branch := BranchInfo{
Name: name,
CurrentCommit: commitHash,
IsCurrentBranch: name == currentBranch,
IsRemote: isRemote,
LastCommitMessage: subject,
LastCommitAuthor: author,
LastCommitDate: commitDate,
UpstreamBranch: upstream,
}
branches = append(branches, branch)
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("parsing git output: %w", err)
}
return branches, nil
}
// getCurrentBranch returns the name of the current branch
func getCurrentBranch(ctx context.Context) (string, error) {
cmd := exec.CommandContext(ctx, "git", "branch", "--show-current")
output, err := cmd.Output()
if err != nil {
return "", err
}
return strings.TrimSpace(string(output)), nil
}
// checkoutBranch checks out the specified branch
func checkoutBranch(ctx context.Context, branchName string) tea.Cmd {
return func() tea.Msg {
cmd := exec.CommandContext(ctx, "git", "checkout", "--merge", branchName)
if output, err := cmd.CombinedOutput(); err != nil {
return checkoutErrorMsg{err: fmt.Errorf("checkout failed: %s", string(output))}
}
return checkoutCompleteMsg{branchName: branchName}
}
}
// getMergeBase returns the merge base between two branches
func getMergeBase(ctx context.Context, branch1, branch2 string) (string, error) {
ctx, done := StartSpan(ctx, "getMergeBase")
defer done()
cmd := exec.CommandContext(ctx, "git", "merge-base", branch1, branch2)
output, err := cmd.Output()
if err != nil {
return "", err
}
return strings.TrimSpace(string(output)), nil
}
// getCommitDate returns the commit date for a given commit hash
func getCommitDate(ctx context.Context, commitHash string) (time.Time, error) {
ctx, done := StartSpan(ctx, "getCommitDate")
defer done()
cmd := exec.CommandContext(ctx, "git", "show", "-s", "--format=%cI", commitHash)
output, err := cmd.Output()
if err != nil {
return time.Time{}, fmt.Errorf("getting commit date for %s: %w", commitHash, err)
}
dateStr := strings.TrimSpace(string(output))
commitDate, err := time.Parse(time.RFC3339, dateStr)
if err != nil {
return time.Time{}, fmt.Errorf("parsing commit date '%s': %w", dateStr, err)
}
return commitDate, nil
}
// getRecentCommits returns the last N commits for a branch
func getRecentCommits(ctx context.Context, branchName string, limit int) ([]CommitInfo, error) {
ctx, done := StartSpan(ctx, "getRecentCommits")
defer done()
// Use git log with a format that's easy to parse
// Format: hash|subject|author|date
cmd := exec.CommandContext(ctx, "git", "log",
branchName,
fmt.Sprintf("-%d", limit),
"--format=%H|%s|%an|%cI")
output, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("getting recent commits for %s: %w", branchName, err)
}
var commits []CommitInfo
scanner := bufio.NewScanner(strings.NewReader(string(output)))
for scanner.Scan() {
line := scanner.Text()
if line == "" {
continue
}
parts := strings.SplitN(line, "|", 4)
if len(parts) < 4 {
continue
}
commitDate, err := time.Parse(time.RFC3339, parts[3])
if err != nil {
// Fall back to zero time if parsing fails
commitDate = time.Time{}
}
commits = append(commits, CommitInfo{
Hash: parts[0],
Subject: parts[1],
Author: parts[2],
Date: commitDate,
})
}
return commits, nil
}
// getStashes returns all stashes grouped by their parent commit hash
func getStashes(ctx context.Context) (map[string][]StashInfo, error) {
ctx, done := StartSpan(ctx, "getStashes")
defer done()
// Format: stash ref|stash hash|parent hashes|message
cmd := exec.CommandContext(ctx, "git", "stash", "list",
"--format=%gd|%P|%s")
output, err := cmd.Output()
if err != nil {
// No stashes is not an error
if exitErr, ok := err.(*exec.ExitError); ok {
if len(exitErr.Stderr) == 0 {
return make(map[string][]StashInfo), nil
}
}
return nil, fmt.Errorf("getting stashes: %w", err)
}
stashMap := make(map[string][]StashInfo)
scanner := bufio.NewScanner(strings.NewReader(string(output)))
for scanner.Scan() {
line := scanner.Text()
if line == "" {
continue
}
parts := strings.SplitN(line, "|", 3)
if len(parts) < 3 {
continue
}
ref := parts[0]
parents := strings.Fields(parts[1])
message := parts[2]
if len(parents) == 0 {
continue
}
// First parent is the commit the stash was based on
parentHash := parents[0]
stashMap[parentHash] = append(stashMap[parentHash], StashInfo{
Ref: ref,
Message: message,
})
}
return stashMap, nil
}
// findTrunkBranch attempts to find the main trunk branch for parent detection
// Checks remote branches first (remote/*, origin/*), then local branches
// Returns empty string if no trunk branch found
func findTrunkBranch(branches []BranchInfo) string {
// Priority order: remote/* > origin/* > local
trunkNames := []string{"master", "main", "canon"}
remotePrefixes := []string{"remote/", "origin/", ""}
for _, prefix := range remotePrefixes {
for _, trunkName := range trunkNames {
fullName := prefix + trunkName
for _, branch := range branches {
if branch.Name == fullName {
return fullName
}
}
}
}
return ""
}
// getAncestorCommits returns ancestor commit hashes for a branch, up to limit
// Returns commits in order from newest to oldest (parent, grandparent, etc.)
func getAncestorCommits(ctx context.Context, branch string, limit int) ([]string, error) {
ctx, done := StartSpan(ctx, "getAncestorCommits")
defer done()
cmd := exec.CommandContext(ctx, "git", "rev-list", "--max-count", fmt.Sprintf("%d", limit), branch)
output, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("getting ancestors: %w", err)
}
var commits []string
scanner := bufio.NewScanner(strings.NewReader(string(output)))
for scanner.Scan() {
commit := strings.TrimSpace(scanner.Text())
if commit != "" {
commits = append(commits, commit)
}
}
if err := scanner.Err(); err != nil {
return nil, err
}
return commits, nil
}
// getPatchIdsForLastNCommits returns patch-ids for the last N commits on a branch
// Used for orphan branches without a clear divergence point
// Uses cached worker pool for parallelization across all branches
func getPatchIdsForLastNCommits(ctx context.Context, branch string, limit int) ([]string, error) {
ctx, done := StartSpan(ctx, "getPatchIdsForLastNCommits")
defer done()
// Step 1: Get last N commit hashes using git rev-list
cmd := exec.CommandContext(ctx, "git", "rev-list", "--max-count", fmt.Sprintf("%d", limit), branch)
output, err := cmd.Output()
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
return nil, fmt.Errorf("git rev-list failed for branch '%s': %s", branch, string(exitErr.Stderr))
}
return nil, fmt.Errorf("getting commits for branch '%s': %w", branch, err)
}
// Parse commit hashes
var commits []string
scanner := bufio.NewScanner(strings.NewReader(string(output)))
for scanner.Scan() {
commit := strings.TrimSpace(scanner.Text())
if commit != "" {
commits = append(commits, commit)
}
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("parsing commits for branch '%s': %w", branch, err)
}
if len(commits) == 0 {
return []string{}, nil
}
// Step 2: Get patch-ids using cached worker pool
patchIdMap, err := getPatchIdsCached(ctx, commits)
if err != nil {
return nil, fmt.Errorf("getting patch-ids for branch '%s': %w", branch, err)
}
// Step 3: Return patch-ids in same order as commits
var patchIds []string
for _, commit := range commits {
if patchId, ok := patchIdMap[commit]; ok {
patchIds = append(patchIds, patchId)
}
}
return patchIds, nil
}
// getFirstNPatchIds returns the patch-ids for the first N commits in a branch relative to base
// If limit is -1, returns all patch-ids
// Uses cached worker pool for parallelization across all branches
func getFirstNPatchIds(ctx context.Context, base, branch string, limit int) ([]string, error) {
ctx, done := StartSpan(ctx, "getFirstNPatchIds")
defer done()
// Step 1: Get commit hashes using git rev-list
revListArgs := []string{"rev-list", "--reverse", fmt.Sprintf("%s..%s", base, branch)}
if limit >= 0 {
revListArgs = append(revListArgs, "--max-count", fmt.Sprintf("%d", limit))
}
cmd := exec.CommandContext(ctx, "git", revListArgs...)
output, err := cmd.Output()
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
return nil, fmt.Errorf("git rev-list failed for %s..%s: %s", base[:8], branch, string(exitErr.Stderr))
}
return nil, fmt.Errorf("getting commits for %s..%s: %w", base[:8], branch, err)
}
// Parse commit hashes
var commits []string
scanner := bufio.NewScanner(strings.NewReader(string(output)))
for scanner.Scan() {
commit := strings.TrimSpace(scanner.Text())
if commit != "" {
commits = append(commits, commit)
}
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("parsing commits for %s..%s: %w", base[:8], branch, err)
}
if len(commits) == 0 {
return []string{}, nil
}
// Step 2: Get patch-ids using cached worker pool
patchIdMap, err := getPatchIdsCached(ctx, commits)
if err != nil {
return nil, fmt.Errorf("getting patch-ids for %s..%s: %w", base[:8], branch, err)
}
// Step 3: Return patch-ids in same order as commits
var patchIds []string
for _, commit := range commits {
if patchId, ok := patchIdMap[commit]; ok {
patchIds = append(patchIds, patchId)
}
}
return patchIds, nil
}
// getCommitMessage returns the commit message for a given commit hash
func getCommitMessage(ctx context.Context, commitHash string) (string, error) {
ctx, done := StartSpan(ctx, "getCommitMessage")
defer done()
cmd := exec.CommandContext(ctx, "git", "log", "-1", "--format=%B", commitHash)
output, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("getting commit message for %s: %w", commitHash[:8], err)
}
return strings.TrimSpace(string(output)), nil
}
// getCommitMessagesBatch fetches commit messages for multiple commits in one batch operation
// Uses git cat-file --batch for efficiency (one process instead of N processes)
func getCommitMessagesBatch(ctx context.Context, commitHashes []string) (map[string]string, error) {
ctx, done := StartSpan(ctx, "getCommitMessagesBatch")
defer done()
if len(commitHashes) == 0 {
return make(map[string]string), nil
}
// Prepare input: one commit hash per line
input := strings.Join(commitHashes, "\n") + "\n"
// Run git cat-file --batch --buffer
cmd := exec.CommandContext(ctx, "git", "cat-file", "--batch", "--buffer")
cmd.Stdin = strings.NewReader(input)
output, err := cmd.Output()
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
return nil, fmt.Errorf("git cat-file --batch failed: %s", string(exitErr.Stderr))
}
return nil, fmt.Errorf("running git cat-file --batch: %w", err)
}
// Parse batch output
result := make(map[string]string)
reader := bufio.NewReader(strings.NewReader(string(output)))
for {
// Read header line: <hash> <type> <size>
headerLine, err := reader.ReadString('\n')
if err == io.EOF {
break
}
if err != nil {
return nil, fmt.Errorf("reading header line: %w", err)
}
// Parse header: hash, type, size
parts := strings.Fields(strings.TrimSpace(headerLine))
if len(parts) < 3 {
// Could be "missing" or "ambiguous"
continue
}
hash := parts[0]
objType := parts[1]
sizeStr := parts[2]
size, err := strconv.Atoi(sizeStr)
if err != nil {
return nil, fmt.Errorf("parsing size %s for commit %s: %w", sizeStr, hash[:8], err)
}
// Read exactly 'size' bytes of object content
contentBytes := make([]byte, size)
_, err = io.ReadFull(reader, contentBytes)
if err != nil {
return nil, fmt.Errorf("reading %d bytes for commit %s: %w", size, hash[:8], err)
}
// Read trailing newline
_, err = reader.ReadByte()
if err != nil && err != io.EOF {
return nil, fmt.Errorf("reading trailing newline for commit %s: %w", hash[:8], err)
}
// Extract commit message from commit object (only if it's a commit)
if objType == "commit" {
message := extractCommitMessage(string(contentBytes))
result[hash] = message
}
}
return result, nil
}
// extractCommitMessage extracts the commit message from raw commit object content
// Commit format: headers, blank line, message
func extractCommitMessage(commitContent string) string {
// Find the first blank line - everything after is the commit message
lines := strings.Split(commitContent, "\n")
messageStart := -1
for i, line := range lines {
if line == "" {
messageStart = i + 1
break
}
}
if messageStart == -1 || messageStart >= len(lines) {
return ""
}
// Join remaining lines as the message
message := strings.Join(lines[messageStart:], "\n")
return strings.TrimSpace(message)
}
// getSingleCommitPatchId returns the patch-id for a single commit
// Uses efficient piping: git log --patch | git patch-id --stable
func getSingleCommitPatchId(ctx context.Context, commitHash string) (string, error) {
ctx, done := StartSpan(ctx, "getSingleCommitPatchId")
defer done()
// Use git log --patch piped to git patch-id for single commit
logCmd := exec.CommandContext(ctx, "git", "log", "--patch", "-1", commitHash)
patchIdCmd := exec.CommandContext(ctx, "git", "patch-id", "--stable")
// Pipe git log output to git patch-id
var err error
patchIdCmd.Stdin, err = logCmd.StdoutPipe()
if err != nil {
return "", fmt.Errorf("creating pipe for commit %s: %w", commitHash[:8], err)
}
if err := logCmd.Start(); err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
return "", fmt.Errorf("git log failed for commit %s: %s", commitHash[:8], string(exitErr.Stderr))
}
return "", fmt.Errorf("starting git log for commit %s: %w", commitHash[:8], err)
}
patchOutput, err := patchIdCmd.Output()
if err != nil {
logCmd.Wait()
if exitErr, ok := err.(*exec.ExitError); ok {
return "", fmt.Errorf("git patch-id failed for commit %s: %s", commitHash[:8], string(exitErr.Stderr))
}
return "", fmt.Errorf("calculating patch-id for commit %s: %w", commitHash[:8], err)
}
logCmd.Wait()
// Parse patch-id output: "<patch-id> <commit-hash>"
line := strings.TrimSpace(string(patchOutput))
if line == "" {
return "", fmt.Errorf("no patch-id output for commit %s", commitHash[:8])
}
parts := strings.Fields(line)
if len(parts) < 1 {
return "", fmt.Errorf("invalid patch-id format for commit %s", commitHash[:8])
}
return parts[0], nil
}
// getPatchIdsCached fetches patch-ids for multiple commits using cache and request coalescing
// Multiple goroutines requesting the same commit will block on the same computation
// Uses "single-flight" pattern: only one goroutine computes each commit, others wait
func getPatchIdsCached(ctx context.Context, commits []string) (map[string]string, error) {
ctx, done := StartSpan(ctx, "getPatchIdsCached")
defer done()
results := make(map[string]string)
for _, commit := range commits {
// Check cache first
if val, ok := patchIdCache.Load(commit); ok {
results[commit] = val.(string)
continue
}
// Try to be the one who computes this commit
// We create a channel that will be closed when computation completes
ch := make(chan struct{})
actual, loaded := patchIdPending.LoadOrStore(commit, ch)
if loaded {
// Someone else is already computing it, wait for completion
<-actual.(chan struct{})
// Now read from cache (the computing goroutine stored it there)
if val, ok := patchIdCache.Load(commit); ok {
results[commit] = val.(string)
} else {
results[commit] = "" // Error case
}
} else {
// We're the first, spawn goroutine to compute
go func(c string, doneCh chan struct{}) {
patchId, err := getSingleCommitPatchId(ctx, c)
if err == nil {
patchIdCache.Store(c, patchId)
}
close(doneCh) // Broadcast completion to all waiters
patchIdPending.Delete(c)
}(commit, ch)
// Wait for our own computation to complete
<-ch
// Read from cache
if val, ok := patchIdCache.Load(commit); ok {
results[commit] = val.(string)
} else {
results[commit] = "" // Error case
}
}
}
return results, nil
}
// getSingleCommitPatchIdCached is a convenience wrapper for getting a single commit's patch-id with caching
func getSingleCommitPatchIdCached(ctx context.Context, commitHash string) (string, error) {
results, err := getPatchIdsCached(ctx, []string{commitHash})
if err != nil {
return "", err
}
if patchId, ok := results[commitHash]; ok {
return patchId, nil
}
return "", fmt.Errorf("patch-id not found for commit %s", commitHash[:8])
}
// getMissingCommits returns commit hashes that exist in 'from' but not in 'to'
// This is equivalent to git rev-list to..from
func getMissingCommits(ctx context.Context, from, to string) ([]string, error) {
ctx, done := StartSpan(ctx, "getMissingCommits")
defer done()
// git rev-list to..from shows commits in 'from' but not in 'to'
cmd := exec.CommandContext(ctx, "git", "rev-list", fmt.Sprintf("%s..%s", to, from))
output, err := cmd.Output()
if err != nil {
// Empty output is valid (no missing commits)
if exitErr, ok := err.(*exec.ExitError); ok && len(exitErr.Stderr) == 0 {
return []string{}, nil
}
return nil, fmt.Errorf("getting missing commits %s..%s: %w", to[:8], from[:8], err)
}
var commits []string
scanner := bufio.NewScanner(strings.NewReader(string(output)))
for scanner.Scan() {
commit := strings.TrimSpace(scanner.Text())
if commit != "" {
commits = append(commits, commit)
}
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("parsing missing commits: %w", err)
}
return commits, nil
}
|