git_cli.go 16.0 KiB raw
1
package main
2
3
import (
4
	"bufio"
5
	"bytes"
6
	"fmt"
7
	"io"
8
	"os/exec"
9
	"strconv"
10
	"strings"
11
	"time"
12
	"unicode/utf8"
13
)
14
15
// gitCLIBackend implements git operations by shelling out to the git CLI.
16
// Used for SHA256 repos which go-git cannot handle.
17
type gitCLIBackend struct {
18
	gitDir string
19
}
20
21
func newGitCLIBackend(gitDir string) *gitCLIBackend {
22
	return &gitCLIBackend{gitDir: gitDir}
23
}
24
25
func (g *gitCLIBackend) cmd(args ...string) (string, error) {
26
	cmd := exec.Command("git", append([]string{"--git-dir", g.gitDir}, args...)...)
27
	var stdout, stderr bytes.Buffer
28
	cmd.Stdout = &stdout
29
	cmd.Stderr = &stderr
30
	if err := cmd.Run(); err != nil {
31
		return "", fmt.Errorf("git %s: %s: %w", strings.Join(args, " "), stderr.String(), err)
32
	}
33
	return stdout.String(), nil
34
}
35
36
func (g *gitCLIBackend) pipe(args ...string) (io.ReadCloser, func() error, error) {
37
	cmd := exec.Command("git", append([]string{"--git-dir", g.gitDir}, args...)...)
38
	stdout, err := cmd.StdoutPipe()
39
	if err != nil {
40
		return nil, nil, err
41
	}
42
	if err := cmd.Start(); err != nil {
43
		return nil, nil, err
44
	}
45
	return stdout, cmd.Wait, nil
46
}
47
48
func (g *gitCLIBackend) resolveRef(refStr string) (string, error) {
49
	if refStr == "" {
50
		refStr = "HEAD"
51
	}
52
	out, err := g.cmd("rev-parse", refStr)
53
	if err != nil {
54
		return "", err
55
	}
56
	return strings.TrimSpace(out), nil
57
}
58
59
func (g *gitCLIBackend) resolveRefAndPath(segments []string) (hash string, ref string, path string, err error) {
60
	for i := 1; i <= len(segments); i++ {
61
		candidate := strings.Join(segments[:i], "/")
62
		h, err := g.resolveRef(candidate)
63
		if err == nil {
64
			rest := ""
65
			if i < len(segments) {
66
				rest = strings.Join(segments[i:], "/")
67
			}
68
			return h, candidate, rest, nil
69
		}
70
	}
71
	return "", "", "", fmt.Errorf("cannot resolve ref from path segments: %s", strings.Join(segments, "/"))
72
}
73
74
func (g *gitCLIBackend) getDefaultBranch() string {
75
	// Use HEAD's symbolic ref (the branch HEAD points to).
76
	out, err := g.cmd("symbolic-ref", "--short", "HEAD")
77
	if err == nil {
78
		branch := strings.TrimSpace(out)
79
		if _, err := g.resolveRef(branch); err == nil {
80
			return branch
81
		}
82
	}
83
84
	// Use init.defaultBranch from git config.
85
	out, err = g.cmd("config", "--get", "init.defaultBranch")
86
	if err == nil {
87
		branch := strings.TrimSpace(out)
88
		if _, err := g.resolveRef(branch); err == nil {
89
			return branch
90
		}
91
	}
92
93
	// Fall back to the first available branch.
94
	branches, err := g.getBranches()
95
	if err == nil && len(branches) > 0 {
96
		return branches[0].Name
97
	}
98
	return "master"
99
}
100
101
func (g *gitCLIBackend) getLog(hash string, page, perPage int) ([]CommitInfo, bool, error) {
102
	skip := page * perPage
103
	out, err := g.cmd("log", "--format=%H%n%h%n%an%n%ae%n%aI%n%s%n%b%x00",
104
		"--skip", strconv.Itoa(skip), "-n", strconv.Itoa(perPage+1), hash)
105
	if err != nil {
106
		return nil, false, err
107
	}
108
109
	entries := strings.Split(out, "\x00")
110
	var commits []CommitInfo
111
	for _, entry := range entries {
112
		entry = strings.TrimSpace(entry)
113
		if entry == "" {
114
			continue
115
		}
116
		lines := strings.SplitN(entry, "\n", 7)
117
		if len(lines) < 6 {
118
			continue
119
		}
120
		t, _ := time.Parse(time.RFC3339, strings.TrimSpace(lines[4]))
121
		body := ""
122
		if len(lines) > 6 {
123
			body = strings.TrimSpace(lines[6])
124
		}
125
		commits = append(commits, CommitInfo{
126
			Hash:        strings.TrimSpace(lines[0]),
127
			ShortHash:   strings.TrimSpace(lines[1]),
128
			Subject:     strings.TrimSpace(lines[5]),
129
			Body:        body,
130
			Author:      strings.TrimSpace(lines[2]),
131
			AuthorEmail: strings.TrimSpace(lines[3]),
132
			AuthorDate:  t,
133
		})
134
	}
135
136
	if len(commits) > perPage {
137
		return commits[:perPage], true, nil
138
	}
139
	return commits, false, nil
140
}
141
142
func (g *gitCLIBackend) getTree(hash string, path string) ([]TreeEntryInfo, error) {
143
	treeish := hash
144
	if path != "" {
145
		treeish = hash + ":" + path
146
	}
147
	out, err := g.cmd("ls-tree", "-l", treeish)
148
	if err != nil {
149
		return nil, err
150
	}
151
152
	var entries []TreeEntryInfo
153
	scanner := bufio.NewScanner(strings.NewReader(out))
154
	for scanner.Scan() {
155
		line := scanner.Text()
156
		// format: <mode> SP <type> SP <hash> SP <size>\t<name>
157
		tabIdx := strings.IndexByte(line, '\t')
158
		if tabIdx < 0 {
159
			continue
160
		}
161
		name := line[tabIdx+1:]
162
		meta := strings.Fields(line[:tabIdx])
163
		if len(meta) < 4 {
164
			continue
165
		}
166
		mode := meta[0]
167
		objType := meta[1]
168
		sizeStr := meta[3]
169
170
		isDir := objType == "tree"
171
		var size int64
172
		if !isDir && sizeStr != "-" {
173
			size, _ = strconv.ParseInt(sizeStr, 10, 64)
174
		}
175
176
		objHash := meta[2]
177
		shortHash := objHash
178
		if len(shortHash) > 8 {
179
			shortHash = shortHash[:8]
180
		}
181
182
		entries = append(entries, TreeEntryInfo{
183
			Name:  name,
184
			IsDir: isDir,
185
			Size:  size,
186
			Mode:  mode,
187
			Hash:  shortHash,
188
		})
189
	}
190
191
	sortTreeEntries(entries)
192
	return entries, nil
193
}
194
195
func (g *gitCLIBackend) getBlob(hash string, path string) (*BlobInfo, error) {
196
	out, err := g.cmd("cat-file", "-s", hash+":"+path)
197
	if err != nil {
198
		return nil, err
199
	}
200
	size, _ := strconv.ParseInt(strings.TrimSpace(out), 10, 64)
201
202
	name := path
203
	if idx := strings.LastIndex(path, "/"); idx >= 0 {
204
		name = path[idx+1:]
205
	}
206
207
	info := &BlobInfo{
208
		Name: name,
209
		Size: size,
210
	}
211
212
	if size > maxBlobDisplaySize {
213
		info.IsBinary = true
214
		return info, nil
215
	}
216
217
	content, err := g.cmd("cat-file", "blob", hash+":"+path)
218
	if err != nil {
219
		return nil, err
220
	}
221
222
	// Check binary
223
	checkLen := len(content)
224
	if checkLen > 8000 {
225
		checkLen = 8000
226
	}
227
	if strings.Contains(content[:checkLen], "\x00") {
228
		info.IsBinary = true
229
		return info, nil
230
	}
231
232
	if !utf8.ValidString(content) {
233
		info.IsBinary = true
234
		return info, nil
235
	}
236
237
	info.Content = content
238
	info.Lines = strings.Split(content, "\n")
239
	if len(info.Lines) > 0 && info.Lines[len(info.Lines)-1] == "" {
240
		info.Lines = info.Lines[:len(info.Lines)-1]
241
	}
242
	return info, nil
243
}
244
245
func (g *gitCLIBackend) getRawBlob(hash string, path string) (io.ReadCloser, string, int64, error) {
246
	out, err := g.cmd("cat-file", "-s", hash+":"+path)
247
	if err != nil {
248
		return nil, "", 0, err
249
	}
250
	size, _ := strconv.ParseInt(strings.TrimSpace(out), 10, 64)
251
252
	ct := "application/octet-stream"
253
	if mtype := mimeFromPath(path); mtype != "" {
254
		ct = mtype
255
	}
256
257
	reader, wait, err := g.pipe("cat-file", "blob", hash+":"+path)
258
	if err != nil {
259
		return nil, "", 0, err
260
	}
261
262
	return &waitReadCloser{reader: reader, wait: wait}, ct, size, nil
263
}
264
265
type waitReadCloser struct {
266
	reader io.ReadCloser
267
	wait   func() error
268
}
269
270
func (w *waitReadCloser) Read(p []byte) (int, error) {
271
	return w.reader.Read(p)
272
}
273
274
func (w *waitReadCloser) Close() error {
275
	err := w.reader.Close()
276
	w.wait()
277
	return err
278
}
279
280
func (g *gitCLIBackend) getCommit(hash string) (*CommitInfo, error) {
281
	out, err := g.cmd("log", "-1", "--format=%H%n%h%n%an%n%ae%n%aI%n%P%n%s%n%b", hash)
282
	if err != nil {
283
		return nil, err
284
	}
285
286
	lines := strings.SplitN(strings.TrimRight(out, "\n"), "\n", 8)
287
	if len(lines) < 7 {
288
		return nil, fmt.Errorf("unexpected git log output")
289
	}
290
291
	t, _ := time.Parse(time.RFC3339, strings.TrimSpace(lines[4]))
292
293
	var parents []string
294
	if p := strings.TrimSpace(lines[5]); p != "" {
295
		parents = strings.Fields(p)
296
	}
297
298
	body := ""
299
	if len(lines) > 7 {
300
		body = strings.TrimSpace(lines[7])
301
	}
302
303
	return &CommitInfo{
304
		Hash:        strings.TrimSpace(lines[0]),
305
		ShortHash:   strings.TrimSpace(lines[1]),
306
		Subject:     strings.TrimSpace(lines[6]),
307
		Body:        body,
308
		Author:      strings.TrimSpace(lines[2]),
309
		AuthorEmail: strings.TrimSpace(lines[3]),
310
		AuthorDate:  t,
311
		Parents:     parents,
312
	}, nil
313
}
314
315
func (g *gitCLIBackend) getDiff(hash string) ([]DiffFile, error) {
316
	var out string
317
	var err error
318
319
	// Check if this is a root commit (no parents)
320
	parentOut, perr := g.cmd("rev-parse", hash+"^")
321
	if perr != nil {
322
		// Root commit: diff against empty tree
323
		out, err = g.cmd("diff-tree", "-p", "--no-commit-id", "--root", "-U1000000", hash)
324
	} else {
325
		parent := strings.TrimSpace(parentOut)
326
		out, err = g.cmd("diff", "-U1000000", parent, hash)
327
	}
328
	if err != nil {
329
		return nil, err
330
	}
331
332
	return parseDiffOutput(out), nil
333
}
334
335
func parseDiffOutput(output string) []DiffFile {
336
	var files []DiffFile
337
	parts := strings.Split(output, "diff --git ")
338
	for _, part := range parts[1:] {
339
		df := parseDiffFilePatch(part)
340
		files = append(files, df)
341
	}
342
	return files
343
}
344
345
func parseDiffFilePatch(patch string) DiffFile {
346
	lines := strings.SplitAfter(patch, "\n")
347
	df := DiffFile{}
348
349
	// Parse header: first line is "a/... b/..."
350
	if len(lines) > 0 {
351
		header := strings.TrimSpace(lines[0])
352
		parts := strings.SplitN(header, " ", 2)
353
		if len(parts) == 2 {
354
			df.OldName = strings.TrimPrefix(parts[0], "a/")
355
			df.NewName = strings.TrimPrefix(parts[1], "b/")
356
		}
357
	}
358
359
	// Look for status indicators and binary
360
	i := 1
361
	for i < len(lines) {
362
		line := strings.TrimRight(lines[i], "\n")
363
		if strings.HasPrefix(line, "new file") {
364
			df.Status = "A"
365
		} else if strings.HasPrefix(line, "deleted file") {
366
			df.Status = "D"
367
		} else if strings.HasPrefix(line, "rename from") {
368
			df.Status = "R"
369
		} else if strings.HasPrefix(line, "Binary files") {
370
			df.IsBinary = true
371
			if df.Status == "" {
372
				df.Status = "M"
373
			}
374
			return df
375
		} else if strings.HasPrefix(line, "@@") || strings.HasPrefix(line, "---") {
376
			break
377
		}
378
		i++
379
	}
380
	if df.Status == "" {
381
		df.Status = "M"
382
	}
383
384
	// Skip --- and +++ lines
385
	for i < len(lines) {
386
		line := strings.TrimRight(lines[i], "\n")
387
		if strings.HasPrefix(line, "---") || strings.HasPrefix(line, "+++") {
388
			i++
389
		} else {
390
			break
391
		}
392
	}
393
394
	// Parse diff lines
395
	var allLines []DiffLine
396
	oldNum, newNum := 1, 1
397
	for i < len(lines) {
398
		line := strings.TrimRight(lines[i], "\n")
399
		if strings.HasPrefix(line, "@@") {
400
			if parts := parseHunkHeader(line); parts != nil {
401
				oldNum = parts[0]
402
				newNum = parts[1]
403
			}
404
			i++
405
			continue
406
		}
407
408
		if len(line) == 0 && i == len(lines)-1 {
409
			i++
410
			continue
411
		}
412
413
		if len(line) > 0 {
414
			switch line[0] {
415
			case '+':
416
				df.Stats.Added++
417
				allLines = append(allLines, DiffLine{Type: "add", Content: line[1:], NewNum: newNum})
418
				newNum++
419
			case '-':
420
				df.Stats.Deleted++
421
				allLines = append(allLines, DiffLine{Type: "del", Content: line[1:], OldNum: oldNum})
422
				oldNum++
423
			case '\\':
424
				// "\ No newline at end of file" — skip
425
			default:
426
				content := line
427
				if len(content) > 0 && content[0] == ' ' {
428
					content = content[1:]
429
				}
430
				allLines = append(allLines, DiffLine{Type: "context", Content: content, OldNum: oldNum, NewNum: newNum})
431
				oldNum++
432
				newNum++
433
			}
434
		} else {
435
			allLines = append(allLines, DiffLine{Type: "context", Content: "", OldNum: oldNum, NewNum: newNum})
436
			oldNum++
437
			newNum++
438
		}
439
		i++
440
	}
441
442
	if len(allLines) > 0 {
443
		df.Hunks = collapseContext(allLines)
444
	}
445
446
	return df
447
}
448
449
func parseHunkHeader(line string) []int {
450
	// @@ -1,5 +1,7 @@
451
	at := strings.Index(line, "@@")
452
	if at < 0 {
453
		return nil
454
	}
455
	rest := line[at+2:]
456
	at2 := strings.Index(rest, "@@")
457
	if at2 < 0 {
458
		return nil
459
	}
460
	spec := strings.TrimSpace(rest[:at2])
461
	parts := strings.Fields(spec)
462
	if len(parts) < 2 {
463
		return nil
464
	}
465
466
	oldStart := 1
467
	newStart := 1
468
469
	old := strings.TrimPrefix(parts[0], "-")
470
	if comma := strings.IndexByte(old, ','); comma >= 0 {
471
		oldStart, _ = strconv.Atoi(old[:comma])
472
	} else {
473
		oldStart, _ = strconv.Atoi(old)
474
	}
475
476
	nw := strings.TrimPrefix(parts[1], "+")
477
	if comma := strings.IndexByte(nw, ','); comma >= 0 {
478
		newStart, _ = strconv.Atoi(nw[:comma])
479
	} else {
480
		newStart, _ = strconv.Atoi(nw)
481
	}
482
483
	return []int{oldStart, newStart}
484
}
485
486
func (g *gitCLIBackend) getBranches() ([]RefInfo, error) {
487
	out, err := g.cmd("for-each-ref", "--sort=-committerdate",
488
		"--format=%(refname:short)%09%(objectname)%09%(objectname:short)%09%(subject)%09%(authorname)%09%(creatordate:iso-strict)",
489
		"refs/heads/")
490
	if err != nil {
491
		return nil, err
492
	}
493
	branches := parseRefLines(out, false)
494
495
	// Move the default branch to the front of the list.
496
	defaultBranch := g.getDefaultBranch()
497
	for i, b := range branches {
498
		if b.Name == defaultBranch && i > 0 {
499
			branches = append([]RefInfo{b}, append(branches[:i], branches[i+1:]...)...)
500
			break
501
		}
502
	}
503
	return branches, nil
504
}
505
506
func (g *gitCLIBackend) getTags() ([]RefInfo, error) {
507
	// For tags, use %(*objectname) to dereference annotated tags to their commit.
508
	// For lightweight tags, %(*objectname) is empty so we fall back to %(objectname).
509
	out, err := g.cmd("for-each-ref", "--sort=-creatordate",
510
		"--format=%(refname:short)%09%(objectname)%09%(objectname:short)%09%(*objectname)%09%(subject)%09%(authorname)%09%(creatordate:iso-strict)",
511
		"refs/tags/")
512
	if err != nil {
513
		return nil, err
514
	}
515
	return parseTagRefLines(out), nil
516
}
517
518
func parseRefLines(output string, isTag bool) []RefInfo {
519
	var refs []RefInfo
520
	scanner := bufio.NewScanner(strings.NewReader(output))
521
	for scanner.Scan() {
522
		line := scanner.Text()
523
		if line == "" {
524
			continue
525
		}
526
		parts := strings.SplitN(line, "\t", 6)
527
		if len(parts) < 6 {
528
			continue
529
		}
530
		t, _ := time.Parse(time.RFC3339, strings.TrimSpace(parts[5]))
531
532
		hash := strings.TrimSpace(parts[1])
533
		shortHash := strings.TrimSpace(parts[2])
534
		if len(shortHash) > 8 {
535
			shortHash = shortHash[:8]
536
		}
537
538
		refs = append(refs, RefInfo{
539
			Name:      strings.TrimSpace(parts[0]),
540
			Hash:      hash,
541
			ShortHash: shortHash,
542
			Subject:   strings.TrimSpace(parts[3]),
543
			Author:    strings.TrimSpace(parts[4]),
544
			Date:      t,
545
			IsTag:     isTag,
546
		})
547
	}
548
	return refs
549
}
550
551
func parseTagRefLines(output string) []RefInfo {
552
	var refs []RefInfo
553
	scanner := bufio.NewScanner(strings.NewReader(output))
554
	for scanner.Scan() {
555
		line := scanner.Text()
556
		if line == "" {
557
			continue
558
		}
559
		parts := strings.SplitN(line, "\t", 7)
560
		if len(parts) < 7 {
561
			continue
562
		}
563
		t, _ := time.Parse(time.RFC3339, strings.TrimSpace(parts[6]))
564
565
		hash := strings.TrimSpace(parts[1])
566
		shortHash := strings.TrimSpace(parts[2])
567
		deref := strings.TrimSpace(parts[3])
568
569
		// If annotated tag, use dereferenced commit hash
570
		if deref != "" {
571
			hash = deref
572
			if len(hash) > 8 {
573
				shortHash = hash[:8]
574
			}
575
		}
576
		if len(shortHash) > 8 {
577
			shortHash = shortHash[:8]
578
		}
579
580
		refs = append(refs, RefInfo{
581
			Name:      strings.TrimSpace(parts[0]),
582
			Hash:      hash,
583
			ShortHash: shortHash,
584
			Subject:   strings.TrimSpace(parts[4]),
585
			Author:    strings.TrimSpace(parts[5]),
586
			Date:      t,
587
			IsTag:     true,
588
		})
589
	}
590
	return refs
591
}
592
593
func (g *gitCLIBackend) isTreePath(hash string, path string) bool {
594
	if path == "" {
595
		return true
596
	}
597
	out, err := g.cmd("cat-file", "-t", hash+":"+path)
598
	if err != nil {
599
		return false
600
	}
601
	return strings.TrimSpace(out) == "tree"
602
}
603
604
func (g *gitCLIBackend) buildTreeNodes(hash string, activePath string) []TreeNode {
605
	var activeComponents []string
606
	if activePath != "" {
607
		activeComponents = strings.Split(activePath, "/")
608
	}
609
	return g.buildTreeLevel(hash, "", 0, activeComponents)
610
}
611
612
func (g *gitCLIBackend) buildTreeLevel(hash string, basePath string, depth int, activeComponents []string) []TreeNode {
613
	entries, err := g.getTree(hash, basePath)
614
	if err != nil {
615
		return nil
616
	}
617
618
	var nodes []TreeNode
619
	for _, e := range entries {
620
		fullPath := e.Name
621
		if basePath != "" {
622
			fullPath = basePath + "/" + e.Name
623
		}
624
625
		isOpen := false
626
		isActive := false
627
628
		if len(activeComponents) > 0 && e.Name == activeComponents[0] {
629
			if e.IsDir {
630
				isOpen = true
631
				if len(activeComponents) == 1 {
632
					isActive = true
633
				}
634
			} else if len(activeComponents) == 1 {
635
				isActive = true
636
			}
637
		}
638
639
		nodes = append(nodes, TreeNode{
640
			Name:     e.Name,
641
			IsDir:    e.IsDir,
642
			Size:     e.Size,
643
			Path:     fullPath,
644
			Depth:    depth,
645
			IsOpen:   isOpen,
646
			IsActive: isActive,
647
		})
648
649
		if isOpen {
650
			var childActive []string
651
			if len(activeComponents) > 1 {
652
				childActive = activeComponents[1:]
653
			}
654
			children := g.buildTreeLevel(hash, fullPath, depth+1, childActive)
655
			nodes = append(nodes, children...)
656
		}
657
	}
658
	return nodes
659
}
660
661
func (g *gitCLIBackend) getReadme(hash string, dir string) *BlobInfo {
662
	treeish := hash
663
	if dir != "" {
664
		treeish = hash + ":" + dir
665
	}
666
	out, err := g.cmd("ls-tree", treeish)
667
	if err != nil {
668
		return nil
669
	}
670
671
	scanner := bufio.NewScanner(strings.NewReader(out))
672
	for scanner.Scan() {
673
		line := scanner.Text()
674
		tabIdx := strings.IndexByte(line, '\t')
675
		if tabIdx < 0 {
676
			continue
677
		}
678
		name := line[tabIdx+1:]
679
		lower := strings.ToLower(name)
680
		if lower == "readme" || lower == "readme.md" || lower == "readme.txt" {
681
			path := name
682
			if dir != "" {
683
				path = dir + "/" + name
684
			}
685
			blob, err := g.getBlob(hash, path)
686
			if err != nil {
687
				return nil
688
			}
689
			return blob
690
		}
691
	}
692
	return nil
693
}