• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

gittuf / gittuf / 30044417887

23 Jul 2026 08:59PM UTC coverage: 78.204% (+0.06%) from 78.143%
30044417887

push

github

web-flow
Merge pull request #1500 from gittuf/fix-tests

pkg: Fix gitinterface test

1 of 1 new or added line in 1 file covered. (100.0%)

31 existing lines in 4 files now uncovered.

12834 of 16411 relevant lines covered (78.2%)

36.49 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

82.94
/pkg/gitinterface/tree.go
1
// Copyright The gittuf Authors
2
// SPDX-License-Identifier: Apache-2.0
3

4
package gitinterface
5

6
import (
7
        "bytes"
8
        "errors"
9
        "fmt"
10
        "os"
11
        "path"
12
        "strings"
13
)
14

15
var (
16
        ErrTreeDoesNotHavePath             = errors.New("tree does not have requested path")
17
        ErrCopyingBlobIDsDoNotMatch        = errors.New("blob ID in local repository does not match upstream repository")
18
        ErrCannotCreateSubtreeIntoRootTree = errors.New("subtree path target cannot be empty or root of tree")
19
)
20

21
func (r *Repository) EmptyTree() (Hash, error) {
2✔
22
        treeID, err := r.executor("hash-object", "-t", "tree", "--stdin").executeString()
2✔
23
        if err != nil {
2✔
24
                return ZeroHash, fmt.Errorf("unable to hash empty tree: %w", err)
×
25
        }
×
26

27
        hash, err := NewHash(treeID)
2✔
28
        if err != nil {
2✔
29
                return ZeroHash, fmt.Errorf("empty tree has invalid Git ID: %w", err)
×
30
        }
×
31

32
        return hash, nil
2✔
33
}
34

35
// GetPathIDInTree returns the Git ID pointed to by the path in the specified
36
// tree if the path exists. If not, a corresponding error is returned.  For
37
// example, if the tree contains a single blob `foo/bar/baz`, querying the ID
38
// for `foo/bar/baz` will return the blob ID for baz. Querying the ID for
39
// `foo/bar` will return the intermediate tree ID for bar, while querying for
40
// `foo/baz` will return an error.
41
func (r *Repository) GetPathIDInTree(treePath string, treeID Hash) (Hash, error) {
118✔
42
        treePath = strings.TrimSuffix(treePath, "/")
118✔
43
        components := strings.Split(treePath, "/")
118✔
44

118✔
45
        currentTreeID := treeID
118✔
46
        for len(components) != 0 {
297✔
47
                items, err := r.GetTreeItems(currentTreeID)
179✔
48
                if err != nil {
181✔
49
                        return nil, err
2✔
50
                }
2✔
51

52
                entryID, has := items[components[0]]
177✔
53
                if !has {
180✔
54
                        return nil, fmt.Errorf("%w: %s", ErrTreeDoesNotHavePath, treePath)
3✔
55
                }
3✔
56

57
                currentTreeID = entryID
174✔
58
                components = components[1:]
174✔
59
        }
60

61
        return currentTreeID, nil
113✔
62
}
63

