blob: f8a1807b1b226c3569e1662c9d41b8cdfdffe166 [file] [log] [blame]
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00001// Package git_tools provides utilities for interacting with Git repositories.
2package git_tools
3
4import (
5 "bufio"
6 "fmt"
7 "os/exec"
8 "strings"
9)
10
11// DiffFile represents a file in a Git diff
12type DiffFile struct {
13 Path string `json:"path"`
14 OldMode string `json:"old_mode"`
15 NewMode string `json:"new_mode"`
16 OldHash string `json:"old_hash"`
17 NewHash string `json:"new_hash"`
18 Status string `json:"status"` // A=added, M=modified, D=deleted, etc.
19} // GitRawDiff returns a structured representation of the Git diff between two commits or references
20func GitRawDiff(repoDir, from, to string) ([]DiffFile, error) {
21 // Git command to generate the diff in raw format with full hashes
22 cmd := exec.Command("git", "-C", repoDir, "diff", "--raw", "--abbrev=40", from, to)
23 out, err := cmd.CombinedOutput()
24 if err != nil {
25 return nil, fmt.Errorf("error executing git diff: %w - %s", err, string(out))
26 }
27
28 // Parse the raw diff output into structured format
29 return parseRawDiff(string(out))
30}
31
32// GitShow returns the result of git show for a specific commit hash
33func GitShow(repoDir, hash string) (string, error) {
34 cmd := exec.Command("git", "-C", repoDir, "show", hash)
35 out, err := cmd.CombinedOutput()
36 if err != nil {
37 return "", fmt.Errorf("error executing git show: %w - %s", err, string(out))
38 }
39 return string(out), nil
40}
41
42// parseRawDiff converts git diff --raw output into structured format
43func parseRawDiff(diffOutput string) ([]DiffFile, error) {
44 var files []DiffFile
45 if diffOutput == "" {
46 return files, nil
47 }
48
49 // Process diff output line by line
50 scanner := bufio.NewScanner(strings.NewReader(strings.TrimSpace(diffOutput)))
51 for scanner.Scan() {
52 line := scanner.Text()
53 // Format: :oldmode newmode oldhash newhash status\tpath
54 // Example: :000000 100644 0000000000000000000000000000000000000000 6b33680ae6de90edd5f627c84147f7a41aa9d9cf A git_tools/git_tools.go
55 if !strings.HasPrefix(line, ":") {
56 continue
57 }
58
59 parts := strings.Fields(line[1:]) // Skip the leading colon
60 if len(parts) < 5 {
61 continue // Not enough parts, skip this line
62 }
63
64 oldMode := parts[0]
65 newMode := parts[1]
66 oldHash := parts[2]
67 newHash := parts[3]
68 status := parts[4]
69
70 // The path is everything after the status character and tab
71 pathIndex := strings.Index(line, status) + len(status) + 1 // +1 for the tab
72 path := ""
73 if pathIndex < len(line) {
74 path = strings.TrimSpace(line[pathIndex:])
75 }
76
77 files = append(files, DiffFile{
78 Path: path,
79 OldMode: oldMode,
80 NewMode: newMode,
81 OldHash: oldHash,
82 NewHash: newHash,
83 Status: status,
84 })
85 }
86
87 return files, nil
88}
89
90// LogEntry represents a single entry in the git log
91type LogEntry struct {
92 Hash string `json:"hash"` // The full commit hash
93 Refs []string `json:"refs"` // References (branches, tags) pointing to this commit
94 Subject string `json:"subject"` // The commit subject/message
95}
96
97// GitRecentLog returns the recent commit log between the initial commit and HEAD
98func GitRecentLog(repoDir string, initialCommitHash string) ([]LogEntry, error) {
99 // Validate input
100 if initialCommitHash == "" {
101 return nil, fmt.Errorf("initial commit hash must be provided")
102 }
103
104 // Find merge-base of HEAD and initial commit
105 cmdMergeBase := exec.Command("git", "-C", repoDir, "merge-base", "HEAD", initialCommitHash)
106 mergeBase, err := cmdMergeBase.CombinedOutput()
107 if err != nil {
108 // If merge-base fails (which can happen in simple repos), use initialCommitHash
109 return getGitLog(repoDir, initialCommitHash)
110 }
111
112 mergeBaseHash := strings.TrimSpace(string(mergeBase))
113 if mergeBaseHash == "" {
114 // If merge-base doesn't return a valid hash, use initialCommitHash
115 return getGitLog(repoDir, initialCommitHash)
116 }
117
118 // Use the merge-base as the 'from' point
119 return getGitLog(repoDir, mergeBaseHash)
120}
121
122// getGitLog gets the git log with the specified format using the provided fromCommit
123func getGitLog(repoDir string, fromCommit string) ([]LogEntry, error) {
124 // Check if fromCommit~10 exists (10 commits before fromCommit)
125 checkCmd := exec.Command("git", "-C", repoDir, "rev-parse", "--verify", fromCommit+"~10")
126 if err := checkCmd.Run(); err != nil {
127 // If fromCommit~10 doesn't exist, use just fromCommit..HEAD as the range
128 cmd := exec.Command("git", "-C", repoDir, "log", "-n", "1000", "--oneline", "--decorate", "--pretty=%H%x00%s%x00%d", fromCommit+"..HEAD")
129 out, err := cmd.CombinedOutput()
130 if err != nil {
131 return nil, fmt.Errorf("error executing git log: %w - %s", err, string(out))
132 }
133 return parseGitLog(string(out))
134 }
135
136 // Use fromCommit~10..HEAD range with the specified format for easy parsing
137 cmd := exec.Command("git", "-C", repoDir, "log", "-n", "1000", "--oneline", "--decorate", "--pretty=%H%x00%s%x00%d", fromCommit+"~10..HEAD")
138 out, err := cmd.CombinedOutput()
139 if err != nil {
140 return nil, fmt.Errorf("error executing git log: %w - %s", err, string(out))
141 }
142
143 return parseGitLog(string(out))
144}
145
146// parseGitLog parses the output of git log with null-separated fields
147func parseGitLog(logOutput string) ([]LogEntry, error) {
148 var entries []LogEntry
149 if logOutput == "" {
150 return entries, nil
151 }
152
153 scanner := bufio.NewScanner(strings.NewReader(strings.TrimSpace(logOutput)))
154 for scanner.Scan() {
155 line := scanner.Text()
156 parts := strings.Split(line, "\x00")
157 if len(parts) != 3 {
158 continue // Skip malformed lines
159 }
160
161 hash := parts[0]
162 subject := parts[1]
163 decoration := parts[2]
164
165 // Parse the refs from the decoration
166 refs := parseRefs(decoration)
167
168 entries = append(entries, LogEntry{
169 Hash: hash,
170 Refs: refs,
171 Subject: subject,
172 })
173 }
174
175 return entries, nil
176}
177
178// parseRefs extracts references from git decoration format
179func parseRefs(decoration string) []string {
180 // The decoration format from %d is: (HEAD -> main, origin/main, tag: v1.0.0)
181 if decoration == "" {
182 return nil
183 }
184
185 // Remove surrounding parentheses and whitespace
186 decoration = strings.TrimSpace(decoration)
187 decoration = strings.TrimPrefix(decoration, " (")
188 decoration = strings.TrimPrefix(decoration, "(")
189 decoration = strings.TrimSuffix(decoration, ")")
190 decoration = strings.TrimSuffix(decoration, ") ")
191
192 if decoration == "" {
193 return nil
194 }
195
196 // Split by comma
197 parts := strings.Split(decoration, ", ")
198
199 // Process each part
200 var refs []string
201 for _, part := range parts {
202 part = strings.TrimSpace(part)
203 if part == "" {
204 continue
205 }
206
207 // Handle HEAD -> branch format
208 if strings.HasPrefix(part, "HEAD -> ") {
209 refs = append(refs, strings.TrimPrefix(part, "HEAD -> "))
210 continue
211 }
212
213 // Handle tag: format
214 if strings.HasPrefix(part, "tag: ") {
215 refs = append(refs, strings.TrimPrefix(part, "tag: "))
216 continue
217 }
218
219 // Handle just HEAD (no branch)
220 if part == "HEAD" {
221 refs = append(refs, part)
222 continue
223 }
224
225 // Regular branch name
226 refs = append(refs, part)
227 }
228
229 return refs
230}