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}