@@ -0,0 +1,672 @@ |
|
|
1 |
package site |
|
|
2 |
|
|
|
3 |
import ( |
|
|
4 |
"bytes" |
|
|
5 |
"context" |
|
|
6 |
"fmt" |
|
|
7 |
"html/template" |
|
|
8 |
"mime" |
|
|
9 |
"net/url" |
|
|
10 |
"path" |
|
|
11 |
"sort" |
|
|
12 |
"strconv" |
|
|
13 |
"strings" |
|
|
14 |
"sync" |
|
|
15 |
"unicode/utf8" |
|
|
16 |
|
|
|
17 |
"github.com/alecthomas/chroma/v2" |
|
|
18 |
"github.com/alecthomas/chroma/v2/formatters/html" |
|
|
19 |
"github.com/alecthomas/chroma/v2/lexers" |
|
|
20 |
"github.com/alecthomas/chroma/v2/styles" |
|
|
21 |
"github.com/npfaro/dumbforge/internal/gitutil" |
|
|
22 |
"github.com/npfaro/dumbforge/internal/s3store" |
|
|
23 |
"github.com/yuin/goldmark" |
|
|
24 |
"github.com/yuin/goldmark/extension" |
|
|
25 |
"github.com/yuin/goldmark/parser" |
|
|
26 |
) |
|
|
27 |
|
|
|
28 |
const ( |
|
|
29 |
siteCache = "no-cache, max-age=0, must-revalidate" |
|
|
30 |
rawCache = "no-cache, max-age=0, must-revalidate" |
|
|
31 |
) |
|
|
32 |
|
|
|
33 |
type Page struct { |
|
|
34 |
Route string |
|
|
35 |
Body []byte |
|
|
36 |
ContentType string |
|
|
37 |
Cache string |
|
|
38 |
Root bool |
|
|
39 |
} |
|
|
40 |
|
|
|
41 |
type Build struct { |
|
|
42 |
Pages []Page |
|
|
43 |
Deletes []string |
|
|
44 |
} |
|
|
45 |
|
|
|
46 |
type Generator struct { |
|
|
47 |
Git gitutil.Git |
|
|
48 |
BaseURL string |
|
|
49 |
RepoName string |
|
|
50 |
Owner string |
|
|
51 |
CloneURL string |
|
|
52 |
Progress func(format string, args ...any) |
|
|
53 |
templates *template.Template |
|
|
54 |
} |
|
|
55 |
|
|
|
56 |
type Commit struct { |
|
|
57 |
OID string |
|
|
58 |
ShortOID string |
|
|
59 |
Author string |
|
|
60 |
Date string |
|
|
61 |
Message string |
|
|
62 |
Initials string |
|
|
63 |
} |
|
|
64 |
|
|
|
65 |
type TreeEntry struct { |
|
|
66 |
Mode string |
|
|
67 |
Type string |
|
|
68 |
OID string |
|
|
69 |
Size int64 |
|
|
70 |
Name string |
|
|
71 |
URL string |
|
|
72 |
} |
|
|
73 |
|
|
|
74 |
type Crumb struct { |
|
|
75 |
Name string |
|
|
76 |
URL string |
|
|
77 |
} |
|
|
78 |
|
|
|
79 |
type pageData struct { |
|
|
80 |
Kind string |
|
|
81 |
Title string |
|
|
82 |
BaseURL string |
|
|
83 |
RepoName string |
|
|
84 |
Owner string |
|
|
85 |
CloneURL string |
|
|
86 |
Branch string |
|
|
87 |
Path string |
|
|
88 |
ParentURL string |
|
|
89 |
Breadcrumbs []Crumb |
|
|
90 |
Entries []TreeEntry |
|
|
91 |
Commit Commit |
|
|
92 |
CommitCount int |
|
|
93 |
ReadmeName string |
|
|
94 |
Readme template.HTML |
|
|
95 |
Code template.HTML |
|
|
96 |
RawURL string |
|
|
97 |
Language string |
|
|
98 |
FileSize int64 |
|
|
99 |
IsImage bool |
|
|
100 |
} |
|
|
101 |
|
|
|
102 |
func New(git gitutil.Git, baseURL, repoName, owner string) (*Generator, error) { |
|
|
103 |
if repoName == "" { |
|
|
104 |
repoName = "repository" |
|
|
105 |
} |
|
|
106 |
tmpl, err := template.New("page").Funcs(template.FuncMap{ |
|
|
107 |
"humanSize": humanSize, |
|
|
108 |
}).Parse(pageTemplate) |
|
|
109 |
if err != nil { |
|
|
110 |
return nil, err |
|
|
111 |
} |
|
|
112 |
return &Generator{ |
|
|
113 |
Git: git, |
|
|
114 |
BaseURL: strings.TrimRight(baseURL, "/"), |
|
|
115 |
RepoName: strings.TrimSuffix(repoName, ".git"), |
|
|
116 |
Owner: owner, |
|
|
117 |
CloneURL: strings.TrimRight(baseURL, "/"), |
|
|
118 |
templates: tmpl, |
|
|
119 |
}, nil |
|
|
120 |
} |
|
|
121 |
|
|
|
122 |
// BuildBranch generates a full branch browser when oldOID is empty. For later |
|
|
123 |
// pushes it only regenerates changed blobs and the directory pages that contain |
|
|
124 |
// them. The repository landing page is refreshed when isHead is true. |
|
|
125 |
func (g *Generator) BuildBranch(ctx context.Context, branch, oldOID, newOID string, isHead bool) (Build, error) { |
|
|
126 |
commit, err := g.commit(ctx, newOID) |
|
|
127 |
if err != nil { |
|
|
128 |
return Build{}, err |
|
|
129 |
} |
|
|
130 |
commitCount, err := g.commitCount(ctx, newOID) |
|
|
131 |
if err != nil { |
|
|
132 |
return Build{}, err |
|
|
133 |
} |
|
|
134 |
result := Build{} |
|
|
135 |
result.Pages = append(result.Pages, Page{ |
|
|
136 |
Route: ".dumbforge/assets/site.css", |
|
|
137 |
Body: []byte(siteCSS + g.chromaCSS()), |
|
|
138 |
ContentType: "text/css; charset=utf-8", |
|
|
139 |
Cache: siteCache, |
|
|
140 |
}) |
|
|
141 |
|
|
|
142 |
if oldOID == "" { |
|
|
143 |
entries, err := g.listTreeRecursive(ctx, newOID) |
|
|
144 |
if err != nil { |
|
|
145 |
return Build{}, err |
|
|
146 |
} |
|
|
147 |
dirs := map[string]struct{}{"": {}} |
|
|
148 |
for _, entry := range entries { |
|
|
149 |
if entry.Type == "tree" { |
|
|
150 |
dirs[entry.Name] = struct{}{} |
|
|
151 |
continue |
|
|
152 |
} |
|
|
153 |
pages, err := g.blobPages(ctx, branch, newOID, entry, commit, commitCount) |
|
|
154 |
if err != nil { |
|
|
155 |
return Build{}, err |
|
|
156 |
} |
|
|
157 |
result.Pages = append(result.Pages, pages...) |
|
|
158 |
} |
|
|
159 |
for _, dir := range sortedKeys(dirs) { |
|
|
160 |
if dir == "" && isHead { |
|
|
161 |
continue |
|
|
162 |
} |
|
|
163 |
page, err := g.treePage(ctx, branch, newOID, dir, commit, commitCount, false) |
|
|
164 |
if err != nil { |
|
|
165 |
return Build{}, err |
|
|
166 |
} |
|
|
167 |
result.Pages = append(result.Pages, page) |
|
|
168 |
} |
|
|
169 |
} else { |
|
|
170 |
changes, err := g.Git.DiffNameStatus(ctx, oldOID, newOID) |
|
|
171 |
if err != nil { |
|
|
172 |
g.progress("could not diff %s..%s; rebuilding branch", short(oldOID), short(newOID)) |
|
|
173 |
return g.BuildBranch(ctx, branch, "", newOID, isHead) |
|
|
174 |
} |
|
|
175 |
dirs := map[string]struct{}{"": {}} |
|
|
176 |
for _, change := range changes { |
|
|
177 |
if change.OldPath != "" { |
|
|
178 |
result.Deletes = append(result.Deletes, |
|
|
179 |
path.Join("blob", branch, change.OldPath), |
|
|
180 |
path.Join("raw", branch, change.OldPath), |
|
|
181 |
) |
|
|
182 |
addAncestorDirs(dirs, change.OldPath) |
|
|
183 |
} |
|
|
184 |
if strings.HasPrefix(change.Status, "D") { |
|
|
185 |
result.Deletes = append(result.Deletes, |
|
|
186 |
path.Join("blob", branch, change.Path), |
|
|
187 |
path.Join("raw", branch, change.Path), |
|
|
188 |
) |
|
|
189 |
addAncestorDirs(dirs, change.Path) |
|
|
190 |
continue |
|
|
191 |
} |
|
|
192 |
entry, err := g.entry(ctx, newOID, change.Path) |
|
|
193 |
if err != nil { |
|
|
194 |
return Build{}, err |
|
|
195 |
} |
|
|
196 |
pages, err := g.blobPages(ctx, branch, newOID, entry, commit, commitCount) |
|
|
197 |
if err != nil { |
|
|
198 |
return Build{}, err |
|
|
199 |
} |
|
|
200 |
result.Pages = append(result.Pages, pages...) |
|
|
201 |
addAncestorDirs(dirs, change.Path) |
|
|
202 |
} |
|
|
203 |
for _, dir := range sortedKeys(dirs) { |
|
|
204 |
if dir == "" && isHead { |
|
|
205 |
continue |
|
|
206 |
} |
|
|
207 |
exists, err := g.treeExists(ctx, newOID, dir) |
|
|
208 |
if err != nil { |
|
|
209 |
return Build{}, err |
|
|
210 |
} |
|
|
211 |
if !exists { |
|
|
212 |
result.Deletes = append(result.Deletes, path.Join("tree", branch, dir)) |
|
|
213 |
continue |
|
|
214 |
} |
|
|
215 |
page, err := g.treePage(ctx, branch, newOID, dir, commit, commitCount, false) |
|
|
216 |
if err != nil { |
|
|
217 |
return Build{}, err |
|
|
218 |
} |
|
|
219 |
result.Pages = append(result.Pages, page) |
|
|
220 |
} |
|
|
221 |
} |
|
|
222 |
|
|
|
223 |
if isHead { |
|
|
224 |
landing, err := g.treePage(ctx, branch, newOID, "", commit, commitCount, true) |
|
|
225 |
if err != nil { |
|
|
226 |
return Build{}, err |
|
|
227 |
} |
|
|
228 |
result.Pages = append(result.Pages, landing) |
|
|
229 |
} |
|
|
230 |
result.Deletes = uniqueStrings(result.Deletes) |
|
|
231 |
return result, nil |
|
|
232 |
} |
|
|
233 |
|
|
|
234 |
func (g *Generator) Publish(ctx context.Context, store *s3store.Store, build Build) error { |
|
|
235 |
for _, route := range build.Deletes { |
|
|
236 |
if err := store.Delete(ctx, route); err != nil { |
|
|
237 |
return err |
|
|
238 |
} |
|
|
239 |
} |
|
|
240 |
|
|
|
241 |
ctx, cancel := context.WithCancel(ctx) |
|
|
242 |
defer cancel() |
|
|
243 |
jobs := make(chan Page) |
|
|
244 |
errCh := make(chan error, 1) |
|
|
245 |
var once sync.Once |
|
|
246 |
var workers sync.WaitGroup |
|
|
247 |
for range 8 { |
|
|
248 |
workers.Add(1) |
|
|
249 |
go func() { |
|
|
250 |
defer workers.Done() |
|
|
251 |
for page := range jobs { |
|
|
252 |
var err error |
|
|
253 |
if page.Root { |
|
|
254 |
err = store.PutRoot(ctx, page.Body, page.ContentType, page.Cache) |
|
|
255 |
} else { |
|
|
256 |
err = store.Put(ctx, page.Route, page.Body, page.ContentType, page.Cache) |
|
|
257 |
} |
|
|
258 |
if err != nil { |
|
|
259 |
once.Do(func() { |
|
|
260 |
errCh <- err |
|
|
261 |
cancel() |
|
|
262 |
}) |
|
|
263 |
return |
|
|
264 |
} |
|
|
265 |
} |
|
|
266 |
}() |
|
|
267 |
} |
|
|
268 |
sendPages: |
|
|
269 |
for _, page := range build.Pages { |
|
|
270 |
select { |
|
|
271 |
case jobs <- page: |
|
|
272 |
case <-ctx.Done(): |
|
|
273 |
break sendPages |
|
|
274 |
} |
|
|
275 |
} |
|
|
276 |
close(jobs) |
|
|
277 |
workers.Wait() |
|
|
278 |
select { |
|
|
279 |
case err := <-errCh: |
|
|
280 |
return err |
|
|
281 |
default: |
|
|
282 |
} |
|
|
283 |
g.progress("published %d site object(s), removed %d", len(build.Pages), len(build.Deletes)) |
|
|
284 |
return nil |
|
|
285 |
} |
|
|
286 |
|
|
|
287 |
func (g *Generator) treePage(ctx context.Context, branch, oid, dir string, commit Commit, commitCount int, root bool) (Page, error) { |
|
|
288 |
entries, err := g.listTree(ctx, oid, dir) |
|
|
289 |
if err != nil { |
|
|
290 |
return Page{}, err |
|
|
291 |
} |
|
|
292 |
for i := range entries { |
|
|
293 |
fullPath := path.Join(dir, entries[i].Name) |
|
|
294 |
if entries[i].Type == "tree" { |
|
|
295 |
entries[i].URL = routeURL(g.BaseURL, path.Join("tree", branch, fullPath)) |
|
|
296 |
} else { |
|
|
297 |
entries[i].URL = routeURL(g.BaseURL, path.Join("blob", branch, fullPath)) |
|
|
298 |
} |
|
|
299 |
} |
|
|
300 |
data := pageData{ |
|
|
301 |
Kind: "tree", |
|
|
302 |
Title: g.RepoName, |
|
|
303 |
BaseURL: g.BaseURL, |
|
|
304 |
RepoName: g.RepoName, |
|
|
305 |
Owner: g.Owner, |
|
|
306 |
CloneURL: g.CloneURL, |
|
|
307 |
Branch: branch, |
|
|
308 |
Path: dir, |
|
|
309 |
Breadcrumbs: g.breadcrumbs(branch, dir), |
|
|
310 |
Entries: entries, |
|
|
311 |
Commit: commit, |
|
|
312 |
CommitCount: commitCount, |
|
|
313 |
} |
|
|
314 |
if dir != "" { |
|
|
315 |
data.Title = path.Base(dir) + " · " + g.RepoName |
|
|
316 |
data.ParentURL = routeURL(g.BaseURL, path.Join("tree", branch, path.Dir(dir))) |
|
|
317 |
} |
|
|
318 |
if root { |
|
|
319 |
data.Kind = "repo" |
|
|
320 |
readmeName, readme, err := g.readme(ctx, oid, entries) |
|
|
321 |
if err != nil { |
|
|
322 |
return Page{}, err |
|
|
323 |
} |
|
|
324 |
data.ReadmeName = readmeName |
|
|
325 |
data.Readme = readme |
|
|
326 |
} |
|
|
327 |
body, err := g.render(data) |
|
|
328 |
if err != nil { |
|
|
329 |
return Page{}, err |
|
|
330 |
} |
|
|
331 |
if root { |
|
|
332 |
return Page{Body: body, ContentType: "text/html; charset=utf-8", Cache: siteCache, Root: true}, nil |
|
|
333 |
} |
|
|
334 |
return Page{Route: path.Join("tree", branch, dir), Body: body, ContentType: "text/html; charset=utf-8", Cache: siteCache}, nil |
|
|
335 |
} |
|
|
336 |
|
|
|
337 |
func (g *Generator) blobPages(ctx context.Context, branch, oid string, entry TreeEntry, commit Commit, commitCount int) ([]Page, error) { |
|
|
338 |
var content []byte |
|
|
339 |
var err error |
|
|
340 |
if entry.Type == "commit" { |
|
|
341 |
content = []byte("Submodule commit " + entry.OID + "\n") |
|
|
342 |
} else { |
|
|
343 |
content, err = g.Git.Run(ctx, "show", oid+":"+entry.Name) |
|
|
344 |
if err != nil { |
|
|
345 |
return nil, err |
|
|
346 |
} |
|
|
347 |
} |
|
|
348 |
rawRoute := path.Join("raw", branch, entry.Name) |
|
|
349 |
contentType := mime.TypeByExtension(path.Ext(entry.Name)) |
|
|
350 |
if contentType == "" { |
|
|
351 |
contentType = "application/octet-stream" |
|
|
352 |
} |
|
|
353 |
data := pageData{ |
|
|
354 |
Kind: "blob", |
|
|
355 |
Title: path.Base(entry.Name) + " · " + g.RepoName, |
|
|
356 |
BaseURL: g.BaseURL, |
|
|
357 |
RepoName: g.RepoName, |
|
|
358 |
Owner: g.Owner, |
|
|
359 |
CloneURL: g.CloneURL, |
|
|
360 |
Branch: branch, |
|
|
361 |
Path: entry.Name, |
|
|
362 |
Breadcrumbs: g.breadcrumbs(branch, entry.Name), |
|
|
363 |
Commit: commit, |
|
|
364 |
CommitCount: commitCount, |
|
|
365 |
RawURL: routeURL(g.BaseURL, rawRoute), |
|
|
366 |
FileSize: int64(len(content)), |
|
|
367 |
IsImage: strings.HasPrefix(contentType, "image/"), |
|
|
368 |
} |
|
|
369 |
if data.IsImage { |
|
|
370 |
data.Language = "Image" |
|
|
371 |
} else if bytes.IndexByte(content, 0) >= 0 || !utf8.Valid(content) { |
|
|
372 |
data.Language = "Binary" |
|
|
373 |
} else if len(content) > 2*1024*1024 { |
|
|
374 |
data.Language = "Large text file" |
|
|
375 |
} else { |
|
|
376 |
code, language, err := highlight(entry.Name, content) |
|
|
377 |
if err != nil { |
|
|
378 |
return nil, err |
|
|
379 |
} |
|
|
380 |
data.Code = code |
|
|
381 |
data.Language = language |
|
|
382 |
} |
|
|
383 |
body, err := g.render(data) |
|
|
384 |
if err != nil { |
|
|
385 |
return nil, err |
|
|
386 |
} |
|
|
387 |
return []Page{ |
|
|
388 |
{Route: path.Join("blob", branch, entry.Name), Body: body, ContentType: "text/html; charset=utf-8", Cache: siteCache}, |
|
|
389 |
{Route: rawRoute, Body: content, ContentType: contentType, Cache: rawCache}, |
|
|
390 |
}, nil |
|
|
391 |
} |
|
|
392 |
|
|
|
393 |
func (g *Generator) readme(ctx context.Context, oid string, entries []TreeEntry) (string, template.HTML, error) { |
|
|
394 |
for _, entry := range entries { |
|
|
395 |
if entry.Type != "blob" || !strings.HasPrefix(strings.ToLower(entry.Name), "readme") { |
|
|
396 |
continue |
|
|
397 |
} |
|
|
398 |
content, err := g.Git.Run(ctx, "show", oid+":"+entry.Name) |
|
|
399 |
if err != nil { |
|
|
400 |
return "", "", err |
|
|
401 |
} |
|
|
402 |
var rendered bytes.Buffer |
|
|
403 |
markdown := goldmark.New( |
|
|
404 |
goldmark.WithExtensions(extension.GFM), |
|
|
405 |
goldmark.WithParserOptions(parser.WithAutoHeadingID()), |
|
|
406 |
) |
|
|
407 |
if err := markdown.Convert(content, &rendered); err != nil { |
|
|
408 |
return "", "", err |
|
|
409 |
} |
|
|
410 |
return entry.Name, template.HTML(rendered.String()), nil |
|
|
411 |
} |
|
|
412 |
return "", "", nil |
|
|
413 |
} |
|
|
414 |
|
|
|
415 |
func highlight(filename string, content []byte) (template.HTML, string, error) { |
|
|
416 |
lexer := lexers.Match(filename) |
|
|
417 |
if lexer == nil { |
|
|
418 |
lexer = lexers.Analyse(string(content)) |
|
|
419 |
} |
|
|
420 |
if lexer == nil { |
|
|
421 |
lexer = lexers.Fallback |
|
|
422 |
} |
|
|
423 |
lexer = chroma.Coalesce(lexer) |
|
|
424 |
iterator, err := lexer.Tokenise(nil, string(content)) |
|
|
425 |
if err != nil { |
|
|
426 |
return "", "", err |
|
|
427 |
} |
|
|
428 |
formatter := html.New( |
|
|
429 |
html.WithClasses(true), |
|
|
430 |
html.WithLineNumbers(true), |
|
|
431 |
html.LineNumbersInTable(true), |
|
|
432 |
html.WithLinkableLineNumbers(true, "L"), |
|
|
433 |
html.TabWidth(4), |
|
|
434 |
) |
|
|
435 |
var rendered bytes.Buffer |
|
|
436 |
if err := formatter.Format(&rendered, styles.Get("github"), iterator); err != nil { |
|
|
437 |
return "", "", err |
|
|
438 |
} |
|
|
439 |
return template.HTML(rendered.String()), lexer.Config().Name, nil |
|
|
440 |
} |
|
|
441 |
|
|
|
442 |
func (g *Generator) chromaCSS() string { |
|
|
443 |
formatter := html.New(html.WithClasses(true)) |
|
|
444 |
var css bytes.Buffer |
|
|
445 |
_ = formatter.WriteCSS(&css, styles.Get("github")) |
|
|
446 |
return "\n" + css.String() |
|
|
447 |
} |
|
|
448 |
|
|
|
449 |
func (g *Generator) render(data pageData) ([]byte, error) { |
|
|
450 |
var result bytes.Buffer |
|
|
451 |
if err := g.templates.Execute(&result, data); err != nil { |
|
|
452 |
return nil, err |
|
|
453 |
} |
|
|
454 |
return result.Bytes(), nil |
|
|
455 |
} |
|
|
456 |
|
|
|
457 |
func (g *Generator) commit(ctx context.Context, oid string) (Commit, error) { |
|
|
458 |
out, err := g.Git.Run(ctx, "show", "-s", "--format=%H%x00%h%x00%an%x00%cs%x00%s", oid) |
|
|
459 |
if err != nil { |
|
|
460 |
return Commit{}, err |
|
|
461 |
} |
|
|
462 |
parts := strings.Split(strings.TrimSpace(string(out)), "\x00") |
|
|
463 |
if len(parts) != 5 { |
|
|
464 |
return Commit{}, fmt.Errorf("unexpected git show output") |
|
|
465 |
} |
|
|
466 |
return Commit{ |
|
|
467 |
OID: parts[0], |
|
|
468 |
ShortOID: parts[1], |
|
|
469 |
Author: parts[2], |
|
|
470 |
Date: parts[3], |
|
|
471 |
Message: parts[4], |
|
|
472 |
Initials: initials(parts[2]), |
|
|
473 |
}, nil |
|
|
474 |
} |
|
|
475 |
|
|
|
476 |
func (g *Generator) commitCount(ctx context.Context, oid string) (int, error) { |
|
|
477 |
out, err := g.Git.Run(ctx, "rev-list", "--count", oid) |
|
|
478 |
if err != nil { |
|
|
479 |
return 0, err |
|
|
480 |
} |
|
|
481 |
return strconv.Atoi(strings.TrimSpace(string(out))) |
|
|
482 |
} |
|
|
483 |
|
|
|
484 |
func (g *Generator) listTree(ctx context.Context, oid, dir string) ([]TreeEntry, error) { |
|
|
485 |
treeish := oid |
|
|
486 |
if dir != "" { |
|
|
487 |
treeish += ":" + dir |
|
|
488 |
} |
|
|
489 |
out, err := g.Git.Run(ctx, "ls-tree", "-z", "-l", treeish) |
|
|
490 |
if err != nil { |
|
|
491 |
return nil, err |
|
|
492 |
} |
|
|
493 |
entries, err := parseTree(out) |
|
|
494 |
if err != nil { |
|
|
495 |
return nil, err |
|
|
496 |
} |
|
|
497 |
sortTree(entries) |
|
|
498 |
return entries, nil |
|
|
499 |
} |
|
|
500 |
|
|
|
501 |
func (g *Generator) listTreeRecursive(ctx context.Context, oid string) ([]TreeEntry, error) { |
|
|
502 |
out, err := g.Git.Run(ctx, "ls-tree", "-r", "-t", "-z", "-l", oid) |
|
|
503 |
if err != nil { |
|
|
504 |
return nil, err |
|
|
505 |
} |
|
|
506 |
return parseTree(out) |
|
|
507 |
} |
|
|
508 |
|
|
|
509 |
func (g *Generator) entry(ctx context.Context, oid, name string) (TreeEntry, error) { |
|
|
510 |
out, err := g.Git.Run(ctx, "ls-tree", "-z", "-l", oid, "--", name) |
|
|
511 |
if err != nil { |
|
|
512 |
return TreeEntry{}, err |
|
|
513 |
} |
|
|
514 |
entries, err := parseTree(out) |
|
|
515 |
if err != nil { |
|
|
516 |
return TreeEntry{}, err |
|
|
517 |
} |
|
|
518 |
if len(entries) != 1 { |
|
|
519 |
return TreeEntry{}, fmt.Errorf("path %s is not a file at %s", name, short(oid)) |
|
|
520 |
} |
|
|
521 |
return entries[0], nil |
|
|
522 |
} |
|
|
523 |
|
|
|
524 |
func (g *Generator) treeExists(ctx context.Context, oid, dir string) (bool, error) { |
|
|
525 |
if dir == "" || dir == "." { |
|
|
526 |
return true, nil |
|
|
527 |
} |
|
|
528 |
out, err := g.Git.Run(ctx, "cat-file", "-t", oid+":"+dir) |
|
|
529 |
if err != nil { |
|
|
530 |
return false, nil |
|
|
531 |
} |
|
|
532 |
return strings.TrimSpace(string(out)) == "tree", nil |
|
|
533 |
} |
|
|
534 |
|
|
|
535 |
func parseTree(data []byte) ([]TreeEntry, error) { |
|
|
536 |
var result []TreeEntry |
|
|
537 |
for _, record := range bytes.Split(data, []byte{0}) { |
|
|
538 |
if len(record) == 0 { |
|
|
539 |
continue |
|
|
540 |
} |
|
|
541 |
parts := bytes.SplitN(record, []byte{'\t'}, 2) |
|
|
542 |
if len(parts) != 2 { |
|
|
543 |
return nil, fmt.Errorf("malformed ls-tree record") |
|
|
544 |
} |
|
|
545 |
fields := strings.Fields(string(parts[0])) |
|
|
546 |
if len(fields) != 4 { |
|
|
547 |
return nil, fmt.Errorf("malformed ls-tree header %q", parts[0]) |
|
|
548 |
} |
|
|
549 |
size := int64(-1) |
|
|
550 |
if fields[3] != "-" { |
|
|
551 |
var err error |
|
|
552 |
size, err = strconv.ParseInt(fields[3], 10, 64) |
|
|
553 |
if err != nil { |
|
|
554 |
return nil, err |
|
|
555 |
} |
|
|
556 |
} |
|
|
557 |
result = append(result, TreeEntry{Mode: fields[0], Type: fields[1], OID: fields[2], Size: size, Name: string(parts[1])}) |
|
|
558 |
} |
|
|
559 |
return result, nil |
|
|
560 |
} |
|
|
561 |
|
|
|
562 |
func sortTree(entries []TreeEntry) { |
|
|
563 |
sort.Slice(entries, func(i, j int) bool { |
|
|
564 |
if (entries[i].Type == "tree") != (entries[j].Type == "tree") { |
|
|
565 |
return entries[i].Type == "tree" |
|
|
566 |
} |
|
|
567 |
return strings.ToLower(entries[i].Name) < strings.ToLower(entries[j].Name) |
|
|
568 |
}) |
|
|
569 |
} |
|
|
570 |
|
|
|
571 |
func (g *Generator) breadcrumbs(branch, filePath string) []Crumb { |
|
|
572 |
crumbs := []Crumb{{Name: g.RepoName, URL: g.BaseURL}} |
|
|
573 |
parts := strings.Split(strings.Trim(filePath, "/"), "/") |
|
|
574 |
for i, part := range parts { |
|
|
575 |
if part == "" { |
|
|
576 |
continue |
|
|
577 |
} |
|
|
578 |
prefix := path.Join(parts[:i+1]...) |
|
|
579 |
routeKind := "tree" |
|
|
580 |
if i == len(parts)-1 && path.Ext(filePath) != "" { |
|
|
581 |
routeKind = "blob" |
|
|
582 |
} |
|
|
583 |
crumbs = append(crumbs, Crumb{Name: part, URL: routeURL(g.BaseURL, path.Join(routeKind, branch, prefix))}) |
|
|
584 |
} |
|
|
585 |
return crumbs |
|
|
586 |
} |
|
|
587 |
|
|
|
588 |
func routeURL(base, route string) string { |
|
|
589 |
parts := strings.Split(strings.Trim(route, "/"), "/") |
|
|
590 |
for i := range parts { |
|
|
591 |
parts[i] = url.PathEscape(parts[i]) |
|
|
592 |
} |
|
|
593 |
if len(parts) == 1 && parts[0] == "" { |
|
|
594 |
return strings.TrimRight(base, "/") |
|
|
595 |
} |
|
|
596 |
return strings.TrimRight(base, "/") + "/" + strings.Join(parts, "/") |
|
|
597 |
} |
|
|
598 |
|
|
|
599 |
func addAncestorDirs(dirs map[string]struct{}, filePath string) { |
|
|
600 |
dir := path.Dir(filePath) |
|
|
601 |
for dir != "." && dir != "/" && dir != "" { |
|
|
602 |
dirs[dir] = struct{}{} |
|
|
603 |
dir = path.Dir(dir) |
|
|
604 |
} |
|
|
605 |
dirs[""] = struct{}{} |
|
|
606 |
} |
|
|
607 |
|
|
|
608 |
func sortedKeys(values map[string]struct{}) []string { |
|
|
609 |
result := make([]string, 0, len(values)) |
|
|
610 |
for value := range values { |
|
|
611 |
result = append(result, value) |
|
|
612 |
} |
|
|
613 |
sort.Strings(result) |
|
|
614 |
return result |
|
|
615 |
} |
|
|
616 |
|
|
|
617 |
func uniqueStrings(values []string) []string { |
|
|
618 |
seen := map[string]struct{}{} |
|
|
619 |
result := make([]string, 0, len(values)) |
|
|
620 |
for _, value := range values { |
|
|
621 |
if _, ok := seen[value]; ok { |
|
|
622 |
continue |
|
|
623 |
} |
|
|
624 |
seen[value] = struct{}{} |
|
|
625 |
result = append(result, value) |
|
|
626 |
} |
|
|
627 |
sort.Strings(result) |
|
|
628 |
return result |
|
|
629 |
} |
|
|
630 |
|
|
|
631 |
func initials(name string) string { |
|
|
632 |
fields := strings.Fields(name) |
|
|
633 |
if len(fields) == 0 { |
|
|
634 |
return "?" |
|
|
635 |
} |
|
|
636 |
result := string([]rune(fields[0])[0]) |
|
|
637 |
if len(fields) > 1 { |
|
|
638 |
result += string([]rune(fields[len(fields)-1])[0]) |
|
|
639 |
} |
|
|
640 |
return strings.ToUpper(result) |
|
|
641 |
} |
|
|
642 |
|
|
|
643 |
func humanSize(size int64) string { |
|
|
644 |
if size < 0 { |
|
|
645 |
return "" |
|
|
646 |
} |
|
|
647 |
if size < 1024 { |
|
|
648 |
return fmt.Sprintf("%d B", size) |
|
|
649 |
} |
|
|
650 |
units := []string{"KB", "MB", "GB"} |
|
|
651 |
value := float64(size) |
|
|
652 |
for _, unit := range units { |
|
|
653 |
value /= 1024 |
|
|
654 |
if value < 1024 { |
|
|
655 |
return fmt.Sprintf("%.1f %s", value, unit) |
|
|
656 |
} |
|
|
657 |
} |
|
|
658 |
return fmt.Sprintf("%.1f TB", value/1024) |
|
|
659 |
} |
|
|
660 |
|
|
|
661 |
func short(oid string) string { |
|
|
662 |
if len(oid) > 7 { |
|
|
663 |
return oid[:7] |
|
|
664 |
} |
|
|
665 |
return oid |
|
|
666 |
} |
|
|
667 |
|
|
|
668 |
func (g *Generator) progress(format string, args ...any) { |
|
|
669 |
if g.Progress != nil { |
|
|
670 |
g.Progress(format, args...) |
|
|
671 |
} |
|
|
672 |
} |