64
// GetTreeItems returns the items in a specified Git tree without recursively
65
// expanding subtrees.
66
func (r *Repository) GetTreeItems(treeID Hash) (map[string]Hash, error) {
185✔
67
        // From Git 2.36, we can use --format here. However, it appears a not
185✔
68
        // insignificant number of developers are still on Git 2.34.1, a side effect
185✔
69
        // of being on Ubuntu 22.04. 22.04 is still widely used in WSL2 environments.
185✔
70
        // So, we're removing --format and parsing the output differently to handle
185✔
71
        // the extra information for each entry we don't need.
185✔
72
        stdOut, err := r.executor("ls-tree", treeID.String()).executeString()
185✔
73
        if err != nil {
189✔
74
                return nil, fmt.Errorf("unable to enumerate items in tree '%s': %w", treeID.String(), err)
4✔
75
        }
4✔
76

77
        if stdOut == "" {
183✔
78
                return nil, nil // alternatively, just check if treeID is empty tree?
2✔
79
        }
2✔
80

81
        entries := strings.Split(stdOut, "\n")
179✔
82
        if len(entries) == 0 {
179✔
83
                return nil, nil
×
84
        }
×
85

86
        items := map[string]Hash{}
179✔
87
        for _, entry := range entries {
537✔
88
                // Without --format, the output is in the following format:
358✔
89
                // <mode> SP <type> SP <object> TAB <file>
358✔
90
                // From: https://git-scm.com/docs/git-ls-tree/2.34.1#_output_format
358✔
91

358✔
92
                entrySplit := strings.Split(entry, " ")
358✔
93
                // entrySplit[0] is <mode> -- discard
358✔
94
                // entrySplit[1] is <type> -- discard
358✔
95
                // entrySplit[2] is <object> TAB <file> -- keep
358✔
96
                entrySplit = strings.Split(entrySplit[2], "\t")
358✔
97

358✔
98
                // <object> is really the object ID
358✔
99
                hash, err := NewHash(entrySplit[0])
358✔
100
                if err != nil {
358✔
101
                        return nil, fmt.Errorf("invalid Git ID '%s' for path '%s': %w", entrySplit[0], entrySplit[1], err)
×
102
                }
×
103

104
                items[entrySplit[1]] = hash
358✔
105
        }
106

107
        return items, nil
179✔
108
}
109

110
// GetAllFilesInTree returns all filepaths and the corresponding blob hashes in
111
// the specified tree.
112
func (r *Repository) GetAllFilesInTree(treeID Hash) (map[string]Hash, error) {
46✔
113
        // From Git 2.36, we can use --format here. However, it appears a not
46✔
114
        // insignificant number of developers are still on Git 2.34.1, a side effect
46✔
115
        // of being on Ubuntu 22.04. 22.04 is still widely used in WSL2 environments.
46✔
116
        // So, we're removing --format and parsing the output differently to handle
46✔
117
        // the extra information for each entry we don't need.
46✔
118
        stdOut, err := r.executor("ls-tree", "-r", treeID.String()).executeString()
46✔
119
        if err != nil {
52✔
120
                return nil, fmt.Errorf("unable to enumerate all files in tree: %w", err)
6✔
121
        }
6✔
122

123
        if stdOut == "" {
40✔
124
                return nil, nil // alternatively, just check if treeID is empty tree?
×
125
        }
×
126

127
        entries := strings.Split(stdOut, "\n")
40✔
128
        if len(entries) == 0 {
40✔
129
                return nil, nil
×
130
        }
×
131

132
        files := map[string]Hash{}
40✔
133
        for _, entry := range entries {
135✔
134
                // Without --format, the output is in the following format:
95✔
135
                // <mode> SP <type> SP <object> TAB <file>
95✔
136
                // From: https://git-scm.com/docs/git-ls-tree/2.34.1#_output_format
95✔
137

95✔
138
                entrySplit := strings.Split(entry, " ")
95✔
139
                // entrySplit[0] is <mode> -- discard
95✔
140
                // entrySplit[1] is <type> -- discard
95✔
141
                // entrySplit[2] is <object> TAB <file> -- keep
95✔
142
                entrySplit = strings.Split(entrySplit[2], "\t")
95✔
143

95✔
144
                // <object> is really the object ID
95✔
145
                hash, err := NewHash(entrySplit[0])
95✔
146
                if err != nil {
95✔
147
                        return nil, fmt.Errorf("invalid Git ID '%s' for path '%s': %w", entrySplit[0], entrySplit[1], err)
×
148
                }
×
149

150
                files[entrySplit[1]] = hash
95✔
151
        }
152

153
        return files, nil
40✔
154
}
155

