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