package main import ( "bufio" "bytes" "fmt" "io" "os/exec" "strconv" "strings" "time" "unicode/utf8" ) // gitCLIBackend implements git operations by shelling out to the git CLI. // Used for SHA256 repos which go-git cannot handle. type gitCLIBackend struct { gitDir string } func newGitCLIBackend(gitDir string) *gitCLIBackend { return &gitCLIBackend{gitDir: gitDir} } func (g *gitCLIBackend) cmd(args ...string) (string, error) { cmd := exec.Command("git", append([]string{"--git-dir", g.gitDir}, args...)...) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr if err := cmd.Run(); err != nil { return "", fmt.Errorf("git %s: %s: %w", strings.Join(args, " "), stderr.String(), err) } return stdout.String(), nil } func (g *gitCLIBackend) pipe(args ...string) (io.ReadCloser, func() error, error) { cmd := exec.Command("git", append([]string{"--git-dir", g.gitDir}, args...)...) stdout, err := cmd.StdoutPipe() if err != nil { return nil, nil, err } if err := cmd.Start(); err != nil { return nil, nil, err } return stdout, cmd.Wait, nil } func (g *gitCLIBackend) resolveRef(refStr string) (string, error) { if refStr == "" { refStr = "HEAD" } out, err := g.cmd("rev-parse", refStr) if err != nil { return "", err } return strings.TrimSpace(out), nil } func (g *gitCLIBackend) resolveRefAndPath(segments []string) (hash string, ref string, path string, err error) { for i := 1; i <= len(segments); i++ { candidate := strings.Join(segments[:i], "/") h, err := g.resolveRef(candidate) if err == nil { rest := "" if i < len(segments) { rest = strings.Join(segments[i:], "/") } return h, candidate, rest, nil } } return "", "", "", fmt.Errorf("cannot resolve ref from path segments: %s", strings.Join(segments, "/")) } func (g *gitCLIBackend) getDefaultBranch() string { // Use HEAD's symbolic ref (the branch HEAD points to). out, err := g.cmd("symbolic-ref", "--short", "HEAD") if err == nil { branch := strings.TrimSpace(out) if _, err := g.resolveRef(branch); err == nil { return branch } } // Use init.defaultBranch from git config. out, err = g.cmd("config", "--get", "init.defaultBranch") if err == nil { branch := strings.TrimSpace(out) if _, err := g.resolveRef(branch); err == nil { return branch } } // Fall back to the first available branch. branches, err := g.getBranches() if err == nil && len(branches) > 0 { return branches[0].Name } return "master" } func (g *gitCLIBackend) getLog(hash string, page, perPage int) ([]CommitInfo, bool, error) { skip := page * perPage out, err := g.cmd("log", "--format=%H%n%h%n%an%n%ae%n%aI%n%s%n%b%x00", "--skip", strconv.Itoa(skip), "-n", strconv.Itoa(perPage+1), hash) if err != nil { return nil, false, err } entries := strings.Split(out, "\x00") var commits []CommitInfo for _, entry := range entries { entry = strings.TrimSpace(entry) if entry == "" { continue } lines := strings.SplitN(entry, "\n", 7) if len(lines) < 6 { continue } t, _ := time.Parse(time.RFC3339, strings.TrimSpace(lines[4])) body := "" if len(lines) > 6 { body = strings.TrimSpace(lines[6]) } commits = append(commits, CommitInfo{ Hash: strings.TrimSpace(lines[0]), ShortHash: strings.TrimSpace(lines[1]), Subject: strings.TrimSpace(lines[5]), Body: body, Author: strings.TrimSpace(lines[2]), AuthorEmail: strings.TrimSpace(lines[3]), AuthorDate: t, }) } if len(commits) > perPage { return commits[:perPage], true, nil } return commits, false, nil } func (g *gitCLIBackend) getTree(hash string, path string) ([]TreeEntryInfo, error) { treeish := hash if path != "" { treeish = hash + ":" + path } out, err := g.cmd("ls-tree", "-l", treeish) if err != nil { return nil, err } var entries []TreeEntryInfo scanner := bufio.NewScanner(strings.NewReader(out)) for scanner.Scan() { line := scanner.Text() // format: SP SP SP \t tabIdx := strings.IndexByte(line, '\t') if tabIdx < 0 { continue } name := line[tabIdx+1:] meta := strings.Fields(line[:tabIdx]) if len(meta) < 4 { continue } mode := meta[0] objType := meta[1] sizeStr := meta[3] isDir := objType == "tree" var size int64 if !isDir && sizeStr != "-" { size, _ = strconv.ParseInt(sizeStr, 10, 64) } objHash := meta[2] shortHash := objHash if len(shortHash) > 8 { shortHash = shortHash[:8] } entries = append(entries, TreeEntryInfo{ Name: name, IsDir: isDir, Size: size, Mode: mode, Hash: shortHash, }) } sortTreeEntries(entries) return entries, nil } func (g *gitCLIBackend) getBlob(hash string, path string) (*BlobInfo, error) { out, err := g.cmd("cat-file", "-s", hash+":"+path) if err != nil { return nil, err } size, _ := strconv.ParseInt(strings.TrimSpace(out), 10, 64) name := path if idx := strings.LastIndex(path, "/"); idx >= 0 { name = path[idx+1:] } info := &BlobInfo{ Name: name, Size: size, } if size > maxBlobDisplaySize { info.IsBinary = true return info, nil } content, err := g.cmd("cat-file", "blob", hash+":"+path) if err != nil { return nil, err } // Check binary checkLen := len(content) if checkLen > 8000 { checkLen = 8000 } if strings.Contains(content[:checkLen], "\x00") { info.IsBinary = true return info, nil } if !utf8.ValidString(content) { info.IsBinary = true return info, nil } info.Content = content info.Lines = strings.Split(content, "\n") if len(info.Lines) > 0 && info.Lines[len(info.Lines)-1] == "" { info.Lines = info.Lines[:len(info.Lines)-1] } return info, nil } func (g *gitCLIBackend) getRawBlob(hash string, path string) (io.ReadCloser, string, int64, error) { out, err := g.cmd("cat-file", "-s", hash+":"+path) if err != nil { return nil, "", 0, err } size, _ := strconv.ParseInt(strings.TrimSpace(out), 10, 64) ct := "application/octet-stream" if mtype := mimeFromPath(path); mtype != "" { ct = mtype } reader, wait, err := g.pipe("cat-file", "blob", hash+":"+path) if err != nil { return nil, "", 0, err } return &waitReadCloser{reader: reader, wait: wait}, ct, size, nil } type waitReadCloser struct { reader io.ReadCloser wait func() error } func (w *waitReadCloser) Read(p []byte) (int, error) { return w.reader.Read(p) } func (w *waitReadCloser) Close() error { err := w.reader.Close() w.wait() return err } func (g *gitCLIBackend) getCommit(hash string) (*CommitInfo, error) { out, err := g.cmd("log", "-1", "--format=%H%n%h%n%an%n%ae%n%aI%n%P%n%s%n%b", hash) if err != nil { return nil, err } lines := strings.SplitN(strings.TrimRight(out, "\n"), "\n", 8) if len(lines) < 7 { return nil, fmt.Errorf("unexpected git log output") } t, _ := time.Parse(time.RFC3339, strings.TrimSpace(lines[4])) var parents []string if p := strings.TrimSpace(lines[5]); p != "" { parents = strings.Fields(p) } body := "" if len(lines) > 7 { body = strings.TrimSpace(lines[7]) } return &CommitInfo{ Hash: strings.TrimSpace(lines[0]), ShortHash: strings.TrimSpace(lines[1]), Subject: strings.TrimSpace(lines[6]), Body: body, Author: strings.TrimSpace(lines[2]), AuthorEmail: strings.TrimSpace(lines[3]), AuthorDate: t, Parents: parents, }, nil } func (g *gitCLIBackend) getDiff(hash string) ([]DiffFile, error) { var out string var err error // Check if this is a root commit (no parents) parentOut, perr := g.cmd("rev-parse", hash+"^") if perr != nil { // Root commit: diff against empty tree out, err = g.cmd("diff-tree", "-p", "--no-commit-id", "--root", "-U1000000", hash) } else { parent := strings.TrimSpace(parentOut) out, err = g.cmd("diff", "-U1000000", parent, hash) } if err != nil { return nil, err } return parseDiffOutput(out), nil } func parseDiffOutput(output string) []DiffFile { var files []DiffFile parts := strings.Split(output, "diff --git ") for _, part := range parts[1:] { df := parseDiffFilePatch(part) files = append(files, df) } return files } func parseDiffFilePatch(patch string) DiffFile { lines := strings.SplitAfter(patch, "\n") df := DiffFile{} // Parse header: first line is "a/... b/..." if len(lines) > 0 { header := strings.TrimSpace(lines[0]) parts := strings.SplitN(header, " ", 2) if len(parts) == 2 { df.OldName = strings.TrimPrefix(parts[0], "a/") df.NewName = strings.TrimPrefix(parts[1], "b/") } } // Look for status indicators and binary i := 1 for i < len(lines) { line := strings.TrimRight(lines[i], "\n") if strings.HasPrefix(line, "new file") { df.Status = "A" } else if strings.HasPrefix(line, "deleted file") { df.Status = "D" } else if strings.HasPrefix(line, "rename from") { df.Status = "R" } else if strings.HasPrefix(line, "Binary files") { df.IsBinary = true if df.Status == "" { df.Status = "M" } return df } else if strings.HasPrefix(line, "@@") || strings.HasPrefix(line, "---") { break } i++ } if df.Status == "" { df.Status = "M" } // Skip --- and +++ lines for i < len(lines) { line := strings.TrimRight(lines[i], "\n") if strings.HasPrefix(line, "---") || strings.HasPrefix(line, "+++") { i++ } else { break } } // Parse diff lines var allLines []DiffLine oldNum, newNum := 1, 1 for i < len(lines) { line := strings.TrimRight(lines[i], "\n") if strings.HasPrefix(line, "@@") { if parts := parseHunkHeader(line); parts != nil { oldNum = parts[0] newNum = parts[1] } i++ continue } if len(line) == 0 && i == len(lines)-1 { i++ continue } if len(line) > 0 { switch line[0] { case '+': df.Stats.Added++ allLines = append(allLines, DiffLine{Type: "add", Content: line[1:], NewNum: newNum}) newNum++ case '-': df.Stats.Deleted++ allLines = append(allLines, DiffLine{Type: "del", Content: line[1:], OldNum: oldNum}) oldNum++ case '\\': // "\ No newline at end of file" — skip default: content := line if len(content) > 0 && content[0] == ' ' { content = content[1:] } allLines = append(allLines, DiffLine{Type: "context", Content: content, OldNum: oldNum, NewNum: newNum}) oldNum++ newNum++ } } else { allLines = append(allLines, DiffLine{Type: "context", Content: "", OldNum: oldNum, NewNum: newNum}) oldNum++ newNum++ } i++ } if len(allLines) > 0 { df.Hunks = collapseContext(allLines) } return df } func parseHunkHeader(line string) []int { // @@ -1,5 +1,7 @@ at := strings.Index(line, "@@") if at < 0 { return nil } rest := line[at+2:] at2 := strings.Index(rest, "@@") if at2 < 0 { return nil } spec := strings.TrimSpace(rest[:at2]) parts := strings.Fields(spec) if len(parts) < 2 { return nil } oldStart := 1 newStart := 1 old := strings.TrimPrefix(parts[0], "-") if comma := strings.IndexByte(old, ','); comma >= 0 { oldStart, _ = strconv.Atoi(old[:comma]) } else { oldStart, _ = strconv.Atoi(old) } nw := strings.TrimPrefix(parts[1], "+") if comma := strings.IndexByte(nw, ','); comma >= 0 { newStart, _ = strconv.Atoi(nw[:comma]) } else { newStart, _ = strconv.Atoi(nw) } return []int{oldStart, newStart} } func (g *gitCLIBackend) getBranches() ([]RefInfo, error) { out, err := g.cmd("for-each-ref", "--sort=-committerdate", "--format=%(refname:short)%09%(objectname)%09%(objectname:short)%09%(subject)%09%(authorname)%09%(creatordate:iso-strict)", "refs/heads/") if err != nil { return nil, err } branches := parseRefLines(out, false) // Move the default branch to the front of the list. defaultBranch := g.getDefaultBranch() for i, b := range branches { if b.Name == defaultBranch && i > 0 { branches = append([]RefInfo{b}, append(branches[:i], branches[i+1:]...)...) break } } return branches, nil } func (g *gitCLIBackend) getTags() ([]RefInfo, error) { // For tags, use %(*objectname) to dereference annotated tags to their commit. // For lightweight tags, %(*objectname) is empty so we fall back to %(objectname). out, err := g.cmd("for-each-ref", "--sort=-creatordate", "--format=%(refname:short)%09%(objectname)%09%(objectname:short)%09%(*objectname)%09%(subject)%09%(authorname)%09%(creatordate:iso-strict)", "refs/tags/") if err != nil { return nil, err } return parseTagRefLines(out), nil } func parseRefLines(output string, isTag bool) []RefInfo { var refs []RefInfo scanner := bufio.NewScanner(strings.NewReader(output)) for scanner.Scan() { line := scanner.Text() if line == "" { continue } parts := strings.SplitN(line, "\t", 6) if len(parts) < 6 { continue } t, _ := time.Parse(time.RFC3339, strings.TrimSpace(parts[5])) hash := strings.TrimSpace(parts[1]) shortHash := strings.TrimSpace(parts[2]) if len(shortHash) > 8 { shortHash = shortHash[:8] } refs = append(refs, RefInfo{ Name: strings.TrimSpace(parts[0]), Hash: hash, ShortHash: shortHash, Subject: strings.TrimSpace(parts[3]), Author: strings.TrimSpace(parts[4]), Date: t, IsTag: isTag, }) } return refs } func parseTagRefLines(output string) []RefInfo { var refs []RefInfo scanner := bufio.NewScanner(strings.NewReader(output)) for scanner.Scan() { line := scanner.Text() if line == "" { continue } parts := strings.SplitN(line, "\t", 7) if len(parts) < 7 { continue } t, _ := time.Parse(time.RFC3339, strings.TrimSpace(parts[6])) hash := strings.TrimSpace(parts[1]) shortHash := strings.TrimSpace(parts[2]) deref := strings.TrimSpace(parts[3]) // If annotated tag, use dereferenced commit hash if deref != "" { hash = deref if len(hash) > 8 { shortHash = hash[:8] } } if len(shortHash) > 8 { shortHash = shortHash[:8] } refs = append(refs, RefInfo{ Name: strings.TrimSpace(parts[0]), Hash: hash, ShortHash: shortHash, Subject: strings.TrimSpace(parts[4]), Author: strings.TrimSpace(parts[5]), Date: t, IsTag: true, }) } return refs } func (g *gitCLIBackend) isTreePath(hash string, path string) bool { if path == "" { return true } out, err := g.cmd("cat-file", "-t", hash+":"+path) if err != nil { return false } return strings.TrimSpace(out) == "tree" } func (g *gitCLIBackend) buildTreeNodes(hash string, activePath string) []TreeNode { var activeComponents []string if activePath != "" { activeComponents = strings.Split(activePath, "/") } return g.buildTreeLevel(hash, "", 0, activeComponents) } func (g *gitCLIBackend) buildTreeLevel(hash string, basePath string, depth int, activeComponents []string) []TreeNode { entries, err := g.getTree(hash, basePath) if err != nil { return nil } var nodes []TreeNode for _, e := range entries { fullPath := e.Name if basePath != "" { fullPath = basePath + "/" + e.Name } isOpen := false isActive := false if len(activeComponents) > 0 && e.Name == activeComponents[0] { if e.IsDir { isOpen = true if len(activeComponents) == 1 { isActive = true } } else if len(activeComponents) == 1 { isActive = true } } nodes = append(nodes, TreeNode{ Name: e.Name, IsDir: e.IsDir, Size: e.Size, Path: fullPath, Depth: depth, IsOpen: isOpen, IsActive: isActive, }) if isOpen { var childActive []string if len(activeComponents) > 1 { childActive = activeComponents[1:] } children := g.buildTreeLevel(hash, fullPath, depth+1, childActive) nodes = append(nodes, children...) } } return nodes } func (g *gitCLIBackend) getReadme(hash string, dir string) *BlobInfo { treeish := hash if dir != "" { treeish = hash + ":" + dir } out, err := g.cmd("ls-tree", treeish) if err != nil { return nil } scanner := bufio.NewScanner(strings.NewReader(out)) for scanner.Scan() { line := scanner.Text() tabIdx := strings.IndexByte(line, '\t') if tabIdx < 0 { continue } name := line[tabIdx+1:] lower := strings.ToLower(name) if lower == "readme" || lower == "readme.md" || lower == "readme.txt" { path := name if dir != "" { path = dir + "/" + name } blob, err := g.getBlob(hash, path) if err != nil { return nil } return blob } } return nil }