-
Notifications
You must be signed in to change notification settings - Fork 4
Expand file tree
/
Copy pathcommit.go
More file actions
952 lines (844 loc) · 30.2 KB
/
Copy pathcommit.go
File metadata and controls
952 lines (844 loc) · 30.2 KB
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
package nanogit
import (
"context"
"errors"
"fmt"
"sort"
"strings"
"time"
"github.com/grafana/nanogit/log"
"github.com/grafana/nanogit/protocol"
"github.com/grafana/nanogit/protocol/client"
"github.com/grafana/nanogit/protocol/hash"
"github.com/grafana/nanogit/storage"
)
// Author represents the person who created the changes in the commit.
// It includes their name, email, and the timestamp of when they made the changes.
// This is typically the person who wrote the code or made the modifications.
type Author struct {
// Name is the full name of the author (e.g., "John Doe")
Name string
// Email is the email address of the author (e.g., "john@example.com")
Email string
// Time is when the changes were originally made by the author
Time time.Time
}
// Committer represents the person who created the commit object.
// This is often the same as the author, but can be different in cases
// where someone else commits changes on behalf of the author (e.g., via patches).
type Committer struct {
// Name is the full name of the committer (e.g., "Jane Smith")
Name string
// Email is the email address of the committer (e.g., "jane@example.com")
Email string
// Time is when the commit object was created
Time time.Time
}
// Commit represents a Git commit object.
// It contains metadata about the commit, including the author, committer,
// commit message, and references to the parent commits and tree.
type Commit struct {
// Hash is the SHA-1 hash of the commit object
Hash hash.Hash
// Tree is the hash of the root tree object that represents the state
// of the repository at the time of the commit
Tree hash.Hash
// Parent is the hash of the parent commit
// TODO: Merge commits can have multiple parents, but currently only single parent is supported
Parent hash.Hash
// Author is the person who created the changes in the commit
Author Author
// Committer is the person who created the commit object
Committer Committer
// Message is the commit message that describes the changes made in this commit
Message string
}
// Time returns the timestamp when the commit object was created.
// This is equivalent to the committer's timestamp, as the committer is the person
// who actually created the commit object in the repository. For most commits,
// this will be the same as the author time, but they can differ in some workflows.
//
// Returns:
// - time.Time: The timestamp when the commit was created
func (c *Commit) Time() time.Time {
return c.Committer.Time
}
// CommitFile represents a file change between two commits.
// It contains information about how a file was modified, including its path,
// mode, hash, and the type of change (added, modified, deleted, etc.).
type CommitFile struct {
// Path of the file in the head commit
Path string
// OldPath is the original path for renamed files (only set when Status is FileStatusRenamed)
OldPath string
// Mode is the file mode in the head commit (e.g., 100644 for regular files)
Mode uint32
// OldMode is the original file mode in the base commit (for modified files)
OldMode uint32
// Hash is the file hash in the head commit
Hash hash.Hash
// OldHash is the original file hash in the base commit (for modified files)
OldHash hash.Hash
// Type is the Git object type in the head commit (blob for files, tree for directories)
Type protocol.ObjectType
// OldType is the Git object type in the base commit (for modified files)
OldType protocol.ObjectType
// Status indicates the type of file change (added, modified, deleted, etc.)
Status protocol.FileStatus
}
// CompareCommitsOptions configures the behavior of CompareCommits.
type CompareCommitsOptions struct {
// DetectRenames reports delete/add pairs with identical content hashes
// as a single renamed file instead of separate delete and add entries.
// Enable it with WithRenameDetection.
DetectRenames bool
}
// CompareCommitsOption configures CompareCommits behavior.
type CompareCommitsOption func(*CompareCommitsOptions)
// WithRenameDetection enables rename detection in CompareCommits.
// When enabled, deleted files with added files having identical content hashes
// will be reported as a single renamed file (FileStatusRenamed) instead of
// separate delete and add operations.
func WithRenameDetection() CompareCommitsOption {
return func(opts *CompareCommitsOptions) {
opts.DetectRenames = true
}
}
func defaultCompareCommitsOptions() *CompareCommitsOptions {
return &CompareCommitsOptions{
DetectRenames: false,
}
}
// CompareCommits compares two commits and returns the differences between them.
// This method performs a comprehensive diff between two commits, analyzing
// all file changes that occurred between the base and head commits.
//
// The comparison includes:
// - Added files (present in head but not in base)
// - Modified files (different content or mode between base and head)
// - Deleted files (present in base but not in head)
// - Renamed files (when WithRenameDetection option is enabled)
//
// Parameters:
// - ctx: Context for the operation
// - baseCommit: Hash of the base commit (older commit)
// - headCommit: Hash of the head commit (newer commit)
// - opts: Optional configuration options (e.g., WithRenameDetection)
//
// Returns:
// - []CommitFile: Sorted list of file changes between the commits
// - error: Error if either commit cannot be found or comparison fails
//
// Example:
//
// changes, err := client.CompareCommits(ctx, oldCommit, newCommit)
// if err != nil {
// return err
// }
// for _, change := range changes {
// fmt.Printf("%s: %s\n", change.Status, change.Path)
// }
//
// Example with rename detection:
//
// changes, err := client.CompareCommits(ctx, oldCommit, newCommit, nanogit.WithRenameDetection())
// if err != nil {
// return err
// }
// for _, change := range changes {
// if change.Status == protocol.FileStatusRenamed {
// fmt.Printf("Renamed: %s -> %s\n", change.OldPath, change.Path)
// }
// }
func (c *httpClient) CompareCommits(ctx context.Context, baseCommit, headCommit hash.Hash, opts ...CompareCommitsOption) ([]CommitFile, error) {
options := defaultCompareCommitsOptions()
for _, opt := range opts {
if opt == nil {
continue
}
opt(options)
}
logger := log.FromContext(ctx)
logger.Debug("Compare commits",
"base_hash", baseCommit.String(),
"head_hash", headCommit.String(),
"detect_renames", options.DetectRenames)
ctx, _ = storage.FromContextOrInMemory(ctx)
// Fetch both trees concurrently to improve performance
type treeResult struct {
tree *FlatTree
err error
}
baseResult := make(chan treeResult, 1)
headResult := make(chan treeResult, 1)
go func() {
tree, err := c.GetFlatTree(ctx, baseCommit)
baseResult <- treeResult{tree, err}
}()
go func() {
tree, err := c.GetFlatTree(ctx, headCommit)
headResult <- treeResult{tree, err}
}()
baseRes := <-baseResult
if baseRes.err != nil {
return nil, fmt.Errorf("get base tree for commit %s: %w", baseCommit.String(), baseRes.err)
}
headRes := <-headResult
if headRes.err != nil {
return nil, fmt.Errorf("get head tree for commit %s: %w", headCommit.String(), headRes.err)
}
baseTree := baseRes.tree
headTree := headRes.tree
changes := c.compareTrees(baseTree, headTree, options)
logger.Debug("Commits compared",
"base_hash", baseCommit.String(),
"head_hash", headCommit.String(),
"change_count", len(changes))
return changes, nil
}
// Memory-efficient maps storing only hash+mode+type instead of full entries
// This reduces memory overhead by ~60%
//
// Memory optimizations applied:
// - mode: uint16 instead of uint32 (saves 2 bytes per entry)
// Git file modes max out at 0o160000, so uint16 is sufficient
//
// - Struct field ordering optimized (hash first, then mode for alignment)
// - Could use [20]byte for SHA-1 hashes instead of []byte to save slice header (24 bytes -> 20 bytes)
// Additional optimizations considered:
// - Could use byte enum for common modes (0o100644, 0o100755, 0o040000) + overflow field
type entryInfo struct {
hash hash.Hash
mode uint16 // uint16 is sufficient for Git file modes (max 0o160000)
objType protocol.ObjectType // Git object type (blob, tree, etc.)
}
// compareTrees recursively compares two trees and collects changes between them.
// It builds maps of entries from both trees and compares them to identify:
// - Files that exist in the head tree but not in the base tree (added)
// - Files that exist in both trees but have different content or mode (modified)
// - Files that exist in the base tree but not in the head tree (deleted)
//
// If rename detection is enabled in options, deleted files with added files
// having identical content hashes will be consolidated into rename operations.
//
// The function returns a sorted list of changes, with each change containing
// the relevant file information and status.
func (c *httpClient) compareTrees(base, head *FlatTree, opts *CompareCommitsOptions) []CommitFile {
// Estimate capacity: assume 10-20% of files changed
estimatedChanges := (len(base.Entries) + len(head.Entries)) / 10
if estimatedChanges < 10 {
estimatedChanges = 10
}
if estimatedChanges > 1000 {
estimatedChanges = 1000
}
changes := make([]CommitFile, 0, estimatedChanges)
// Pre-allocate maps with capacity to avoid reallocations
inBase := make(map[string]entryInfo, len(base.Entries))
for _, entry := range base.Entries {
inBase[entry.Path] = entryInfo{
hash: entry.Hash,
mode: uint16(entry.Mode),
objType: entry.Type,
}
}
// Single pass through head entries to find added/modified files
inHead := make(map[string]struct{}, len(head.Entries)) // For deleted file lookup
for _, entry := range head.Entries {
inHead[entry.Path] = struct{}{}
if baseInfo, exists := inBase[entry.Path]; !exists {
// File exists in head but not in base - it was added
changes = append(changes, CommitFile{
Path: entry.Path,
Status: protocol.FileStatusAdded,
Mode: entry.Mode,
Hash: entry.Hash,
Type: entry.Type,
})
} else if !baseInfo.hash.Is(entry.Hash) && entry.Type != protocol.ObjectTypeTree {
// File exists in both but has different content - it was modified
changes = append(changes, CommitFile{
Path: entry.Path,
Status: protocol.FileStatusModified,
Mode: entry.Mode,
Hash: entry.Hash,
Type: entry.Type,
OldHash: baseInfo.hash,
OldMode: uint32(baseInfo.mode),
OldType: baseInfo.objType,
})
}
}
// Check for deleted files - only iterate through base entries not in head
for path, baseInfo := range inBase {
if _, exists := inHead[path]; !exists {
// File exists in base but not in head - it was deleted
changes = append(changes, CommitFile{
Path: path,
Status: protocol.FileStatusDeleted,
Mode: uint32(baseInfo.mode),
Hash: baseInfo.hash,
Type: baseInfo.objType,
OldHash: baseInfo.hash,
OldType: baseInfo.objType,
})
}
}
// Rename detection (if enabled)
if opts.DetectRenames {
changes = detectRenames(changes)
}
// Sort changes by path for consistent ordering
sort.Slice(changes, func(i, j int) bool {
return changes[i].Path < changes[j].Path
})
return changes
}
// detectRenames identifies renamed files by matching deleted files with added files
// that have identical content hashes. Returns a new slice with renames consolidated.
//
// When multiple files share the same hash, they are paired one-to-one up to
// min(deletions, additions). Unpaired changes remain as separate add/delete operations.
func detectRenames(changes []CommitFile) []CommitFile {
// Group deleted and added files by their content hash
deletedByHash := make(map[hash.Hash][]CommitFile)
addedByHash := make(map[hash.Hash][]CommitFile)
for _, change := range changes {
switch change.Status {
case protocol.FileStatusDeleted:
deletedByHash[change.OldHash] = append(deletedByHash[change.OldHash], change)
case protocol.FileStatusAdded:
addedByHash[change.Hash] = append(addedByHash[change.Hash], change)
}
}
// Track which paths were paired (simpler than tracking indices)
pairedDeletePaths := make(map[string]bool)
pairedAddPaths := make(map[string]bool)
result := make([]CommitFile, 0, len(changes))
// Find matching pairs and create rename entries
for h, deletedFiles := range deletedByHash {
addedFiles, exists := addedByHash[h]
if !exists {
continue
}
// Sort by path for deterministic pairing
sort.Slice(deletedFiles, func(i, j int) bool {
return deletedFiles[i].Path < deletedFiles[j].Path
})
sort.Slice(addedFiles, func(i, j int) bool {
return addedFiles[i].Path < addedFiles[j].Path
})
// Pair one-to-one up to min(deletions, additions)
pairCount := min(len(deletedFiles), len(addedFiles))
for i := 0; i < pairCount; i++ {
deleted, added := deletedFiles[i], addedFiles[i]
// Mark as paired
pairedDeletePaths[deleted.Path] = true
pairedAddPaths[added.Path] = true
// Create rename entry
result = append(result, CommitFile{
Path: added.Path,
OldPath: deleted.Path,
Mode: added.Mode,
OldMode: deleted.Mode,
Hash: added.Hash,
OldHash: deleted.OldHash,
Type: added.Type,
OldType: deleted.OldType,
Status: protocol.FileStatusRenamed,
})
}
}
// Add back unpaired changes
for _, change := range changes {
switch change.Status {
case protocol.FileStatusDeleted:
if !pairedDeletePaths[change.Path] {
result = append(result, change)
}
case protocol.FileStatusAdded:
if !pairedAddPaths[change.Path] {
result = append(result, change)
}
default:
// Modified, TypeChanged, etc. always included
result = append(result, change)
}
}
return result
}
// GetCommit retrieves a specific commit object from the repository by its hash.
// This method fetches the complete commit information including metadata,
// author, committer, message, and references to parent commits and tree.
//
// Parameters:
// - ctx: Context for the operation
// - hash: SHA-1 hash of the commit to retrieve
//
// Returns:
// - *Commit: The commit object with all metadata
// - error: Error if the commit is not found or cannot be retrieved
//
// Example:
//
// commit, err := client.GetCommit(ctx, commitHash)
// if err != nil {
// return err
// }
// fmt.Printf("Commit by %s: %s\n", commit.Author.Name, commit.Message)
func (c *httpClient) GetCommit(ctx context.Context, commitHash hash.Hash) (*Commit, error) {
return c.getCommit(ctx, commitHash, true)
}
func (c *httpClient) getCommit(ctx context.Context, commitHash hash.Hash, noExtraObjects bool) (*Commit, error) {
logger := log.FromContext(ctx)
logger.Debug("Get commit",
"commit_hash", commitHash.String())
// noExtraObjects=true (the GetCommit public API) is a single-object
// fetch by shape. noExtraObjects=false (NewStagedWriter init) lets
// the server return associated tree objects for cache warmup, so the
// response shape is multi-object and the larger budget applies.
maxBytes := c.limits.SingleObjectFetchMaxBytes
if !noExtraObjects {
maxBytes = c.limits.MultiObjectFetchMaxBytes
}
objects, err := c.Fetch(ctx, client.FetchOptions{
NoProgress: true,
NoBlobFilter: true,
Want: []hash.Hash{commitHash},
Deepen: 1,
Shallow: true,
Done: true,
NoExtraObjects: noExtraObjects,
MaxResponseBytes: maxBytes,
})
if err != nil {
// TODO: handle this at the client level
if strings.Contains(err.Error(), "not our ref") {
return nil, NewObjectNotFoundError(commitHash)
}
return nil, fmt.Errorf("fetch commit %s: %w", commitHash.String(), err)
}
if len(objects) == 0 {
return nil, NewObjectNotFoundError(commitHash)
}
var foundObj *protocol.PackfileObject
for _, obj := range objects {
// Skip tree objects that are included in the response despite the blob:none filter.
// Most Git servers don't support tree:0 filter specification, so we may receive
// recursive tree objects that we need to filter out.
if obj.Type == protocol.ObjectTypeTree {
continue
}
if obj.Type != protocol.ObjectTypeCommit {
return nil, NewUnexpectedObjectTypeError(commitHash, protocol.ObjectTypeCommit, obj.Type)
}
if foundObj != nil {
return nil, NewUnexpectedObjectCountError(1, []*protocol.PackfileObject{foundObj, obj})
}
if obj.Hash.Is(commitHash) {
foundObj = obj
}
}
if foundObj == nil {
return nil, NewObjectNotFoundError(commitHash)
}
commit, err := packfileObjectToCommit(foundObj)
if err != nil {
return nil, fmt.Errorf("parse commit %s: %w", commitHash.String(), err)
}
logger.Debug("Commit found",
"commit_hash", commitHash.String(),
"tree_hash", commit.Tree.String(),
"parent_hash", commit.Parent.String())
return commit, nil
}
func packfileObjectToCommit(commit *protocol.PackfileObject) (*Commit, error) {
if commit.Type != protocol.ObjectTypeCommit {
return nil, errors.New("commit is not a commit")
}
authorTime, err := commit.Commit.Author.Time()
if err != nil {
return nil, fmt.Errorf("parsing author time: %w", err)
}
committerTime, err := commit.Commit.Committer.Time()
if err != nil {
return nil, fmt.Errorf("parsing committer time: %w", err)
}
return &Commit{
Hash: commit.Hash,
Tree: commit.Commit.Tree,
Parent: commit.Commit.Parent,
Author: Author{
Name: commit.Commit.Author.Name,
Email: commit.Commit.Author.Email,
Time: authorTime,
},
Committer: Committer{
Name: commit.Commit.Committer.Name,
Email: commit.Commit.Committer.Email,
Time: committerTime,
},
Message: strings.TrimSpace(commit.Commit.Message),
}, nil
}
// ListCommitsOptions provides filtering and pagination options for listing commits.
// Similar to GitHub's API, it allows limiting results, filtering by path, and pagination.
type ListCommitsOptions struct {
// PerPage specifies the number of commits to return per page
// If 0, defaults to 30. Maximum allowed is 100
PerPage int
// Page specifies which page of results to return (1-based)
// If 0, defaults to 1
Page int
// Path filters commits to only those that affect the specified file or directory path
// If empty, all commits are included
Path string
// Since filters commits to only those created after this time
// If zero, no time filtering is applied
Since time.Time
// Until filters commits to only those created before this time
// If zero, no time filtering is applied
Until time.Time
}
// ListCommits retrieves a list of commits starting from the specified commit,
// walking backwards through the commit history. This method supports filtering
// and pagination similar to GitHub's API, allowing you to traverse repository
// history efficiently.
//
// The method traverses the commit graph starting from the specified commit,
// following parent links to build a chronological list of commits. It supports
// various filters to narrow down results and pagination for large histories.
//
// Parameters:
// - ctx: Context for the operation
// - startCommit: Hash of the commit to start traversal from (typically HEAD)
// - options: Filtering and pagination options
//
// Returns:
// - []Commit: List of commits matching the specified criteria
// - error: Error if traversal fails or commits cannot be retrieved
//
// Example:
//
// // Get the latest 10 commits on main branch
// options := nanogit.ListCommitsOptions{
// PerPage: 10,
// Page: 1,
// }
// commits, err := client.ListCommits(ctx, mainBranchHash, options)
// if err != nil {
// return err
// }
// for _, commit := range commits {
// fmt.Printf("%s: %s\n", commit.Hash.String()[:8], commit.Message)
// }
func (c *httpClient) ListCommits(ctx context.Context, startCommit hash.Hash, options ListCommitsOptions) ([]Commit, error) {
logger := log.FromContext(ctx)
logger.Debug("List commits",
"start_hash", startCommit.String(),
"path_filter", options.Path,
"page", options.Page,
"per_page", options.PerPage)
page, perPage := c.validatePagination(options)
skip := (page - 1) * perPage
collect := perPage
ctx, allObjects := storage.FromContextOrInMemory(ctx)
commitObjs, err := c.collectCommitObjects(ctx, startCommit, options, skip+collect, perPage, allObjects)
if err != nil {
return nil, err
}
commits, err := c.paginateCommits(commitObjs, skip, collect)
if err != nil {
return nil, err
}
logger.Debug("Commits listed",
"start_hash", startCommit.String(),
"total_found", len(commitObjs),
"returned_count", len(commits),
"page", page,
"per_page", perPage)
return commits, nil
}
// validatePagination validates and normalizes pagination parameters
func (c *httpClient) validatePagination(options ListCommitsOptions) (int, int) {
perPage := options.PerPage
if perPage <= 0 {
perPage = 30
}
if perPage > 100 {
perPage = 100
}
page := options.Page
if page <= 0 {
page = 1
}
return page, perPage
}
// collectCommitObjects traverses commit history and collects matching commits
func (c *httpClient) collectCommitObjects(ctx context.Context, startCommit hash.Hash, options ListCommitsOptions, maxCommits, perPage int, allObjects storage.PackfileStorage) ([]*protocol.PackfileObject, error) {
logger := log.FromContext(ctx)
var commitObjs []*protocol.PackfileObject
visited := make(map[string]bool)
queue := []hash.Hash{startCommit}
for len(queue) > 0 && len(commitObjs) < maxCommits {
currentHash := queue[0]
queue = queue[1:]
if visited[currentHash.String()] {
continue
}
visited[currentHash.String()] = true
commit, err := c.fetchCommitObject(ctx, currentHash, perPage, allObjects)
if err != nil {
return nil, err
}
matches, err := c.commitMatchesFilters(ctx, commit, &options, allObjects)
if err != nil {
return nil, fmt.Errorf("check filters for commit %s: %w", currentHash.String(), err)
}
if matches {
commitObjs = append(commitObjs, commit)
logger.Debug("Commit added",
"commit_hash", currentHash.String(),
"total_commits", len(commitObjs))
}
if !commit.Commit.Parent.Is(hash.Zero) {
queue = append(queue, commit.Commit.Parent)
}
}
return commitObjs, nil
}
// fetchCommitObject fetches a single commit object
func (c *httpClient) fetchCommitObject(ctx context.Context, commitHash hash.Hash, perPage int, allObjects storage.PackfileStorage) (*protocol.PackfileObject, error) {
logger := log.FromContext(ctx)
logger.Debug("Process commit",
"commit_hash", commitHash.String())
objects, err := c.Fetch(ctx, client.FetchOptions{
NoProgress: true,
NoBlobFilter: true,
Want: []hash.Hash{commitHash},
Deepen: perPage,
Done: true,
NoExtraObjects: false, // we want to read other commits
MaxResponseBytes: c.limits.MultiObjectFetchMaxBytes,
})
if err != nil {
return nil, fmt.Errorf("fetch commit %s: %w", commitHash.String(), err)
}
commit, ok := objects[commitHash.String()]
if !ok || commit.Type != protocol.ObjectTypeCommit {
commit, ok = allObjects.GetByType(commitHash, protocol.ObjectTypeCommit)
if !ok {
return nil, NewObjectNotFoundError(commitHash)
}
}
return commit, nil
}
// paginateCommits applies pagination to the collected commits
func (c *httpClient) paginateCommits(commitObjs []*protocol.PackfileObject, skip, collect int) ([]Commit, error) {
if skip >= len(commitObjs) {
return []Commit{}, nil
}
end := min(skip+collect, len(commitObjs))
commits := make([]Commit, 0, end-skip)
for _, obj := range commitObjs[skip:end] {
commit, err := packfileObjectToCommit(obj)
if err != nil {
return nil, fmt.Errorf("parse commit %s: %w", obj.Hash.String(), err)
}
commits = append(commits, *commit)
}
return commits, nil
}
// commitMatchesFilters checks if a commit matches the specified filters.
func (c *httpClient) commitMatchesFilters(ctx context.Context, commit *protocol.PackfileObject, options *ListCommitsOptions, allObjects storage.PackfileStorage) (bool, error) {
logger := log.FromContext(ctx)
logger.Debug("Check commit filters",
"commit_hash", commit.Hash.String(),
"path_filter", options.Path)
commitTime, err := commit.Commit.Author.Time()
if err != nil {
return false, fmt.Errorf("parse commit time for %s: %w", commit.Hash.String(), err)
}
if !options.Since.IsZero() && commitTime.Before(options.Since) {
logger.Debug("Commit filtered by time",
"commit_hash", commit.Hash.String(),
"commit_time", commitTime,
"since", options.Since)
return false, nil
}
if !options.Until.IsZero() && commitTime.After(options.Until) {
logger.Debug("Commit filtered by time",
"commit_hash", commit.Hash.String(),
"commit_time", commitTime,
"until", options.Until)
return false, nil
}
if options.Path != "" {
affected, err := c.commitAffectsPath(ctx, commit, options.Path, allObjects)
if err != nil {
logger.Debug("Failed to check path filter",
"commit_hash", commit.Hash.String(),
"path", options.Path,
"error", err)
return false, fmt.Errorf("check path filter: %w", err)
}
if !affected {
logger.Debug("Commit filtered by path",
"commit_hash", commit.Hash.String(),
"path", options.Path)
return false, nil
}
}
return true, nil
}
// commitAffectsPath checks if a commit affects the specified path by comparing with the hash of that path in the parent commit.
// TODO: make it work for merge commits
func (c *httpClient) commitAffectsPath(ctx context.Context, commit *protocol.PackfileObject, path string, allObjects storage.PackfileStorage) (bool, error) {
logger := log.FromContext(ctx)
logger.Debug("Checking if commit affects path",
"commitHash", commit.Hash.String(),
"path", path)
// For the initial commit (no parent), check if the path exists
if commit.Commit.Parent.Is(hash.Zero) {
parentHash, err := c.hashForPath(ctx, commit.Hash, path, allObjects)
if err != nil {
logger.Debug("Failed to get hash for path in initial commit",
"commitHash", commit.Hash.String(),
"path", path,
"error", err)
return false, fmt.Errorf("hash for path: %w", err)
}
affected := !parentHash.Is(hash.Zero)
logger.Debug("Initial commit path check",
"commitHash", commit.Hash.String(),
"path", path,
"affected", affected)
return affected, nil
}
pathHashParent, err := c.hashForPath(ctx, commit.Commit.Parent, path, allObjects)
if err != nil {
logger.Debug("Failed to get hash for path in parent commit",
"commitHash", commit.Commit.Parent.String(),
"path", path,
"error", err)
return false, fmt.Errorf("hash for path: %w", err)
}
pathHashCommit, err := c.hashForPath(ctx, commit.Hash, path, allObjects)
if err != nil {
logger.Debug("Failed to get hash for path in current commit",
"commitHash", commit.Hash.String(),
"path", path,
"error", err)
return false, fmt.Errorf("hash for path: %w", err)
}
affected := !pathHashParent.Is(pathHashCommit)
logger.Debug("Path comparison completed",
"commitHash", commit.Hash.String(),
"path", path,
"parentHash", pathHashParent.String(),
"currentHash", pathHashCommit.String(),
"affected", affected)
return affected, nil
}
// walkPathToTreeHash walks the path to find the tree hash
// if the object is not in the storage, it will be fetched.
// All objects returned by the client will be added to the storage.
// If the object is not found, hash.Zero will be returned.
// If the object is a tree, the hash of the tree will be returned.
// If the object is a blob, the hash of the blob will be returned.
// Otherwise, return an error.
func (c *httpClient) hashForPath(ctx context.Context, commitHash hash.Hash, path string, allObjects storage.PackfileStorage) (hash.Hash, error) {
logger := log.FromContext(ctx)
logger.Debug("Getting hash for path",
"commitHash", commitHash.String(),
"path", path)
commit, ok := allObjects.GetByType(commitHash, protocol.ObjectTypeCommit)
if !ok {
logger.Debug("Commit not in storage, fetching", "commitHash", commitHash.String())
objects, err := c.Fetch(ctx, client.FetchOptions{
NoProgress: true,
NoBlobFilter: true,
Want: []hash.Hash{commitHash},
Shallow: true,
Done: true,
NoExtraObjects: false, // let's read of other tree objects if possible
MaxResponseBytes: c.limits.MultiObjectFetchMaxBytes,
})
if err != nil {
logger.Debug("Failed to fetch commit", "commitHash", commitHash.String(), "error", err)
return hash.Zero, fmt.Errorf("getting commit to get hash for path: %w", err)
}
// Try to find it in the objects we got but if not, get it from the storage
commit, ok = objects[commitHash.String()]
if !ok {
return hash.Zero, NewObjectNotFoundError(commitHash)
}
}
treeHash := commit.Commit.Tree
tree, err := c.GetTree(ctx, treeHash)
if err != nil {
logger.Debug("Failed to get tree", "treeHash", treeHash.String(), "error", err)
return hash.Zero, fmt.Errorf("getting tree: %w", err)
}
// If path is empty, return the tree hash
if path == "" {
// This should never happen with the current use of hashForPath
return treeHash, nil
}
// Split path into components
components := strings.Split(path, "/")
currentTree := tree
// Walk through all components except the last one
for i := 0; i < len(components)-1; i++ {
component := strings.TrimSpace(components[i])
if component == "" {
return hash.Zero, errors.New("path component is empty")
}
// Find the entry in the current tree
var entryHash hash.Hash
var found bool
for _, entry := range currentTree.Entries {
if entry.Name == component {
entryHash = entry.Hash
found = true
break
}
}
if !found {
logger.Debug("Path component not found",
"component", component,
"depth", i+1,
"fullPath", path)
return hash.Zero, nil
}
// Get the next tree for the next iteration
nextTree, err := c.GetTree(ctx, entryHash)
if err != nil {
logger.Debug("Failed to get next tree",
"treeHash", entryHash.String(),
"component", component,
"error", err)
return hash.Zero, fmt.Errorf("getting tree: %w", err)
}
currentTree = nextTree
}
// Handle the final component
finalComponent := strings.TrimSpace(components[len(components)-1])
if finalComponent == "" {
return hash.Zero, errors.New("path component is empty")
}
// Find the final entry in the current tree
for _, entry := range currentTree.Entries {
if entry.Name == finalComponent {
logger.Debug("Found hash for path",
"path", path,
"hash", entry.Hash.String())
return entry.Hash, nil
}
}
// Final component not found
logger.Debug("Final path component not found",
"component", finalComponent,
"fullPath", path)
return hash.Zero, nil
}