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

gittuf / gittuf / 13101799132

02 Feb 2025 07:27PM UTC coverage: 63.083% (+0.1%) from 62.977%
13101799132

push

github

web-flow
Merge pull request #779 from gittuf/gitinterface-tree-entry-types

gitinterface: Support types for tree entries

77 of 83 new or added lines in 5 files covered. (92.77%)

1 existing line in 1 file now uncovered.

5509 of 8733 relevant lines covered (63.08%)

51.99 hits per line

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

76.21
/internal/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
)
18

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

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

30
        return hash, nil
1✔
31
}
32

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

7✔
43
        currentTreeID := treeID
7✔
44
        for len(components) != 0 {
21✔
45
                items, err := r.GetTreeItems(currentTreeID)
14✔
46
                if err != nil {
14✔
47
                        return nil, err
×
48
                }
×
49

50
                entryID, has := items[components[0]]
14✔
51
                if !has {
16✔
52
                        return nil, fmt.Errorf("%w: %s", ErrTreeDoesNotHavePath, treePath)
2✔
53
                }
2✔
54

55
                currentTreeID = entryID
12✔
56
                components = components[1:]
12✔
57
        }
58

59
        return currentTreeID, nil
5✔
60
}
61

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

75
        if stdOut == "" {
20✔
76
                return nil, nil // alternatively, just check if treeID is empty tree?
2✔
77
        }
2✔
78

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

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

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

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

102
                items[entrySplit[1]] = hash
25✔
103
        }
104

105
        return items, nil
16✔
106
}
107

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

121
        if stdOut == "" {
12✔
122
                return nil, nil // alternatively, just check if treeID is empty tree?
×
123
        }
×
124

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

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

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

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

148
                files[entrySplit[1]] = hash
24✔
149
        }
150

151
        return files, nil
12✔
152
}
153

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

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

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

175
        niceGit, err := isNiceGitVersion()
2✔
176
        if err != nil {
2✔
177
                return ZeroHash, err
×
178
        }
×
179

180
        var stdOut string
2✔
181
        if !niceGit {
2✔
182
                // Older Git versions do not support merge-tree, and, as such, require
×
183
                // quite a long workaround to find what the merge tree is. This
×
184
                // workaround boils down to:
×
185
                // Create new branch > Merge into said branch > Extract tree hash
×
186
                currentBranch, err := r.executor("branch", "--show-current").executeString()
×
187
                if err != nil {
×
188
                        return ZeroHash, fmt.Errorf("unable to determine current branch: %w", err)
×
189
                }
×
190

191
                if currentBranch == "" {
×
192
                        return ZeroHash, fmt.Errorf("currently in detached HEAD state, please switch to a branch")
×
193
                }
×
194

195
                _, err = r.executor("checkout", commitAID.String()).executeString()
×
196
                if err != nil {
×
197
                        return ZeroHash, fmt.Errorf("unable to enter detached HEAD state: %w", err)
×
198
                }
×
199

200
                _, err = r.executor("merge", "-m", "Computing merge tree", commitBID.String()).executeString()
×
201
                if err != nil {
×
202
                        // Attempt to abort the merge in all cases as a failsafe
×
203
                        _, abrtErr := r.executor("merge", "--abort").executeString()
×
204
                        if abrtErr != nil {
×
205
                                return ZeroHash, fmt.Errorf("unable to perform merge, and unable to abort merge: %w, %w", err, abrtErr)
×
206
                        }
×
207

208
                        return ZeroHash, fmt.Errorf("unable to perform merge: %w", err)
×
209
                }
210

211
                stdOut, err = r.executor("show", "-s", "--format=%T").executeString()
×
212
                if err != nil {
×
213
                        return ZeroHash, fmt.Errorf("unable to extract tree hash of merge commit: %w", err)
×
214
                }
×
215

216
                // Switch back to the branch the user was on
217
                _, err = r.executor("checkout", currentBranch).executeString()
×
218
                if err != nil {
×
219
                        return ZeroHash, fmt.Errorf("unable to switch back to original branch: %w", err)
×
220
                }
×
221
        } else {
2✔
222
                stdOut, err = r.executor("merge-tree", commitAID.String(), commitBID.String()).executeString()
2✔
223
                if err != nil {
3✔
224
                        return ZeroHash, fmt.Errorf("unable to compute merge tree: %w", err)
1✔
225
                }
1✔
226
        }
227

228
        treeHash, err := NewHash(stdOut)
1✔
229
        if err != nil {
1✔
230
                return ZeroHash, fmt.Errorf("invalid merge tree ID: %w", err)
×
231
        }
×
232

233
        return treeHash, nil
1✔
234
}
235