156
// GetMergeTree computes the merge tree for the commits passed in. The tree is
157
// not written to the object store. Assuming a typical merge workflow, the first
158
// commit is expected to be the tip of the base branch. As such, the second
159
// commit is expected to be merged into the first. If the first commit is zero,
160
// the second commit's tree is returned.
161
func (r *Repository) GetMergeTree(commitAID, commitBID Hash) (Hash, error) {
5✔
162
        if err := r.ensureIsCommit(commitBID); err != nil {
6✔
163
                return ZeroHash, err
1✔
164
        }
1✔
165

166
        if commitAID.IsZero() {
5✔
167
                // fast-forward merge -> use tree ID from commitB
1✔
168
                return r.GetCommitTreeID(commitBID)
1✔
169
        }
1✔
170

171
        // Only commitB needs to be non-zero, we can allow fast-forward merges when
172
        // the base commit is zero. So, check this only after above
173
        if err := r.ensureIsCommit(commitAID); err != nil {
4✔
174
                return ZeroHash, err
1✔
175
        }
1✔
176

177
        stdOut, err := r.executor("merge-tree", commitAID.String(), commitBID.String()).executeString()
2✔
178
        if err != nil {
3✔
179
                return ZeroHash, fmt.Errorf("unable to compute merge tree: %w", err)
1✔
180
        }
1✔
181

182
        treeHash, err := NewHash(stdOut)
1✔
183
        if err != nil {
1✔
184
                return ZeroHash, fmt.Errorf("invalid merge tree ID: %w", err)
×
185
        }
×
186

187
        return treeHash, nil
1✔
188
}
189

190
// CreateSubtreeFromUpstreamRepository accepts an upstream repository handler
191
// and a commit ID in the upstream repository. This information is used to copy
192
// the entire contents of the commit's Git tree into the specified localPath in
193
// the localRef. A new commit is added to localRef with the changes made to
194
// localPath. localPath represents a directory path where the changes are copied
195
// to. Existing items in that directory are overwritten in the subsequently
196
// created commit in localRef. localPath must be specified, if left blank (say
197
// to imply copying into the root directory of the downstream repository),
198
// creating a subtree will fail.
199
func (r *Repository) CreateSubtreeFromUpstreamRepository(upstream *Repository, upstreamCommitID Hash, upstreamPath, localRef, localPath string) (Hash, error) {
39✔
200
        if localPath == "" {
40✔
201
                return nil, ErrCannotCreateSubtreeIntoRootTree
1✔
202
        }
1✔
203
        currentTip, err := r.GetReference(localRef)
38✔
204
        if err != nil {
51✔
205
                if !errors.Is(err, ErrReferenceNotFound) {
13✔
206
                        return nil, err
×
207
                }
×
208
        }
209

210
        entries := []TreeEntry{}
38✔
211
        if !currentTip.IsZero() {
63✔
212
                currentRefTree, err := r.GetCommitTreeID(currentTip)
25✔
213
                if err != nil {
25✔
214
                        return nil, err
×
215
                }
×
216
                currentFiles, err := r.GetAllFilesInTree(currentRefTree)
25✔
217
                if err != nil {
25✔
218
                        return nil, err
×
219
                }
×
220

221
                // Ignore entries for `localPath` to account for upstream deletions
222
                // If localPath is foo/, we want to ignore all items under foo/
223
                // If localPath is foo, we want to ignore all items under foo/
224
                // If localPath is foo, we DO NOT want to remove all items under foobar/
225
                // So, add the / suffix if necessary to localPath
226
                if !strings.HasSuffix(localPath, "/") {
38✔
227
                        localPath += "/"
13✔
228
                }
13✔
229

230
                // Create list of TreeEntry objects representing all blobs except those
231
                // currently under localPath
232
                for filePath, blobID := range currentFiles {
87✔
233
                        if !strings.HasPrefix(filePath, localPath) {
112✔
234
                                entries = append(entries, NewEntryBlob(filePath, blobID))
50✔
235
                        }
50✔
236
                }
237
        }
238

239
        // Remove trailing "/" now
240
        localPath = strings.TrimSuffix(localPath, "/")
38✔
241

38✔
242
        treeID, err := upstream.GetCommitTreeID(upstreamCommitID)
38✔
243
        if err != nil {
38✔
244
                return nil, err
×
245
        }
×
246

247
        if upstreamPath != "" {
63✔
248
                // If upstreamPath is empty, then the entire tree is copied over,
25✔
249
                // otherwise, identify the subtree to copy over
25✔
250
                treeID, err = upstream.GetPathIDInTree(upstreamPath, treeID)
25✔
251
                if err != nil {
26✔
252
                        return nil, err
1✔
253
                }
1✔
254
        }
255

256
        if r.HasObject(treeID) {
71✔
257
                // Use existing intermediate tree
34✔
258
                entries = append(entries, NewEntryTree(localPath, treeID))
34✔
259
        } else {
37✔
260
                // We have to create the intermediate tree for localPath
3✔
261
                filesToCopy, err := upstream.GetAllFilesInTree(treeID)
3✔
262
                if err != nil {
3✔
263
                        return nil, err
×
264
                }
×
265

266
                for blobPath, blobID := range filesToCopy {
12✔
267
                        // if blob already exists, we don't need to carry out expensive
9✔
268
                        // read/write
9✔
269
                        if !r.HasObject(blobID) {
9✔
270
                                blob, err := upstream.ReadBlob(blobID)
×
271
                                if err != nil {
×
272
                                        return nil, err
×
273
                                }
×
274
                                localBlobID, err := r.WriteBlob(blob)
×
275
                                if err != nil {
×
276
                                        return nil, err
×
277
                                }
×
278
                                if !localBlobID.Equal(blobID) {
×
279
                                        return nil, ErrCopyingBlobIDsDoNotMatch
×
280
                                }
×
281
                        }
282

283
                        // add blob to entries, with the path including the localPath prefix
284
                        entries = append(entries, NewEntryBlob(path.Join(localPath, blobPath), blobID))
9✔
285
                }
286
        }
287

288
        treeBuilder := NewTreeBuilder(r)
37✔
289
        newTreeID, err := treeBuilder.WriteTreeFromEntries(entries)
37✔
290
        if err != nil {
37✔
291
                return nil, err
×
292
        }
×
293

294
        commitID, err := r.Commit(newTreeID, localRef, fmt.Sprintf("Update contents of '%s'\n", localPath), false)
37✔
295
        if err != nil {
37✔
296
                return nil, err
×
297
        }
×
298

299
        if !r.IsBare() {
74✔
300
                head, err := r.GetSymbolicReferenceTarget("HEAD")
37✔
301
                if err != nil {
37✔
302
                        return nil, err
×
303
                }
×
304
                if head == localRef {
38✔
305
                        worktree := strings.TrimSuffix(r.gitDirPath, ".git") // TODO: this doesn't support detached git dir
1✔
306

1✔
307
                        if _, err := r.executor("restore", "--staged", localPath).withDir(worktree).executeString(); err != nil {
1✔
308
                                return nil, err
×
309
                        }
×
310
                        if _, err := r.executor("restore", localPath).withDir(worktree).executeString(); err != nil {
1✔
311
                                return nil, err
×
312
                        }
×
313
                }
314
        }
315

316
        return commitID, nil
37✔
317
}
318

