blob: fc59e0dfaa4a93de25275ef6824b94df30405ca4 [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"
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00006 "context"
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00007 "fmt"
Philip Zeyliger272a90e2025-05-16 14:49:51 -07008 "os"
Philip Zeyligerd3ac1122025-05-14 02:54:18 +00009 "os/exec"
Philip Zeyliger272a90e2025-05-16 14:49:51 -070010 "path/filepath"
Philip Zeyligerd3ac1122025-05-14 02:54:18 +000011 "strings"
12)
13
14// DiffFile represents a file in a Git diff
15type DiffFile struct {
16 Path string `json:"path"`
17 OldMode string `json:"old_mode"`
18 NewMode string `json:"new_mode"`
19 OldHash string `json:"old_hash"`
20 NewHash string `json:"new_hash"`
21 Status string `json:"status"` // A=added, M=modified, D=deleted, etc.
22} // GitRawDiff returns a structured representation of the Git diff between two commits or references
Philip Zeyliger272a90e2025-05-16 14:49:51 -070023// If 'to' is empty, it will show unstaged changes (diff with working directory)
Philip Zeyligerd3ac1122025-05-14 02:54:18 +000024func GitRawDiff(repoDir, from, to string) ([]DiffFile, error) {
25 // Git command to generate the diff in raw format with full hashes
Philip Zeyliger272a90e2025-05-16 14:49:51 -070026 var cmd *exec.Cmd
27 if to == "" {
28 // If 'to' is empty, show unstaged changes
29 cmd = exec.Command("git", "-C", repoDir, "diff", "--raw", "--abbrev=40", from)
30 } else {
31 // Normal diff between two refs
32 cmd = exec.Command("git", "-C", repoDir, "diff", "--raw", "--abbrev=40", from, to)
33 }
34
Philip Zeyligerd3ac1122025-05-14 02:54:18 +000035 out, err := cmd.CombinedOutput()
36 if err != nil {
37 return nil, fmt.Errorf("error executing git diff: %w - %s", err, string(out))
38 }
39
40 // Parse the raw diff output into structured format
41 return parseRawDiff(string(out))
42}
43
44// GitShow returns the result of git show for a specific commit hash
45func GitShow(repoDir, hash string) (string, error) {
46 cmd := exec.Command("git", "-C", repoDir, "show", hash)
47 out, err := cmd.CombinedOutput()
48 if err != nil {
49 return "", fmt.Errorf("error executing git show: %w - %s", err, string(out))
50 }
51 return string(out), nil
52}
53
54// parseRawDiff converts git diff --raw output into structured format
55func parseRawDiff(diffOutput string) ([]DiffFile, error) {
56 var files []DiffFile
57 if diffOutput == "" {
58 return files, nil
59 }
60
61 // Process diff output line by line
62 scanner := bufio.NewScanner(strings.NewReader(strings.TrimSpace(diffOutput)))
63 for scanner.Scan() {
64 line := scanner.Text()
65 // Format: :oldmode newmode oldhash newhash status\tpath
66 // Example: :000000 100644 0000000000000000000000000000000000000000 6b33680ae6de90edd5f627c84147f7a41aa9d9cf A git_tools/git_tools.go
67 if !strings.HasPrefix(line, ":") {
68 continue
69 }
70
71 parts := strings.Fields(line[1:]) // Skip the leading colon
72 if len(parts) < 5 {
73 continue // Not enough parts, skip this line
74 }
75
76 oldMode := parts[0]
77 newMode := parts[1]
78 oldHash := parts[2]
79 newHash := parts[3]
80 status := parts[4]
81
82 // The path is everything after the status character and tab
83 pathIndex := strings.Index(line, status) + len(status) + 1 // +1 for the tab
84 path := ""
85 if pathIndex < len(line) {
86 path = strings.TrimSpace(line[pathIndex:])
87 }
88
89 files = append(files, DiffFile{
90 Path: path,
91 OldMode: oldMode,
92 NewMode: newMode,
93 OldHash: oldHash,
94 NewHash: newHash,
95 Status: status,
96 })
97 }
98
99 return files, nil
100}
101
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700102// GitLogEntry represents a single entry in the git log
103type GitLogEntry struct {
Philip Zeyligerd3ac1122025-05-14 02:54:18 +0000104 Hash string `json:"hash"` // The full commit hash
105 Refs []string `json:"refs"` // References (branches, tags) pointing to this commit
106 Subject string `json:"subject"` // The commit subject/message
107}
108
109// GitRecentLog returns the recent commit log between the initial commit and HEAD
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700110func GitRecentLog(repoDir string, initialCommitHash string) ([]GitLogEntry, error) {
Philip Zeyligerd3ac1122025-05-14 02:54:18 +0000111 // Validate input
112 if initialCommitHash == "" {
113 return nil, fmt.Errorf("initial commit hash must be provided")
114 }
115
116 // Find merge-base of HEAD and initial commit
117 cmdMergeBase := exec.Command("git", "-C", repoDir, "merge-base", "HEAD", initialCommitHash)
118 mergeBase, err := cmdMergeBase.CombinedOutput()
119 if err != nil {
120 // If merge-base fails (which can happen in simple repos), use initialCommitHash
121 return getGitLog(repoDir, initialCommitHash)
122 }
123
124 mergeBaseHash := strings.TrimSpace(string(mergeBase))
125 if mergeBaseHash == "" {
126 // If merge-base doesn't return a valid hash, use initialCommitHash
127 return getGitLog(repoDir, initialCommitHash)
128 }
129
130 // Use the merge-base as the 'from' point
131 return getGitLog(repoDir, mergeBaseHash)
132}
133
134// getGitLog gets the git log with the specified format using the provided fromCommit
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700135func getGitLog(repoDir string, fromCommit string) ([]GitLogEntry, error) {
Philip Zeyligerd3ac1122025-05-14 02:54:18 +0000136 // Check if fromCommit~10 exists (10 commits before fromCommit)
137 checkCmd := exec.Command("git", "-C", repoDir, "rev-parse", "--verify", fromCommit+"~10")
138 if err := checkCmd.Run(); err != nil {
139 // If fromCommit~10 doesn't exist, use just fromCommit..HEAD as the range
140 cmd := exec.Command("git", "-C", repoDir, "log", "-n", "1000", "--oneline", "--decorate", "--pretty=%H%x00%s%x00%d", fromCommit+"..HEAD")
141 out, err := cmd.CombinedOutput()
142 if err != nil {
143 return nil, fmt.Errorf("error executing git log: %w - %s", err, string(out))
144 }
145 return parseGitLog(string(out))
146 }
147
148 // Use fromCommit~10..HEAD range with the specified format for easy parsing
149 cmd := exec.Command("git", "-C", repoDir, "log", "-n", "1000", "--oneline", "--decorate", "--pretty=%H%x00%s%x00%d", fromCommit+"~10..HEAD")
150 out, err := cmd.CombinedOutput()
151 if err != nil {
152 return nil, fmt.Errorf("error executing git log: %w - %s", err, string(out))
153 }
154
155 return parseGitLog(string(out))
156}
157
158// parseGitLog parses the output of git log with null-separated fields
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700159func parseGitLog(logOutput string) ([]GitLogEntry, error) {
160 var entries []GitLogEntry
Philip Zeyligerd3ac1122025-05-14 02:54:18 +0000161 if logOutput == "" {
162 return entries, nil
163 }
164
165 scanner := bufio.NewScanner(strings.NewReader(strings.TrimSpace(logOutput)))
166 for scanner.Scan() {
167 line := scanner.Text()
168 parts := strings.Split(line, "\x00")
169 if len(parts) != 3 {
170 continue // Skip malformed lines
171 }
172
173 hash := parts[0]
174 subject := parts[1]
175 decoration := parts[2]
176
177 // Parse the refs from the decoration
178 refs := parseRefs(decoration)
179
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700180 entries = append(entries, GitLogEntry{
Philip Zeyligerd3ac1122025-05-14 02:54:18 +0000181 Hash: hash,
182 Refs: refs,
183 Subject: subject,
184 })
185 }
186
187 return entries, nil
188}
189
190// parseRefs extracts references from git decoration format
191func parseRefs(decoration string) []string {
192 // The decoration format from %d is: (HEAD -> main, origin/main, tag: v1.0.0)
193 if decoration == "" {
194 return nil
195 }
196
197 // Remove surrounding parentheses and whitespace
198 decoration = strings.TrimSpace(decoration)
199 decoration = strings.TrimPrefix(decoration, " (")
200 decoration = strings.TrimPrefix(decoration, "(")
201 decoration = strings.TrimSuffix(decoration, ")")
202 decoration = strings.TrimSuffix(decoration, ") ")
203
204 if decoration == "" {
205 return nil
206 }
207
208 // Split by comma
209 parts := strings.Split(decoration, ", ")
210
211 // Process each part
212 var refs []string
213 for _, part := range parts {
214 part = strings.TrimSpace(part)
215 if part == "" {
216 continue
217 }
218
219 // Handle HEAD -> branch format
220 if strings.HasPrefix(part, "HEAD -> ") {
221 refs = append(refs, strings.TrimPrefix(part, "HEAD -> "))
222 continue
223 }
224
225 // Handle tag: format
226 if strings.HasPrefix(part, "tag: ") {
227 refs = append(refs, strings.TrimPrefix(part, "tag: "))
228 continue
229 }
230
231 // Handle just HEAD (no branch)
232 if part == "HEAD" {
233 refs = append(refs, part)
234 continue
235 }
236
237 // Regular branch name
238 refs = append(refs, part)
239 }
240
241 return refs
242}
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700243
244// validateRepoPath verifies that a file is tracked by git and within the repository boundaries
245// Returns the full path to the file if valid
246func validateRepoPath(repoDir, filePath string) (string, error) {
247 // First verify that the requested file is tracked by git to prevent
248 // access to files outside the repository
249 cmd := exec.Command("git", "-C", repoDir, "ls-files", "--error-unmatch", filePath)
250 if err := cmd.Run(); err != nil {
251 return "", fmt.Errorf("file not tracked by git or outside repository: %s", filePath)
252 }
253
254 // Construct the full file path
255 fullPath := filepath.Join(repoDir, filePath)
256
257 // Validate that the resolved path is still within the repository directory
258 // to prevent directory traversal attacks (e.g., ../../../etc/passwd)
259 absRepoDir, err := filepath.Abs(repoDir)
260 if err != nil {
261 return "", fmt.Errorf("unable to resolve absolute repository path: %w", err)
262 }
263
264 absFilePath, err := filepath.Abs(fullPath)
265 if err != nil {
266 return "", fmt.Errorf("unable to resolve absolute file path: %w", err)
267 }
268
269 // Check that the absolute file path starts with the absolute repository path
270 if !strings.HasPrefix(absFilePath, absRepoDir+string(filepath.Separator)) {
271 return "", fmt.Errorf("file path outside repository: %s", filePath)
272 }
273
274 return fullPath, nil
275}
276
277// GitCat returns the contents of a file in the repository at the given path
278// This is used to get the current working copy of a file (not using git show)
279func GitCat(repoDir, filePath string) (string, error) {
280 fullPath, err := validateRepoPath(repoDir, filePath)
281 if err != nil {
282 return "", err
283 }
284
285 // Read the file
286 content, err := os.ReadFile(fullPath)
287 if err != nil {
288 return "", fmt.Errorf("error reading file %s: %w", filePath, err)
289 }
290
291 return string(content), nil
292}
293
294// GitSaveFile saves content to a file in the repository, checking first that it's tracked by git
295// This prevents writing to files outside the repository
296func GitSaveFile(repoDir, filePath, content string) error {
297 fullPath, err := validateRepoPath(repoDir, filePath)
298 if err != nil {
299 return err
300 }
301
302 // Write the content to the file
Autoformatter8c463622025-05-16 21:54:17 +0000303 err = os.WriteFile(fullPath, []byte(content), 0o644)
Philip Zeyliger272a90e2025-05-16 14:49:51 -0700304 if err != nil {
305 return fmt.Errorf("error writing to file %s: %w", filePath, err)
306 }
307
308 return nil
309}
Philip Zeyliger75bd37d2025-05-22 18:49:14 +0000310
311// AutoCommitDiffViewChanges automatically commits changes to the specified file
312// If the last commit message is exactly "User changes from diff view.", it amends the commit
313// Otherwise, it creates a new commit
314func AutoCommitDiffViewChanges(ctx context.Context, repoDir, filePath string) error {
315 // Check if the last commit has the expected message
316 cmd := exec.CommandContext(ctx, "git", "log", "-1", "--pretty=%s")
317 cmd.Dir = repoDir
318 output, err := cmd.Output()
319 commitMsg := strings.TrimSpace(string(output))
320
321 // Check if we should amend or create a new commit
322 const expectedMsg = "User changes from diff view."
323 amend := err == nil && commitMsg == expectedMsg
324
325 // Add the file to git
326 cmd = exec.CommandContext(ctx, "git", "add", filePath)
327 cmd.Dir = repoDir
328 if err := cmd.Run(); err != nil {
329 return fmt.Errorf("error adding file to git: %w", err)
330 }
331
332 // Commit the changes
333 if amend {
334 // Amend the previous commit
335 cmd = exec.CommandContext(ctx, "git", "commit", "--amend", "--no-edit")
336 } else {
337 // Create a new commit
338 cmd = exec.CommandContext(ctx, "git", "commit", "-m", expectedMsg, filePath)
339 }
340 cmd.Dir = repoDir
341
342 if err := cmd.Run(); err != nil {
343 return fmt.Errorf("error committing changes: %w", err)
344 }
345
346 return nil
347}