236
// TreeBuilder is used to create multi-level trees in a repository.  Based on
237
// `buildTreeHelper` in go-git.
238
type TreeBuilder struct {
239
        repo    *Repository
240
        trees   map[string]*entryTree
241
        entries map[string]TreeEntry
242
}
243

244
func NewTreeBuilder(repo *Repository) *TreeBuilder {
51✔
245
        return &TreeBuilder{repo: repo}
51✔
246
}
51✔
247

248
// WriteTreeFromEntries accepts list of TreeEntry representations, and returns
249
// the Git ID of the tree that contains these entries. It constructs the
250
// required intermediate trees.
251
func (t *TreeBuilder) WriteTreeFromEntries(files []TreeEntry) (Hash, error) {
102✔
252
        rootNodeKey := ""
102✔
253
        t.trees = map[string]*entryTree{rootNodeKey: {}}
102✔
254
        t.entries = map[string]TreeEntry{}
102✔
255

102✔
256
        for _, entry := range files {
220✔
257
                t.identifyIntermediates(entry)
118✔
258
        }
118✔
259

260
        return t.writeTrees(rootNodeKey, t.trees[rootNodeKey])
102✔
261
}
262

263
// identifyIntermediates identifies the intermediate trees that must be
264
// constructed for the specified path.
265
func (t *TreeBuilder) identifyIntermediates(entry TreeEntry) {
118✔
266
        parts := strings.Split(entry.getName(), "/")
118✔
267

118✔
268
        var fullPath string
118✔
269
        for _, part := range parts {
262✔
270
                parent := fullPath
144✔
271
                fullPath = path.Join(fullPath, part)
144✔
272

144✔
273
                t.populateTree(parent, fullPath, entry)
144✔
274
        }
144✔
275
}
276

277
// populateTree populates tree and entry information for each tree that must be
278
// created.
279
func (t *TreeBuilder) populateTree(parent, fullPath string, entry TreeEntry) {
144✔
280
        if _, ok := t.trees[fullPath]; ok {
147✔
281
                return
3✔
282
        }
3✔
283

284
        if _, ok := t.entries[fullPath]; ok {
141✔
285
                return
×
286
        }
×
287

288
        var entryObj TreeEntry
141✔
289

141✔
290
        if fullPath == entry.getName() {
259✔
291
                // => This is a leaf node
118✔
292
                // However, gitID _may_ be a tree ID, and we've inserted an existing
118✔
293
                // tree object as a subtree here, we want to support this so that we
118✔
294
                // don't have to recreate trees that already exist
118✔
295

118✔
296
                if err := t.repo.ensureIsTree(entry.getID()); err == nil {
121✔
297
                        // gitID represents tree
3✔
298
                        entryObj = &entryTree{
3✔
299
                                name:          path.Base(fullPath),
3✔
300
                                gitID:         entry.getID(),
3✔
301
                                alreadyExists: true,
3✔
302
                        }
3✔
303
                } else {
118✔
304
                        // gitID is not for a tree
115✔
305
                        entryObj = &entryBlob{
115✔
306
                                name:  path.Base(fullPath),
115✔
307
                                gitID: entry.getID(),
115✔
308
                        }
115✔
309
                }
115✔
310
        } else {
23✔
311
                // => This is an intermediate node, has to be a tree that we must build
23✔
312
                entryObj = &entryTree{
23✔
313
                        name:          path.Base(fullPath),
23✔
314
                        gitID:         ZeroHash,
23✔
315
                        alreadyExists: false,
23✔
316
                }
23✔
317
                t.trees[fullPath] = &entryTree{}
23✔
318
        }
23✔
319

320
        t.trees[parent].entries = append(t.trees[parent].entries, entryObj)
141✔
321
}
322

323
// writeTrees recursively stores each tree that must be created in the
324
// repository's object store. It returns the ID of the tree created at each
325
// invocation.
326
func (t *TreeBuilder) writeTrees(parent string, tree *entryTree) (Hash, error) {
125✔
327
        for i, e := range tree.entries {
266✔
328
                switch e := e.(type) {
141✔
329
                case *entryTree:
26✔
330
                        if e.alreadyExists {
29✔
331
                                // The tree already exists and we don't need to write it again.
3✔
332
                                continue
3✔
333
                        }
334

335
                        p := path.Join(parent, e.name)
23✔
336
                        entryID, err := t.writeTrees(p, t.trees[p])
23✔
337
                        if err != nil {
23✔
NEW
338
                                return ZeroHash, err
×
NEW
339
                        }
×
340
                        e.gitID = entryID
23✔
341

23✔
342
                        tree.entries[i] = e
23✔
343

344
                case *entryBlob:
115✔
345
                        continue
115✔
346
                }
347
        }
348

349
        return t.writeTree(tree.entries)
125✔
350
}
351