319
// TreeBuilder is used to create multi-level trees in a repository.  Based on
320
// `buildTreeHelper` in go-git.
321
type TreeBuilder struct {
322
        repo    *Repository
323
        trees   map[string]*entryTree
324
        entries map[string]TreeEntry
325
}
326

327
func NewTreeBuilder(repo *Repository) *TreeBuilder {
122✔
328
        return &TreeBuilder{repo: repo}
122✔
329
}
122✔
330

331
// WriteTreeFromEntries accepts list of TreeEntry representations, and returns
332
// the Git ID of the tree that contains these entries. It constructs the
333
// required intermediate trees.
334
func (t *TreeBuilder) WriteTreeFromEntries(files []TreeEntry) (Hash, error) {
185✔
335
        rootNodeKey := ""
185✔
336
        t.trees = map[string]*entryTree{rootNodeKey: {}}
185✔
337
        t.entries = map[string]TreeEntry{}
185✔
338

185✔
339
        for _, entry := range files {
451✔
340
                t.identifyIntermediates(entry)
266✔
341
        }
266✔
342

343
        return t.writeTrees(rootNodeKey, t.trees[rootNodeKey])
185✔
344
}
345

346
// identifyIntermediates identifies the intermediate trees that must be
347
// constructed for the specified path.
348
func (t *TreeBuilder) identifyIntermediates(entry TreeEntry) {
266✔
349
        parts := strings.Split(entry.getName(), "/")
266✔
350

266✔
351
        var fullPath string
266✔
352
        for _, part := range parts {
657✔
353
                parent := fullPath
391✔
354
                fullPath = path.Join(fullPath, part)
391✔
355

391✔
356
                t.populateTree(parent, fullPath, entry)
391✔
357
        }
391✔
358
}
359

