main
1package git
2
3import (
4 "bufio"
5 "fmt"
6 "io"
7 "os/exec"
8 "path/filepath"
9 "strconv"
10 "strings"
11 "time"
12)
13
14func gitCmd(repoDir string, args ...string) ([]byte, error) {
15 cmd := exec.Command("git", args...)
16 if repoDir != "" {
17 cmd.Dir = repoDir
18 }
19 out, err := cmd.Output()
20 if err != nil {
21 if ee, ok := err.(*exec.ExitError); ok {
22 return nil, fmt.Errorf("git %s: %w: %s", args[0], err, ee.Stderr)
23 }
24 return nil, err
25 }
26 return out, nil
27}
28
29func RefExists(ref, repoDir string) bool {
30 _, err := gitCmd(repoDir, "rev-parse", "--verify", "--quiet", ref+"^{commit}")
31 return err == nil
32}
33
34func Branches(repoDir string) ([]Ref, error) {
35 out, err := gitCmd(repoDir, "for-each-ref", "--format=%(refname:short)", "refs/heads/")
36 if err != nil {
37 return nil, err
38 }
39 lines := strings.Split(string(out), "\n")
40 branches := make([]Ref, 0, len(lines))
41 for _, line := range lines {
42 if line == "" {
43 continue
44 }
45 branches = append(branches, NewRef(line))
46 }
47 return branches, nil
48}
49
50func Tags(repoDir string) ([]Tag, error) {
51 format := []string{
52 "%(refname:short)",
53 "%(creatordate:unix)",
54 "%(objectname)",
55 "%(*objectname)",
56 }
57 out, err := gitCmd(repoDir,
58 "for-each-ref",
59 "--sort=-creatordate",
60 "--format="+strings.Join(format, "%00"),
61 "refs/tags",
62 )
63 if err != nil {
64 return nil, err
65 }
66
67 lines := strings.Split(strings.TrimSpace(string(out)), "\n")
68 tags := make([]Tag, 0, len(lines))
69
70 for _, line := range lines {
71 if line == "" {
72 continue
73 }
74 parts := strings.Split(line, "\x00")
75 if len(parts) != len(format) {
76 continue
77 }
78 name, timestamp, objectName, commitHash := parts[0], parts[1], parts[2], parts[3]
79 timestampInt, err := strconv.Atoi(timestamp)
80 if err != nil {
81 return nil, fmt.Errorf("failed to parse tag creation date: %w", err)
82 }
83 if commitHash == "" {
84 commitHash = objectName // tag is lightweight
85 }
86 tags = append(tags, Tag{
87 Name: name,
88 Date: time.Unix(int64(timestampInt), 0),
89 CommitHash: commitHash,
90 })
91 }
92
93 return tags, nil
94}
95
96func Files(ref Ref, repoDir string) ([]Blob, error) {
97 if ref.IsEmpty() {
98 ref = NewRef("HEAD")
99 }
100
101 // -r: recurse into subtrees
102 // -l: include blob size
103 cmd := exec.Command("git", "ls-tree", "--full-tree", "-r", "-l", ref.String())
104 if repoDir != "" {
105 cmd.Dir = repoDir
106 }
107 stdout, err := cmd.StdoutPipe()
108 if err != nil {
109 return nil, fmt.Errorf("failed to get stdout pipe: %w", err)
110 }
111
112 stderr, err := cmd.StderrPipe()
113 if err != nil {
114 return nil, fmt.Errorf("failed to get stderr pipe: %w", err)
115 }
116
117 if err := cmd.Start(); err != nil {
118 return nil, fmt.Errorf("failed to start git ls-tree: %w", err)
119 }
120
121 files := make([]Blob, 0, 256)
122
123 // Read stdout line by line; each line is like:
124 // <mode> <type> <object> <size>\t<path>
125 // Example: "100644 blob e69de29... 12\tREADME.md"
126 scanner := bufio.NewScanner(stdout)
127
128 // Allow long paths by increasing the scanner buffer limit
129 scanner.Buffer(make([]byte, 0, 64*1024), 2*1024*1024)
130
131 for scanner.Scan() {
132 line := scanner.Text()
133 if line == "" {
134 continue
135 }
136
137 // Split header and path using the tab delimiter
138 // to preserve spaces in file names
139 tab := strings.IndexByte(line, '\t')
140 if tab == -1 {
141 return nil, fmt.Errorf("expected tab delimiter in ls-tree output: %s", line)
142 }
143 header := line[:tab]
144 path := line[tab+1:]
145
146 // header fields: mode, type, object, size
147 parts := strings.Fields(header)
148 if len(parts) < 4 {
149 return nil, fmt.Errorf("unexpected ls-tree output format: %s", line)
150 }
151 modeNumber := parts[0]
152 typ := parts[1]
153 // object := parts[2]
154 sizeStr := parts[3]
155
156 if typ != "blob" {
157 // We only care about files (blobs)
158 continue
159 }
160
161 // Size could be "-" for non-blobs in some forms;
162 // for blobs it should be a number.
163 size, err := strconv.ParseInt(sizeStr, 10, 64)
164 if err != nil {
165 return nil, err
166 }
167
168 mode, err := ParseFileMode(modeNumber)
169 if err != nil {
170 return nil, err
171 }
172
173 files = append(files, Blob{
174 Ref: ref,
175 Mode: mode,
176 Path: path,
177 FileName: filepath.Base(path),
178 Size: size,
179 })
180 }
181
182 if err := scanner.Err(); err != nil {
183 // Drain stderr to include any git error message
184 _ = cmd.Wait()
185 b, _ := io.ReadAll(stderr)
186 if len(b) > 0 {
187 return nil, fmt.Errorf("failed to read ls-tree output: %v: %s", err, string(b))
188 }
189 return nil, fmt.Errorf("failed to read ls-tree output: %w", err)
190 }
191
192 // Ensure the command completed successfully
193 if err := cmd.Wait(); err != nil {
194 b, _ := io.ReadAll(stderr)
195 if len(b) > 0 {
196 return nil, fmt.Errorf("git ls-tree %q failed: %v: %s", ref, err, string(b))
197 }
198 return nil, fmt.Errorf("git ls-tree %q failed: %w", ref, err)
199 }
200
201 return files, nil
202}
203
204func BlobContent(ref Ref, path string, repoDir string) ([]byte, bool, error) {
205 if ref.IsEmpty() {
206 ref = NewRef("HEAD")
207 }
208 out, err := gitCmd(repoDir, "show", ref.String()+":"+path)
209 if err != nil {
210 return nil, false, err
211 }
212 return out, IsBinary(out), nil
213}
214
215func Commits(ref Ref, repoDir string) ([]Commit, error) {
216 format := []string{"%H", "%h", "%s", "%b", "%an", "%ae", "%ad", "%cn", "%ce", "%cd", "%P", "%D"}
217 out, err := gitCmd(repoDir,
218 "log",
219 "--date=unix",
220 "--pretty=format:"+strings.Join(format, "\x1F"),
221 "-z",
222 ref.String(),
223 )
224 if err != nil {
225 return nil, err
226 }
227
228 lines := strings.Split(string(out), "\x00")
229 commits := make([]Commit, 0, len(lines))
230 for _, line := range lines {
231 if line == "" {
232 continue
233 }
234 parts := strings.Split(line, "\x1F")
235 if len(parts) != len(format) {
236 return nil, fmt.Errorf("unexpected commit format: %s", line)
237 }
238 full, short, subject, body, author, email, date :=
239 parts[0], parts[1], parts[2], parts[3], parts[4], parts[5], parts[6]
240 committerName, committerEmail, committerDate, parents, refs :=
241 parts[7], parts[8], parts[9], parts[10], parts[11]
242 timestamp, err := strconv.Atoi(date)
243 if err != nil {
244 return nil, fmt.Errorf("failed to parse commit date: %w", err)
245 }
246 committerTimestamp, err := strconv.Atoi(committerDate)
247 if err != nil {
248 return nil, fmt.Errorf("failed to parse committer date: %w", err)
249 }
250 commits = append(commits, Commit{
251 Hash: full,
252 ShortHash: short,
253 Subject: subject,
254 Body: body,
255 Author: author,
256 Email: email,
257 Date: time.Unix(int64(timestamp), 0),
258 CommitterName: committerName,
259 CommitterEmail: committerEmail,
260 CommitterDate: time.Unix(int64(committerTimestamp), 0),
261 Parents: strings.Fields(parents),
262 RefNames: parseRefNames(refs),
263 })
264 }
265 return commits, nil
266}
267
268func parseRefNames(refNames string) []RefName {
269 refNames = strings.TrimSpace(refNames)
270 if refNames == "" {
271 return nil
272 }
273
274 parts := strings.Split(refNames, ", ")
275 out := make([]RefName, 0, len(parts))
276 for _, p := range parts {
277 p = strings.TrimSpace(p)
278 if p == "" {
279 continue
280 }
281
282 // tag: v1.2.3
283 if strings.HasPrefix(p, "tag: ") {
284 out = append(out, RefName{
285 Kind: RefKindTag,
286 Name: strings.TrimSpace(strings.TrimPrefix(p, "tag: ")),
287 })
288 continue
289 }
290
291 // HEAD -> main
292 if strings.HasPrefix(p, "HEAD -> ") {
293 out = append(out, RefName{
294 Kind: RefKindHEAD,
295 Name: "HEAD",
296 Target: strings.TrimSpace(strings.TrimPrefix(p, "HEAD -> ")),
297 })
298 continue
299 }
300
301 // origin/HEAD -> origin/main
302 if strings.Contains(p, " -> ") && strings.HasSuffix(strings.SplitN(p, " -> ", 2)[0], "/HEAD") {
303 leftRight := strings.SplitN(p, " -> ", 2)
304 out = append(out, RefName{
305 Kind: RefKindRemoteHEAD,
306 Name: strings.TrimSpace(leftRight[0]),
307 Target: strings.TrimSpace(leftRight[1]),
308 })
309 continue
310 }
311
312 // Remote branch like origin/main
313 if strings.Contains(p, "/") {
314 out = append(out, RefName{
315 Kind: RefKindRemote,
316 Name: p,
317 })
318 continue
319 }
320
321 // Local branch
322 out = append(out, RefName{
323 Kind: RefKindBranch,
324 Name: p,
325 })
326 }
327 return out
328}
329
330func CommitDiff(hash, repoDir string) (string, error) {
331 out, err := gitCmd(repoDir, "show", "--pretty=format:", "--patch", hash)
332 if err != nil {
333 return "", err
334 }
335 return string(out), nil
336}
337