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

gittuf / gittuf / 13078423490

31 Jan 2025 06:16PM UTC coverage: 62.836% (+0.1%) from 62.7%
13078423490

push

github

web-flow
Merge pull request #773 from gittuf/gitinterface-tree

gitinterface: Add APIs for inspecting trees

45 of 53 new or added lines in 1 file covered. (84.91%)

5451 of 8675 relevant lines covered (62.84%)

51.03 hits per line

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

71.74
/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✔
NEW
46
                        return nil, err
×
NEW
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✔
NEW
71
                return nil, fmt.Errorf("unable to enumerate items in tree '%s': %w", treeID.String(), err)
×
NEW
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✔
NEW
80
                return nil, nil
×
NEW
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✔
NEW
98
                        return nil, fmt.Errorf("invalid Git ID '%s' for path '%s': %w", entrySplit[0], entrySplit[1], err)
×
NEW
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) {
12✔
110
        // From Git 2.36, we can use --format here. However, it appears a not
12✔
111
        // insignificant number of developers are still on Git 2.34.1, a side effect
12✔
112
        // of being on Ubuntu 22.04. 22.04 is still widely used in WSL2 environments.
12✔
113
        // So, we're removing --format and parsing the output differently to handle
12✔
114
        // the extra information for each entry we don't need.
12✔
115
        stdOut, err := r.executor("ls-tree", "-r", treeID.String()).executeString()
12✔
116
        if err != nil {
16✔
117
                return nil, fmt.Errorf("unable to enumerate all files in tree: %w", err)
4✔
118
        }
4✔
119

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

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

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

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

12✔
141
                // <object> is really the object ID
12✔
142
                hash, err := NewHash(entrySplit[0])
12✔
143
                if err != nil {
12✔
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
12✔
148
        }
149

150
        return files, nil
8✔
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 {
44✔
244
        return &TreeBuilder{repo: repo}
44✔
245
}
44✔
246

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

92✔
254
        for path, gitID := range files {
192✔
255
                t.buildIntermediates(path, gitID)
100✔
256
        }
100✔
257

258
        return t.writeTrees(rootNoteKey, t.trees[rootNoteKey])
92✔
259
}
260

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

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

119✔
271
                t.buildTree(name, parent, fullPath, gitID)
119✔
272
        }
119✔
273
}
274

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

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

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

118✔
288
        if fullPath == name {
218✔
289
                entryObj.isDir = false
100✔
290
                entryObj.gitID = gitID
100✔
291
        } else {
118✔
292
                entryObj.isDir = true
18✔
293
                t.trees[fullPath] = &entry{}
18✔
294
        }
18✔
295

296
        t.trees[parent].entries = append(t.trees[parent].entries, entryObj)
118✔
297
}
298

299
// writeTrees recursively stores each tree that must be created in the
300
// repository's object store. It returns the ID of the tree created at each
301
// invocation.
302
func (t *TreeBuilder) writeTrees(parent string, tree *entry) (Hash, error) {
110✔
303
        for i, e := range tree.entries {
228✔
304
                if !e.isDir && !e.gitID.IsZero() {
218✔
305
                        continue
100✔
306
                }
307

308
                p := path.Join(parent, e.name)
18✔
309
                entryID, err := t.writeTrees(p, t.trees[p])
18✔
310
                if err != nil {
18✔
311
                        return ZeroHash, err
×
312
                }
×
313
                e.gitID = entryID
18✔
314

18✔
315
                tree.entries[i] = e
18✔
316
        }
317

318
        return t.writeTree(tree.entries)
110✔
319
}
320

321
// writeTree creates a tree in the repository for the specified entries. It
322
// only supports a typical blob with permission 0o644 and a subtree. This is
323
// because it is only intended for use with gittuf specific metadata and tests.
324
// Generic tree creation is left to invocations of the Git binary by the user.
325
func (t *TreeBuilder) writeTree(entries []*entry) (Hash, error) {
110✔
326
        input := ""
110✔
327
        for _, entry := range entries {
228✔
328
                // this is very opinionated about the modes right now because the plan
118✔
329
                // is to use it for gittuf metadata, which requires regular files and
118✔
330
                // subdirectories
118✔
331
                if entry.isDir {
136✔
332
                        input += "040000 tree " + entry.gitID.String() + "\t" + entry.name
18✔
333
                } else {
118✔
334
                        input += "100644 blob " + entry.gitID.String() + "\t" + entry.name
100✔
335
                }
100✔
336
                input += "\n"
118✔
337
        }
338

339
        stdOut, err := t.repo.executor("mktree").withStdIn(bytes.NewBufferString(input)).executeString()
110✔
340
        if err != nil {
110✔
341
                return ZeroHash, fmt.Errorf("unable to write Git tree: %w", err)
×
342
        }
×
343

344
        treeID, err := NewHash(stdOut)
110✔
345
        if err != nil {
110✔
346
                return ZeroHash, fmt.Errorf("invalid tree ID: %w", err)
×
347
        }
×
348

349
        return treeID, nil
110✔
350
}
351

352
// entry is a helper type that represents an entry in a Git tree. If `isDir` is
353
// true, it indicates the entry represents a subtree.
354
type entry struct {
355
        name    string
356
        isDir   bool
357
        gitID   Hash
358
        entries []*entry // only used when isDir is true
359
}
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