360
// populateTree populates tree and entry information for each tree that must be
361
// created.
362
func (t *TreeBuilder) populateTree(parent, fullPath string, entry TreeEntry) {
391✔
363
        if _, ok := t.trees[fullPath]; ok {
410✔
364
                return
19✔
365
        }
19✔
366

367
        if _, ok := t.entries[fullPath]; ok {
372✔
UNCOV
368
                return
×
UNCOV
369
        }
×
370

371
        var entryObj TreeEntry
372✔
372

372✔
373
        if fullPath == entry.getName() {
632✔
374
                // => This is a leaf node
260✔
375
                // However, gitID _may_ be a tree ID, and we've inserted an existing
260✔
376
                // tree object as a subtree here, we want to support this so that we
260✔
377
                // don't have to recreate trees that already exist
260✔
378

260✔
379
                if err := t.repo.ensureIsTree(entry.getID()); err == nil {
303✔
380
                        // gitID represents tree
43✔
381
                        entryObj = &entryTree{
43✔
382
                                name:          path.Base(fullPath),
43✔
383
                                gitID:         entry.getID(),
43✔
384
                                alreadyExists: true,
43✔
385
                        }
43✔
386
                } else {
260✔
387
                        // gitID is not for a tree
217✔
388
                        entryObj = &entryBlob{
217✔
389
                                name:  path.Base(fullPath),
217✔
390
                                gitID: entry.getID(),
217✔
391
                        }
217✔
392
                }
217✔
393
        } else {
112✔
394
                // => This is an intermediate node, has to be a tree that we must build
112✔
395
                entryObj = &entryTree{
112✔
396
                        name:          path.Base(fullPath),
112✔
397
                        gitID:         ZeroHash,
112✔
398
                        alreadyExists: false,
112✔
399
                }
112✔
400
                t.trees[fullPath] = &entryTree{}
112✔
401
        }
112✔
402

403
        t.trees[parent].entries = append(t.trees[parent].entries, entryObj)
372✔
404
}
405

406
// writeTrees recursively stores each tree that must be created in the
407
// repository's object store. It returns the ID of the tree created at each
408
// invocation.
409
func (t *TreeBuilder) writeTrees(parent string, tree *entryTree) (Hash, error) {
297✔
410
        for i, e := range tree.entries {
669✔
411
                switch e := e.(type) {
372✔
412
                case *entryTree:
155✔
413
                        if e.alreadyExists {
198✔
414
                                // The tree already exists and we don't need to write it again.
43✔
415
                                continue
43✔
416
                        }
417

418
                        p := path.Join(parent, e.name)
112✔
419
                        entryID, err := t.writeTrees(p, t.trees[p])
112✔
420
                        if err != nil {
112✔
UNCOV
421
                                return ZeroHash, err
×
UNCOV
422
                        }
×
423
                        e.gitID = entryID
112✔
424

112✔
425
                        tree.entries[i] = e
112✔
426

427
                case *entryBlob:
217✔
428
                        continue
217✔
429
                }
430
        }
431

432
        return t.writeTree(tree.entries)
297✔
433
}
434

