all repos

mugit @ f70e150377a19db58e4348197a9bf08f23c07a4f

🐮 git server that your cow will love

mugit/internal/git/tree.go (view raw)

Oleksandr Smirnov Oleksandr Smirnov
olexsmir@gmail.com
run errcheck, 27 days ago
1
package git
2
3
import (
4
	"bufio"
5
	"context"
6
	"errors"
7
	"fmt"
8
	"io"
9
	"mime"
10
	"path"
11
	"path/filepath"
12
	"strings"
13
	"time"
14
15
	"github.com/go-git/go-git/v5/plumbing"
16
	"github.com/go-git/go-git/v5/plumbing/object"
17
)
18
19
type NiceTree struct {
20
	IsFile bool
21
	Name   string
22
	Commit *Commit
23
	Mode   string
24
	Size   int64
25
}
26
27
func (g *Repo) makeNiceTree(ctx context.Context, t *object.Tree, parent string) []NiceTree {
28
	var nts []NiceTree
29
30
	ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
31
	defer cancel()
32
33
	cms, err := g.lastCommitForFilesInTree(ctx, t, parent)
34
	if err != nil {
35
		return nts
36
	}
37
38
	for _, e := range t.Entries {
39
		fpath := path.Join(parent, e.Name)
40
		mode, _ := e.Mode.ToOSFileMode()
41
		sz, _ := t.Size(e.Name)
42
		nts = append(nts, NiceTree{
43
			Commit: cms[fpath],
44
			Name:   e.Name,
45
			Mode:   mode.String(),
46
			IsFile: e.Mode.IsFile(),
47
			Size:   sz,
48
		})
49
	}
50
	return nts
51
}
52
53
func (g *Repo) FileTree(ctx context.Context, path string) ([]NiceTree, error) {
54
	c, err := g.r.CommitObject(g.h)
55
	if err != nil {
56
		return nil, fmt.Errorf("commit object: %w", err)
57
	}
58
59
	tree, err := c.Tree()
60
	if err != nil {
61
		return nil, fmt.Errorf("file tree: %w", err)
62
	}
63
64
	var files []NiceTree
65
	if path == "" {
66
		files = g.makeNiceTree(ctx, tree, path)
67
	} else {
68
		o, err := tree.FindEntry(path)
69
		if err != nil {
70
			return nil, err
71
		}
72
73
		if !o.Mode.IsFile() {
74
			subtree, err := tree.Tree(path)
75
			if err != nil {
76
				return nil, err
77
			}
78
			files = g.makeNiceTree(ctx, subtree, path)
79
		}
80
	}
81
82
	return files, nil
83
}
84
85
type FileContent struct {
86
	IsBinary bool
87
	IsImage  bool
88
	Content  []byte
89
	Mime     string
90
	Size     int64
91
}
92
93
func (fc *FileContent) String() string {
94
	if fc.IsBinary || fc.IsImage {
95
		return ""
96
	}
97
	return string(fc.Content)
98
}
99
100
func (g *Repo) FileContent(path string) (*FileContent, error) {
101
	c, err := g.r.CommitObject(g.h)
102
	if err != nil {
103
		return &FileContent{}, fmt.Errorf("commit object: %w", err)
104
	}
105
106
	tree, err := c.Tree()
107
	if err != nil {
108
		return &FileContent{}, fmt.Errorf("file tree: %w", err)
109
	}
110
111
	file, err := tree.File(path)
112
	if err != nil {
113
		if errors.Is(err, object.ErrFileNotFound) {
114
			return &FileContent{}, ErrFileNotFound
115
		}
116
		return &FileContent{}, err
117
	}
118
119
	reader, err := file.Reader()
120
	if err != nil {
121
		return nil, fmt.Errorf("file reader: %w", err)
122
	}
123
	defer func() { _ = reader.Close() }()
124
125
	content, err := io.ReadAll(reader)
126
	if err != nil {
127
		return nil, fmt.Errorf("read file: %w", err)
128
	}
129
130
	isBin, _ := file.IsBinary()
131
	mimeType := mime.TypeByExtension(filepath.Ext(path))
132
	if mimeType == "" {
133
		mimeType = "text/plain"
134
		if isBin {
135
			mimeType = "application/octet-stream"
136
		}
137
	}
138
139
	return &FileContent{
140
		IsBinary: isBin,
141
		IsImage:  strings.HasPrefix(mimeType, "image/"),
142
		Content:  content,
143
		Mime:     mimeType,
144
		Size:     file.Size,
145
	}, nil
146
}
147
148
type logCommit struct {
149
	Commit
150
	hash  plumbing.Hash
151
	files []string
152
}
153
154
func (g *Repo) lastFileCommitHash(ctx context.Context, fpath string) (string, error) {
155
	output, err := g.streamingGitLog(ctx, "-n", "1", "--format=%H", "--", fpath)
156
	if err != nil {
157
		return "", fmt.Errorf("last file commit for %q: %w", fpath, err)
158
	}
159
	defer func() { _ = output.Close() }()
160
161
	raw, err := io.ReadAll(output)
162
	if err != nil {
163
		return "", fmt.Errorf("reading log output for %q: %w", fpath, err)
164
	}
165
166
	hash := string(raw)
167
	if hash == "" {
168
		return "", fmt.Errorf("no last commit found for %q", fpath)
169
	}
170
171
	return hash, nil
172
}
173
174
func (g *Repo) lastCommitForFilesInTree(ctx context.Context, subtree *object.Tree, parent string) (map[string]*Commit, error) {
175
	filesToDo := make(map[string]struct{})
176
	filesDone := make(map[string]*Commit)
177
	for _, e := range subtree.Entries {
178
		fpath := path.Clean(path.Join(parent, e.Name))
179
		filesToDo[fpath] = struct{}{}
180
	}
181
182
	if len(filesToDo) == 0 {
183
		return filesDone, nil
184
	}
185
186
	ctx, cancel := context.WithCancel(ctx)
187
	defer cancel()
188
189
	pathSpec := "."
190
	if parent != "" {
191
		pathSpec = parent
192
	}
193
194
	output, err := g.streamingGitLog(ctx, "--pretty=format:%H,%ad,%ae,%an,%cd,%ce,%cn,%s", "--date=iso", "--name-only", "--", pathSpec)
195
	if err != nil {
196
		return nil, err
197
	}
198
	defer func() { _ = output.Close() }()
199
200
	var current logCommit
201
	reader := bufio.NewReader(output)
202
	for {
203
		line, err := reader.ReadString('\n')
204
		if err != nil && err != io.EOF {
205
			return nil, err
206
		}
207
208
		line = strings.TrimSpace(line)
209
		if line == "" {
210
			if !current.hash.IsZero() {
211
				c := current.Commit
212
				// we have a fully parsed commit
213
				for _, f := range current.files {
214
					if _, ok := filesToDo[f]; ok {
215
						filesDone[f] = &c
216
						delete(filesToDo, f)
217
					}
218
				}
219
220
				if len(filesToDo) == 0 {
221
					cancel()
222
					break
223
				}
224
225
				current = logCommit{}
226
			}
227
		} else if current.hash.IsZero() {
228
			parts := strings.SplitN(line, ",", 8)
229
			if len(parts) == 8 {
230
				current.hash = plumbing.NewHash(parts[0])
231
232
				// NOTE: this is copy-paste of [newCommit]
233
				current.Hash = parts[0]
234
				current.HashShort = parts[0][:7]
235
				current.Authored, _ = time.Parse("2006-01-02 15:04:05 -0700", parts[1])
236
				current.AuthorEmail = parts[2]
237
				current.AuthorName = parts[3]
238
				current.Committed, _ = time.Parse("2006-01-02 15:04:05 -0700", parts[4])
239
				current.CommitterEmail = parts[5]
240
				current.CommitterName = parts[6]
241
				current.Message = parts[7]
242
			}
243
		} else {
244
			// all ancestors along this path should also be included
245
			file := path.Clean(line)
246
			ancestors := ancestors(file)
247
			current.files = append(current.files, file)
248
			current.files = append(current.files, ancestors...)
249
		}
250
251
		if err == io.EOF {
252
			break
253
		}
254
	}
255
256
	return filesDone, nil
257
}
258
259
func ancestors(p string) []string {
260
	var ancestors []string
261
	for {
262
		p = path.Dir(p)
263
		if p == "." || p == "/" {
264
			break
265
		}
266
		ancestors = append(ancestors, p)
267
	}
268
	return ancestors
269
}