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