435
// writeTree creates a tree in the repository for the specified entries. It
436
// only supports a typical blob with permission 0o644 and a subtree. This is
437
// because it is only intended for use with gittuf specific metadata and tests.
438
// Generic tree creation is left to invocations of the Git binary by the user.
439
func (t *TreeBuilder) writeTree(entries []TreeEntry) (Hash, error) {
297✔
440
        input := ""
297✔
441
        for _, entry := range entries {
669✔
442
                // this is very opinionated about the modes right now because the plan
372✔
443
                // is to use it for gittuf metadata, which requires regular files and
372✔
444
                // subdirectories
372✔
445
                switch entry := entry.(type) {
372✔
446
                case *entryTree:
155✔
447
                        input += "040000 tree " + entry.gitID.String() + "\t" + entry.name
155✔
448
                case *entryBlob:
217✔
449
                        // TODO: support entryBlob's permissions here
217✔
450
                        input += "100644 blob " + entry.gitID.String() + "\t" + entry.name
217✔
451
                }
452
                input += "\n"
372✔
453
        }
454

455
        stdOut, err := t.repo.executor("mktree").withStdIn(bytes.NewBufferString(input)).executeString()
297✔
456
        if err != nil {
297✔
UNCOV
457
                return ZeroHash, fmt.Errorf("unable to write Git tree: %w", err)
×
UNCOV
458
        }
×
459

460
        treeID, err := NewHash(stdOut)
297✔
461
        if err != nil {
297✔
UNCOV
462
                return ZeroHash, fmt.Errorf("invalid tree ID: %w", err)
×
UNCOV
463
        }
×
464

465
        return treeID, nil
297✔
466
}
467

468
// TreeEntry represents an entry in a Git tree.
469
type TreeEntry interface {
470
        getName() string
471
        getID() Hash
472
}
473

474
// entryTree implements TreeEntry and indicates the entry is for a Git tree.
475
type entryTree struct {
476
        name          string
477
        gitID         Hash
478
        alreadyExists bool
479
        entries       []TreeEntry
480
}
481

482
func (e *entryTree) getName() string {
123✔
483
        return e.name
123✔
484
}
123✔
485

486
func (e *entryTree) getID() Hash {
86✔
487
        return e.gitID
86✔
488
}
86✔
489

490
// NewEntryTree creates a TreeEntry that represents a Git tree. If the tree
491
// doesn't exist, i.e., it must be created, gitID must be set to ZeroHash. The
492
// name must be set to the full path of the tree object.
493
func NewEntryTree(name string, gitID Hash) TreeEntry {
49✔
494
        entry := &entryTree{name: name, gitID: gitID}
49✔
495
        if gitID == nil || !gitID.IsZero() {
98✔
496
                entry.alreadyExists = true
49✔
497
        }
49✔
498
        return entry
49✔
499
}
500

501
// entryBlob implements TreeEntry and indicates the entry is for a Git blob.
502
type entryBlob struct {
503
        name        string
504
        gitID       Hash
505
        permissions os.FileMode //nolint:unused
506
}
507

508
func (e *entryBlob) getName() string {
515✔
509
        return e.name
515✔
510
}
515✔
511

512
func (e *entryBlob) getID() Hash {
434✔
513
        return e.gitID
434✔
514
}
434✔
515

516
// NewEntryBlob creates a TreeEntry that represents a Git blob.
517
func NewEntryBlob(name string, gitID Hash) TreeEntry {
193✔
518
        return &entryBlob{name: name, gitID: gitID, permissions: 0o644}
193✔
519
}
193✔
520

521
// NewEntryBlobWithPermissions creates a TreeEntry that represents a Git blob.
522
// The permissions parameter can be used to set custom permissions.
UNCOV
523
func NewEntryBlobWithPermissions(name string, gitID Hash, permissions os.FileMode) TreeEntry {
×
UNCOV
524
        return &entryBlob{name: name, gitID: gitID, permissions: permissions}
×
UNCOV
525
}
×
526

527
// ensureIsTree is a helper to check that the ID represents a Git tree
528
// object.
529
func (r *Repository) ensureIsTree(treeID Hash) error {
263✔
530
        objType, err := r.executor("cat-file", "-t", treeID.String()).executeString()
263✔
531
        if err != nil {
264✔
532
                return fmt.Errorf("unable to inspect if object is tree: %w", err)
1✔
533
        } else if objType != "tree" {
481✔
534
                return fmt.Errorf("requested Git ID '%s' is not a tree object", treeID.String())
218✔
535
        }
218✔
536

537
        return nil
44✔
538
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc