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

gittuf / gittuf / 13100963856

02 Feb 2025 05:28PM UTC coverage: 62.977% (+0.1%) from 62.873%
13100963856

push

github

web-flow
Merge pull request #778 from gittuf/gitinterface-intermediate-trees

*: Extend TreeBuilder to reuse existing trees

40 of 41 new or added lines in 5 files covered. (97.56%)

5479 of 8700 relevant lines covered (62.98%)

51.86 hits per line

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

74.32
/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
        "path"
11
        "strings"
12
)
13

14
var (
15
        ErrTreeDoesNotHavePath = errors.New("tree does not have requested path")
16
)
17

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

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

29
        return hash, nil
1✔
30
}
31

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

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

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

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

58
        return currentTreeID, nil
5✔
59
}
60

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

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

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

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

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

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

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

104
        return items, nil
16✔
105
}
106

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

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

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

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

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

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

147
                files[entrySplit[1]] = hash
16✔
148
        }
149

150
        return files, nil
10✔
151
}
152

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

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

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

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

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

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

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

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

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

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

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

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

232
        return treeHash, nil
1✔
233
}
234

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

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

247
// WriteTreeFromEntryIDs accepts a map of paths to their Git IDs and returns the
248
// tree ID that contains these files.
249
func (t *TreeBuilder) WriteTreeFromEntryIDs(files map[string]Hash) (Hash, error) {
99✔
250
        rootNodeKey := ""
99✔
251
        t.trees = map[string]*entry{rootNodeKey: {}}
99✔
252
        t.entries = map[string]*entry{}
99✔
253

99✔
254
        for path, gitID := range files {
208✔
255
                t.identifyIntermediates(path, gitID)
109✔
256
        }
109✔
257

258
        return t.writeTrees(rootNodeKey, t.trees[rootNodeKey])
99✔
259
}
260

261
// identifyIntermediates identifies the intermediate trees that must be
262
// constructed for the specified path.
263
func (t *TreeBuilder) identifyIntermediates(name string, gitID Hash) {
109✔
264
        parts := strings.Split(name, "/")
109✔
265

109✔
266
        var fullPath string
109✔
267
        for _, part := range parts {
238✔
268
                parent := fullPath
129✔
269
                fullPath = path.Join(fullPath, part)
129✔
270

129✔
271
                t.populateTree(name, parent, fullPath, gitID)
129✔
272
        }
129✔
273
}
274

275
// populateTree populates tree and entry information for each tree that must be
276
// created.
277
func (t *TreeBuilder) populateTree(name, parent, fullPath string, gitID Hash) {
129✔
278
        if _, ok := t.trees[fullPath]; ok {
130✔
279
                return
1✔
280
        }
1✔
281

282
        if _, ok := t.entries[fullPath]; ok {
128✔
283
                return
×
284
        }
×
285

286
        entryObj := &entry{name: path.Base(fullPath), gitID: ZeroHash}
128✔
287

128✔
288
        if fullPath == name {
237✔
289
                // => This is a leaf node
109✔
290
                // However, gitID _may_ be a tree ID, and we've inserted an existing
109✔
291
                // tree object as a subtree here, we want to support this so that we
109✔
292
                // don't have to recreate trees that already exist
109✔
293

109✔
294
                if err := t.repo.ensureIsTree(gitID); err == nil {
111✔
295
                        // gitID represents tree
2✔
296
                        entryObj.isDir = true
2✔
297
                        entryObj.dirExists = true
2✔
298
                } else {
109✔
299
                        // gitID is not for a tree
107✔
300
                        entryObj.isDir = false
107✔
301
                }
107✔
302
                entryObj.gitID = gitID
109✔
303
        } else {
19✔
304
                // => This is an intermediate node, has to be a tree that we must build
19✔
305
                entryObj.isDir = true
19✔
306
                t.trees[fullPath] = &entry{}
19✔
307
        }
19✔
308

309
        t.trees[parent].entries = append(t.trees[parent].entries, entryObj)
128✔
310
}
311

312
// writeTrees recursively stores each tree that must be created in the
313
// repository's object store. It returns the ID of the tree created at each
314
// invocation.
315
func (t *TreeBuilder) writeTrees(parent string, tree *entry) (Hash, error) {
118✔
316
        for i, e := range tree.entries {
246✔
317
                if (e.isDir && e.dirExists) || (!e.isDir && !e.gitID.IsZero()) {
237✔
318
                        // The first condition checks if the entry is for a directory that
109✔
319
                        // already exists. If true, then we don't need to write subtrees.
109✔
320
                        // The second condition checks if the entry is _not_ for a directory
109✔
321
                        // and the entry's ID is _not_ zero, meaning it's a leaf entry
109✔
322
                        // representing a blob. So once again, we don't need to write
109✔
323
                        // subtrees.
109✔
324
                        continue
109✔
325
                }
326

327
                p := path.Join(parent, e.name)
19✔
328
                entryID, err := t.writeTrees(p, t.trees[p])
19✔
329
                if err != nil {
19✔
330
                        return ZeroHash, err
×
331
                }
×
332
                e.gitID = entryID
19✔
333

19✔
334
                tree.entries[i] = e
19✔
335
        }
336

337
        return t.writeTree(tree.entries)
118✔
338
}
339

340
// writeTree creates a tree in the repository for the specified entries. It
341
// only supports a typical blob with permission 0o644 and a subtree. This is
342
// because it is only intended for use with gittuf specific metadata and tests.
343
// Generic tree creation is left to invocations of the Git binary by the user.
344
func (t *TreeBuilder) writeTree(entries []*entry) (Hash, error) {
118✔
345
        input := ""
118✔
346
        for _, entry := range entries {
246✔
347
                // this is very opinionated about the modes right now because the plan
128✔
348
                // is to use it for gittuf metadata, which requires regular files and
128✔
349
                // subdirectories
128✔
350
                if entry.isDir {
149✔
351
                        input += "040000 tree " + entry.gitID.String() + "\t" + entry.name
21✔
352
                } else {
128✔
353
                        input += "100644 blob " + entry.gitID.String() + "\t" + entry.name
107✔
354
                }
107✔
355
                input += "\n"
128✔
356
        }
357

358
        stdOut, err := t.repo.executor("mktree").withStdIn(bytes.NewBufferString(input)).executeString()
118✔
359
        if err != nil {
118✔
360
                return ZeroHash, fmt.Errorf("unable to write Git tree: %w", err)
×
361
        }
×
362

363
        treeID, err := NewHash(stdOut)
118✔
364
        if err != nil {
118✔
365
                return ZeroHash, fmt.Errorf("invalid tree ID: %w", err)
×
366
        }
×
367

368
        return treeID, nil
118✔
369
}
370

371
// entry is a helper type that represents an entry in a Git tree. If `isDir` is
372
// true, it indicates the entry represents a subtree.
373
type entry struct {
374
        name      string
375
        isDir     bool
376
        dirExists bool
377
        gitID     Hash
378
        entries   []*entry // only used when isDir is true
379
}
380

381
// ensureIsTree is a helper to check that the ID represents a Git tree
382
// object.
383
func (r *Repository) ensureIsTree(treeID Hash) error {
111✔
384
        objType, err := r.executor("cat-file", "-t", treeID.String()).executeString()
111✔
385
        if err != nil {
111✔
NEW
386
                return fmt.Errorf("unable to inspect if object is tree: %w", err)
×
387
        } else if objType != "tree" {
219✔
388
                return fmt.Errorf("requested Git ID '%s' is not a tree object", treeID.String())
108✔
389
        }
108✔
390

391
        return nil
3✔
392
}
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