352
// writeTree creates a tree in the repository for the specified entries. It
353
// only supports a typical blob with permission 0o644 and a subtree. This is
354
// because it is only intended for use with gittuf specific metadata and tests.
355
// Generic tree creation is left to invocations of the Git binary by the user.
356
func (t *TreeBuilder) writeTree(entries []TreeEntry) (Hash, error) {
125✔
357
        input := ""
125✔
358
        for _, entry := range entries {
266✔
359
                // this is very opinionated about the modes right now because the plan
141✔
360
                // is to use it for gittuf metadata, which requires regular files and
141✔
361
                // subdirectories
141✔
362
                switch entry := entry.(type) {
141✔
363
                case *entryTree:
26✔
364
                        input += "040000 tree " + entry.gitID.String() + "\t" + entry.name
26✔
365
                case *entryBlob:
115✔
366
                        // TODO: support entryBlob's permissions here
115✔
367
                        input += "100644 blob " + entry.gitID.String() + "\t" + entry.name
115✔
368
                }
369
                input += "\n"
141✔
370
        }
371

372
        stdOut, err := t.repo.executor("mktree").withStdIn(bytes.NewBufferString(input)).executeString()
125✔
373
        if err != nil {
125✔
374
                return ZeroHash, fmt.Errorf("unable to write Git tree: %w", err)
×
375
        }
×
376

377
        treeID, err := NewHash(stdOut)
125✔
378
        if err != nil {
125✔
379
                return ZeroHash, fmt.Errorf("invalid tree ID: %w", err)
×
380
        }
×
381

382
        return treeID, nil
125✔
383
}
384

385
// TreeEntry represents an entry in a Git tree.
386
type TreeEntry interface {
387
        getName() string
388
        getID() Hash
389
}
390

391
// entryTree implements TreeEntry and indicates the entry is for a Git tree.
392
type entryTree struct {
393
        name          string
394
        gitID         Hash
395
        alreadyExists bool
396
        entries       []TreeEntry
397
}
398

399
func (e *entryTree) getName() string {
8✔
400
        return e.name
8✔
401
}
8✔
402

403
func (e *entryTree) getID() Hash {
6✔
404
        return e.gitID
6✔
405
}
6✔
406

407
// NewEntryTree creates a TreeEntry that represents a Git tree. If the tree
408
// doesn't exist, i.e., it must be created, gitID must be set to ZeroHash. The
409
// name must be set to the full path of the tree object.
410
func NewEntryTree(name string, gitID Hash) TreeEntry {
3✔
411
        entry := &entryTree{name: name, gitID: gitID}
3✔
412
        if gitID == nil || !gitID.IsZero() {
6✔
413
                entry.alreadyExists = true
3✔
414
        }
3✔
415
        return entry
3✔
416
}
417

418
// entryBlob implements TreeEntry and indicates the entry is for a Git blob.
419
type entryBlob struct {
420
        name        string
421
        gitID       Hash
422
        permissions os.FileMode //nolint:unused
423
}
424

425
func (e *entryBlob) getName() string {
251✔
426
        return e.name
251✔
427
}
251✔
428

429
func (e *entryBlob) getID() Hash {
230✔
430
        return e.gitID
230✔
431
}
230✔
432

433
// NewEntryBlob creates a TreeEntry that represents a Git blob.
434
func NewEntryBlob(name string, gitID Hash) TreeEntry {
115✔
435
        return &entryBlob{name: name, gitID: gitID, permissions: 0o644}
115✔
436
}
115✔
437

438
// NewEntryBlobWithPermissions creates a TreeEntry that represents a Git blob.
439
// The permissions parameter can be used to set custom permissions.
NEW
440
func NewEntryBlobWithPermissions(name string, gitID Hash, permissions os.FileMode) TreeEntry {
×
NEW
441
        return &entryBlob{name: name, gitID: gitID, permissions: permissions}
×
UNCOV
442
}
×
443

444
// ensureIsTree is a helper to check that the ID represents a Git tree
445
// object.
446
func (r *Repository) ensureIsTree(treeID Hash) error {
120✔
447
        objType, err := r.executor("cat-file", "-t", treeID.String()).executeString()
120✔
448
        if err != nil {
120✔
449
                return fmt.Errorf("unable to inspect if object is tree: %w", err)
×
450
        } else if objType != "tree" {
236✔
451
                return fmt.Errorf("requested Git ID '%s' is not a tree object", treeID.String())
116✔
452
        }
116✔
453

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

© 2026 Coveralls, Inc