package git import ( "bytes" "context" "encoding/binary" "errors" "fmt" "os" "os/exec" "path" "path/filepath" "sort" "strconv" "strings" "time" "unicode" "unicode/utf16" "unicode/utf8" ) var ( ErrInvalidRef = errors.New("invalid git reference") ErrInvalidPath = errors.New("invalid repository file path") ErrNotFound = errors.New("git object not found") ErrEmptyRepo = errors.New("repository is empty") ) type TreeEntry struct { Mode string Type string // "tree" or "blob" Hash string Size int64 Name string Path string CommitHash string CommitMsg string CommitTime time.Time } type Blob struct { Name string Path string Size int64 Data []byte IsBinary bool LineCount int } type Commit struct { Hash string ShortHash string AuthorName string AuthorEmail string AuthoredAt time.Time Subject string Body string Parents []string } type DiffLine struct { Type string // "add", "del", "ctx", "hunk" Content string OldNum int NewNum int } type FileDiff struct { OldPath string NewPath string Status string // "added", "deleted", "modified", "renamed" Additions int Deletions int IsBinary bool Lines []DiffLine } type CommitDetail struct { Commit Commit FilesChanged int TotalAdditions int TotalDeletions int Files []FileDiff } func ValidateRef(ref string) error { ref = strings.TrimSpace(ref) if ref == "" || len(ref) > 255 { return ErrInvalidRef } if strings.HasPrefix(ref, "-") || strings.Contains(ref, "..") || strings.ContainsAny(ref, " \t\n\r~^:?*[\\`\x00") || strings.HasSuffix(ref, ".lock") || strings.HasSuffix(ref, "/") { return ErrInvalidRef } return nil } func CleanSubPath(p string) (string, error) { p = strings.TrimSpace(strings.ReplaceAll(p, "\\", "/")) p = strings.TrimPrefix(p, "/") p = strings.TrimSuffix(p, "/") if p == "" || p == "." { return "", nil } if strings.Contains(p, "\x00") || strings.HasPrefix(p, "-") { return "", ErrInvalidPath } cleaned := path.Clean(p) if cleaned == "." { return "", nil } if strings.HasPrefix(cleaned, "..") || strings.Contains(cleaned, "/../") { return "", ErrInvalidPath } return cleaned, nil } func runGit(ctx context.Context, repoPath string, timeout time.Duration, args ...string) ([]byte, error) { return runGitFull(ctx, repoPath, timeout, nil, nil, args...) } func runGitFull(ctx context.Context, repoPath string, timeout time.Duration, extraEnv []string, input []byte, args ...string) ([]byte, error) { cctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() fullArgs := make([]string, 0, len(args)+2) if repoPath != "" { fullArgs = append(fullArgs, "-C", repoPath) } fullArgs = append(fullArgs, args...) cmd := exec.CommandContext(cctx, "git", fullArgs...) cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "LC_ALL=C", ) cmd.Env = append(cmd.Env, extraEnv...) if input != nil { cmd.Stdin = bytes.NewReader(input) } var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr if err := cmd.Run(); err != nil { return nil, fmt.Errorf("git %v: %w (%s)", args, err, strings.TrimSpace(stderr.String())) } return stdout.Bytes(), nil } func InitBare(ctx context.Context, repoPath, defaultBranch string) error { if defaultBranch == "" { defaultBranch = "main" } if err := ValidateRef(defaultBranch); err != nil { defaultBranch = "main" } if err := os.MkdirAll(filepath.Dir(repoPath), 0o755); err != nil { return fmt.Errorf("mkdir repo parent: %w", err) } _, err := runGit(ctx, "", 10*time.Second, "init", "--bare", "--initial-branch="+defaultBranch, repoPath) if err != nil { if _, err2 := runGit(ctx, "", 10*time.Second, "init", "--bare", repoPath); err2 != nil { return fmt.Errorf("init bare repo: %w", err2) } } _, _ = runGit(ctx, repoPath, 5*time.Second, "symbolic-ref", "HEAD", "refs/heads/"+defaultBranch) _, _ = runGit(ctx, repoPath, 5*time.Second, "config", "http.receivepack", "true") _, _ = runGit(ctx, repoPath, 5*time.Second, "config", "http.uploadpack", "true") return nil } func EnsureDefaultBranch(ctx context.Context, repoPath, preferred string) (string, bool) { if preferred == "" { preferred = "main" } branches, err := ListBranches(ctx, repoPath) if err != nil || len(branches) == 0 { return preferred, true } out, err := runGit(ctx, repoPath, 3*time.Second, "symbolic-ref", "--short", "HEAD") headBranch := strings.TrimSpace(string(out)) if err == nil && headBranch != "" { for _, b := range branches { if b == headBranch { return headBranch, false } } } chosen := branches[0] for _, b := range branches { if b == preferred { chosen = preferred break } } _, _ = runGit(ctx, repoPath, 3*time.Second, "symbolic-ref", "HEAD", "refs/heads/"+chosen) return chosen, false } func ListBranches(ctx context.Context, repoPath string) ([]string, error) { out, err := runGit(ctx, repoPath, 5*time.Second, "for-each-ref", "--format=%(refname:short)", "refs/heads/") if err != nil { return nil, err } return splitNonEmptyLines(string(out)), nil } func ListTags(ctx context.Context, repoPath string) ([]string, error) { out, err := runGit(ctx, repoPath, 5*time.Second, "for-each-ref", "--sort=-creatordate", "--format=%(refname:short)", "refs/tags/") if err != nil { return nil, err } return splitNonEmptyLines(string(out)), nil } func ListTree(ctx context.Context, repoPath, ref, subPath string) ([]TreeEntry, error) { if err := ValidateRef(ref); err != nil { return nil, err } cleanPath, err := CleanSubPath(subPath) if err != nil { return nil, err } target := ref if cleanPath != "" { target = ref + ":" + cleanPath } out, err := runGit(ctx, repoPath, 10*time.Second, "ls-tree", "-l", target) if err != nil { if strings.Contains(err.Error(), "Not a valid object name") || strings.Contains(err.Error(), "bad file") { return nil, ErrNotFound } return nil, err } lines := splitNonEmptyLines(string(out)) entries := make([]TreeEntry, 0, len(lines)) for _, line := range lines { // Format: \t tabIdx := strings.IndexByte(line, '\t') if tabIdx == -1 { continue } meta := strings.Fields(line[:tabIdx]) name := line[tabIdx+1:] if len(meta) < 4 { continue } var size int64 if meta[3] != "-" { size, _ = strconv.ParseInt(meta[3], 10, 64) } entryPath := name if cleanPath != "" { entryPath = cleanPath + "/" + name } entries = append(entries, TreeEntry{ Mode: meta[0], Type: meta[1], Hash: meta[2], Size: size, Name: name, Path: entryPath, }) } sort.Slice(entries, func(i, j int) bool { if entries[i].Type != entries[j].Type { return entries[i].Type == "tree" } return strings.ToLower(entries[i].Name) < strings.ToLower(entries[j].Name) }) // Populate last commit summary for up to 50 entries if len(entries) <= 50 { for i := range entries { if c, err := GetLatestCommit(ctx, repoPath, ref, entries[i].Path); err == nil && c != nil { entries[i].CommitHash = c.ShortHash entries[i].CommitMsg = c.Subject entries[i].CommitTime = c.AuthoredAt } } } return entries, nil } func ListAllFiles(ctx context.Context, repoPath, ref string) ([]string, error) { if err := ValidateRef(ref); err != nil { return nil, err } out, err := runGit(ctx, repoPath, 10*time.Second, "ls-tree", "-r", "--name-only", ref) if err != nil { return nil, err } files := splitNonEmptyLines(string(out)) if len(files) > 1000 { files = files[:1000] } return files, nil } func ReadBlob(ctx context.Context, repoPath, ref, filePath string) (*Blob, error) { if err := ValidateRef(ref); err != nil { return nil, err } cleanPath, err := CleanSubPath(filePath) if err != nil || cleanPath == "" { return nil, ErrInvalidPath } spec := ref + ":" + cleanPath typeOut, err := runGit(ctx, repoPath, 5*time.Second, "cat-file", "-t", spec) if err != nil { return nil, ErrNotFound } if strings.TrimSpace(string(typeOut)) != "blob" { return nil, ErrNotFound } data, err := runGit(ctx, repoPath, 10*time.Second, "cat-file", "-p", spec) if err != nil { return nil, ErrNotFound } data = normalizeTextEncoding(data) checkLen := len(data) if checkLen > 8000 { checkLen = 8000 } isBinary := bytes.IndexByte(data[:checkLen], 0) != -1 || !utf8.Valid(data[:checkLen]) lineCount := 0 if !isBinary && len(data) > 0 { lineCount = bytes.Count(data, []byte{'\n'}) if data[len(data)-1] != '\n' { lineCount++ } } return &Blob{ Name: path.Base(cleanPath), Path: cleanPath, Size: int64(len(data)), Data: data, IsBinary: isBinary, LineCount: lineCount, }, nil } func FindReadme(ctx context.Context, repoPath, ref, subPath string) (*Blob, error) { candidates := []string{"README.md", "readme.md", "Readme.md", "README.markdown", "README.txt", "README"} cleanSub, err := CleanSubPath(subPath) if err != nil { return nil, err } for _, name := range candidates { p := name if cleanSub != "" { p = cleanSub + "/" + name } if blob, err := ReadBlob(ctx, repoPath, ref, p); err == nil && !blob.IsBinary { return blob, nil } } return nil, ErrNotFound } // normalizeTextEncoding converts BOM-marked text (UTF-8/UTF-16) to UTF-8 so // files written by Windows tooling (e.g. PowerShell) render as text instead // of being misdetected as binary. func normalizeTextEncoding(data []byte) []byte { if len(data) >= 3 && data[0] == 0xEF && data[1] == 0xBB && data[2] == 0xBF { return data[3:] } if len(data) >= 2 && data[0] == 0xFF && data[1] == 0xFE { if len(data) >= 4 && data[2] == 0x00 && data[3] == 0x00 { return data // UTF-32LE is left untouched } return utf16ToUTF8(data[2:], binary.LittleEndian) } if len(data) >= 2 && data[0] == 0xFE && data[1] == 0xFF { return utf16ToUTF8(data[2:], binary.BigEndian) } return data } func utf16ToUTF8(data []byte, order binary.ByteOrder) []byte { runes := make([]rune, 0, len(data)/2) for i := 0; i+1 < len(data); i += 2 { unit := order.Uint16(data[i : i+2]) if unit == 0 { break } if utf16.IsSurrogate(rune(unit)) && i+3 < len(data) { next := order.Uint16(data[i+2 : i+4]) if combined := utf16.DecodeRune(rune(unit), rune(next)); combined != unicode.ReplacementChar { runes = append(runes, combined) i += 2 continue } } runes = append(runes, rune(unit)) } return []byte(string(runes)) } const commitFormat = "%H%x1f%h%x1f%an%x1f%ae%x1f%at%x1f%s%x1f%b%x1f%P%x1e" func GetLatestCommit(ctx context.Context, repoPath, ref, subPath string) (*Commit, error) { if err := ValidateRef(ref); err != nil { return nil, err } args := []string{"log", "-n", "1", "--format=" + commitFormat, ref} if subPath != "" { cleanPath, err := CleanSubPath(subPath) if err != nil { return nil, err } if cleanPath != "" { args = append(args, "--", cleanPath) } } out, err := runGit(ctx, repoPath, 5*time.Second, args...) if err != nil { return nil, err } commits := parseCommits(string(out)) if len(commits) == 0 { return nil, ErrNotFound } return &commits[0], nil } func ListCommits(ctx context.Context, repoPath, ref string, limit, offset int) ([]Commit, error) { if err := ValidateRef(ref); err != nil { return nil, err } if limit <= 0 || limit > 100 { limit = 30 } if offset < 0 { offset = 0 } out, err := runGit(ctx, repoPath, 10*time.Second, "log", "--max-count="+strconv.Itoa(limit), "--skip="+strconv.Itoa(offset), "--format="+commitFormat, ref, "--", ) if err != nil { return nil, err } return parseCommits(string(out)), nil } func GetCommitDiff(ctx context.Context, repoPath, commitHash string) (*CommitDetail, error) { if err := ValidateRef(commitHash); err != nil { return nil, err } metaOut, err := runGit(ctx, repoPath, 5*time.Second, "show", "-s", "--format="+commitFormat, commitHash) if err != nil { return nil, ErrNotFound } commits := parseCommits(string(metaOut)) if len(commits) == 0 { return nil, ErrNotFound } commit := commits[0] diffOut, err := runGit(ctx, repoPath, 15*time.Second, "show", "--patch", "--find-renames", "--format=", commitHash) if err != nil { return nil, err } files, adds, dels := parseUnifiedDiff(string(diffOut)) return &CommitDetail{ Commit: commit, FilesChanged: len(files), TotalAdditions: adds, TotalDeletions: dels, Files: files, }, nil } func parseCommits(raw string) []Commit { records := strings.Split(raw, "\x1e") var commits []Commit for _, rec := range records { rec = strings.TrimSpace(rec) if rec == "" { continue } fields := strings.Split(rec, "\x1f") if len(fields) < 7 { continue } ts, _ := strconv.ParseInt(strings.TrimSpace(fields[4]), 10, 64) var parents []string if len(fields) >= 8 && strings.TrimSpace(fields[7]) != "" { parents = strings.Fields(fields[7]) } commits = append(commits, Commit{ Hash: strings.TrimSpace(fields[0]), ShortHash: strings.TrimSpace(fields[1]), AuthorName: strings.TrimSpace(fields[2]), AuthorEmail: strings.TrimSpace(fields[3]), AuthoredAt: time.Unix(ts, 0).UTC(), Subject: strings.TrimSpace(fields[5]), Body: strings.TrimSpace(fields[6]), Parents: parents, }) } return commits } func parseUnifiedDiff(raw string) ([]FileDiff, int, int) { var files []FileDiff var current *FileDiff totalAdds := 0 totalDels := 0 oldLine := 0 newLine := 0 lines := strings.Split(raw, "\n") for _, line := range lines { line = strings.TrimSuffix(line, "\r") if strings.HasPrefix(line, "diff --git ") { if current != nil { files = append(files, *current) } oldP, newP := parseDiffGitHeader(line) current = &FileDiff{ OldPath: oldP, NewPath: newP, Status: "modified", } continue } if current == nil { continue } if strings.HasPrefix(line, "new file mode ") { current.Status = "added" continue } if strings.HasPrefix(line, "deleted file mode ") { current.Status = "deleted" continue } if strings.HasPrefix(line, "rename from ") { current.Status = "renamed" current.OldPath = strings.TrimPrefix(line, "rename from ") continue } if strings.HasPrefix(line, "rename to ") { current.Status = "renamed" current.NewPath = strings.TrimPrefix(line, "rename to ") continue } if strings.HasPrefix(line, "Binary files ") { current.IsBinary = true continue } if strings.HasPrefix(line, "index ") || strings.HasPrefix(line, "--- ") || strings.HasPrefix(line, "+++ ") { continue } if strings.HasPrefix(line, "@@ ") { oldLine, newLine = parseHunkHeader(line) current.Lines = append(current.Lines, DiffLine{ Type: "hunk", Content: line, }) continue } if len(current.Lines) == 0 { continue } if strings.HasPrefix(line, "+") { current.Additions++ totalAdds++ current.Lines = append(current.Lines, DiffLine{ Type: "add", Content: line[1:], NewNum: newLine, }) newLine++ } else if strings.HasPrefix(line, "-") { current.Deletions++ totalDels++ current.Lines = append(current.Lines, DiffLine{ Type: "del", Content: line[1:], OldNum: oldLine, }) oldLine++ } else if strings.HasPrefix(line, " ") { current.Lines = append(current.Lines, DiffLine{ Type: "ctx", Content: line[1:], OldNum: oldLine, NewNum: newLine, }) oldLine++ newLine++ } } if current != nil { files = append(files, *current) } return files, totalAdds, totalDels } func parseDiffGitHeader(line string) (string, string) { // diff --git a/foo b/foo rest := strings.TrimPrefix(line, "diff --git ") parts := strings.SplitN(rest, " b/", 2) if len(parts) == 2 { return strings.TrimPrefix(parts[0], "a/"), parts[1] } return rest, rest } func parseHunkHeader(line string) (int, int) { // @@ -oldStart,oldCount +newStart,newCount @@ oldStart, newStart := 1, 1 endIdx := strings.Index(line[3:], "@@") if endIdx == -1 { return oldStart, newStart } header := strings.TrimSpace(line[3 : 3+endIdx]) fields := strings.Fields(header) for _, f := range fields { if strings.HasPrefix(f, "-") { nums := strings.SplitN(f[1:], ",", 2) if v, err := strconv.Atoi(nums[0]); err == nil { oldStart = v } } else if strings.HasPrefix(f, "+") { nums := strings.SplitN(f[1:], ",", 2) if v, err := strconv.Atoi(nums[0]); err == nil { newStart = v } } } return oldStart, newStart } func splitNonEmptyLines(s string) []string { raw := strings.Split(s, "\n") out := make([]string, 0, len(raw)) for _, l := range raw { l = strings.TrimSpace(l) if l != "" { out = append(out, l) } } return out } type BranchInfo struct { Name string Hash string ShortHash string Subject string AuthorName string UpdatedAt time.Time IsDefault bool } type TagInfo struct { Name string Hash string ShortHash string Subject string CreatedAt time.Time } func ListBranchDetails(ctx context.Context, repoPath, defaultBranch string) ([]BranchInfo, error) { out, err := runGit(ctx, repoPath, 5*time.Second, "for-each-ref", "--sort=-committerdate", "--format=%(refname:short)%00%(objectname)%00%(objectname:short)%00%(contents:subject)%00%(authorname)%00%(committerdate:unix)", "refs/heads/", ) if err != nil { return nil, err } lines := splitNonEmptyLines(string(out)) res := make([]BranchInfo, 0, len(lines)) for _, l := range lines { parts := strings.Split(l, "\x00") if len(parts) < 6 { continue } ts, _ := strconv.ParseInt(parts[5], 10, 64) var tm time.Time if ts > 0 { tm = time.Unix(ts, 0).UTC() } res = append(res, BranchInfo{ Name: parts[0], Hash: parts[1], ShortHash: parts[2], Subject: parts[3], AuthorName: parts[4], UpdatedAt: tm, IsDefault: parts[0] == defaultBranch, }) } sort.SliceStable(res, func(i, j int) bool { if res[i].IsDefault != res[j].IsDefault { return res[i].IsDefault } return res[i].UpdatedAt.After(res[j].UpdatedAt) }) return res, nil } func ListTagDetails(ctx context.Context, repoPath string) ([]TagInfo, error) { out, err := runGit(ctx, repoPath, 5*time.Second, "for-each-ref", "--sort=-creatordate", "--format=%(refname:short)%00%(objectname)%00%(objectname:short)%00%(contents:subject)%00%(creatordate:unix)", "refs/tags/", ) if err != nil { return nil, err } lines := splitNonEmptyLines(string(out)) res := make([]TagInfo, 0, len(lines)) for _, l := range lines { parts := strings.Split(l, "\x00") if len(parts) < 5 { continue } ts, _ := strconv.ParseInt(parts[4], 10, 64) var tm time.Time if ts > 0 { tm = time.Unix(ts, 0).UTC() } res = append(res, TagInfo{ Name: parts[0], Hash: parts[1], ShortHash: parts[2], Subject: parts[3], CreatedAt: tm, }) } return res, nil } func CreateBranch(ctx context.Context, repoPath, branchName, fromRef string) error { branchName = strings.TrimSpace(branchName) fromRef = strings.TrimSpace(fromRef) if err := ValidateRef(branchName); err != nil { return err } if err := ValidateRef(fromRef); err != nil { return err } _, err := runGit(ctx, repoPath, 5*time.Second, "branch", branchName, fromRef) return err } func DeleteBranch(ctx context.Context, repoPath, branchName string) error { branchName = strings.TrimSpace(branchName) if err := ValidateRef(branchName); err != nil { return err } _, err := runGit(ctx, repoPath, 5*time.Second, "branch", "-D", branchName) return err } func CreateTag(ctx context.Context, repoPath, tagName, fromRef, message string) error { tagName = strings.TrimSpace(tagName) fromRef = strings.TrimSpace(fromRef) message = strings.TrimSpace(message) if err := ValidateRef(tagName); err != nil { return err } if err := ValidateRef(fromRef); err != nil { return err } if message != "" { _, err := runGit(ctx, repoPath, 5*time.Second, "-c", "user.name=Polar", "-c", "user.email=polar@localhost", "tag", "-a", tagName, "-m", message, fromRef, ) return err } _, err := runGit(ctx, repoPath, 5*time.Second, "tag", tagName, fromRef) return err } func DeleteTag(ctx context.Context, repoPath, tagName string) error { tagName = strings.TrimSpace(tagName) if err := ValidateRef(tagName); err != nil { return err } _, err := runGit(ctx, repoPath, 5*time.Second, "tag", "-d", tagName) return err } func ArchiveZip(ctx context.Context, repoPath, ref, prefix string) ([]byte, error) { ref = strings.TrimSpace(ref) if err := ValidateRef(ref); err != nil { return nil, err } prefix = strings.Trim(prefix, "/") if prefix == "" { prefix = "repo" } return runGit(ctx, repoPath, 30*time.Second, "archive", "--format=zip", "--prefix="+prefix+"/", ref) } func ArchiveTarGz(ctx context.Context, repoPath, ref, prefix string) ([]byte, error) { ref = strings.TrimSpace(ref) if err := ValidateRef(ref); err != nil { return nil, err } prefix = strings.Trim(prefix, "/") if prefix == "" { prefix = "repo" } return runGit(ctx, repoPath, 30*time.Second, "archive", "--format=tar.gz", "--prefix="+prefix+"/", ref) } func CompareBranches(ctx context.Context, repoPath, baseBranch, headBranch string) (*CommitDetail, []Commit, error) { if err := ValidateRef(baseBranch); err != nil { return nil, nil, err } if err := ValidateRef(headBranch); err != nil { return nil, nil, err } out, err := runGit(ctx, repoPath, 10*time.Second, "log", "--format="+commitFormat, baseBranch+".."+headBranch, "--") if err != nil { return nil, nil, fmt.Errorf("git log base..head: %w", err) } commits := parseCommits(string(out)) diffOut, err := runGit(ctx, repoPath, 15*time.Second, "diff", "--patch", "--find-renames", baseBranch+"..."+headBranch) if err != nil { return nil, nil, fmt.Errorf("git diff base...head: %w", err) } files, adds, dels := parseUnifiedDiff(string(diffOut)) detail := &CommitDetail{ FilesChanged: len(files), TotalAdditions: adds, TotalDeletions: dels, Files: files, } return detail, commits, nil } func CanMergeBranches(ctx context.Context, repoPath, baseBranch, headBranch string) (canMerge bool, isFastForward bool, err error) { if err := ValidateRef(baseBranch); err != nil { return false, false, err } if err := ValidateRef(headBranch); err != nil { return false, false, err } _, err = runGit(ctx, repoPath, 5*time.Second, "merge-base", "--is-ancestor", headBranch, baseBranch) if err == nil { return true, true, nil } _, err = runGit(ctx, repoPath, 5*time.Second, "merge-base", "--is-ancestor", baseBranch, headBranch) if err == nil { return true, true, nil } baseCommit, err := runGit(ctx, repoPath, 5*time.Second, "merge-base", baseBranch, headBranch) if err != nil { return false, false, fmt.Errorf("find merge base: %w", err) } baseHash := strings.TrimSpace(string(baseCommit)) if baseHash == "" { return false, false, errors.New("no common ancestor") } treeOut, err := runGit(ctx, repoPath, 5*time.Second, "merge-tree", baseHash, baseBranch, headBranch) if err != nil { return false, false, nil } if strings.Contains(string(treeOut), "+<<<<<<<") { return false, false, nil } return true, false, nil } func MergeBranches(ctx context.Context, repoPath, baseBranch, headBranch, commitMsg, authorName, authorEmail string) (string, error) { if err := ValidateRef(baseBranch); err != nil { return "", err } if err := ValidateRef(headBranch); err != nil { return "", err } return mergeRefs(ctx, repoPath, baseBranch, headBranch, commitMsg, authorName, authorEmail) } // MergeFromPath fetches headBranch from another local repository (a fork) and merges // it into baseBranch of repoPath. Used to merge cross-repository pull requests. func MergeFromPath(ctx context.Context, repoPath, forkPath, baseBranch, headBranch, tempRef, commitMsg, authorName, authorEmail string) (string, error) { if err := ValidateRef(baseBranch); err != nil { return "", err } if err := ValidateRef(headBranch); err != nil { return "", err } if tempRef == "" { tempRef = "refs/polar/tmp-merge" } if err := FetchToTemp(ctx, repoPath, forkPath, headBranch, tempRef); err != nil { return "", err } defer DeleteRef(context.WithoutCancel(ctx), repoPath, tempRef) return mergeRefs(ctx, repoPath, baseBranch, tempRef, commitMsg, authorName, authorEmail) } // FetchToTemp fetches a branch from another local repository into a temporary ref. func FetchToTemp(ctx context.Context, repoPath, srcPath, branch, tempRef string) error { if err := ValidateRef(branch); err != nil { return err } _, err := runGit(ctx, repoPath, 120*time.Second, "fetch", "--no-tags", "--quiet", srcPath, "+refs/heads/"+branch+":"+tempRef) if err != nil { return fmt.Errorf("fetch branch %s: %w", branch, err) } return nil } // DeleteRef removes a ref; missing refs are ignored. func DeleteRef(ctx context.Context, repoPath, ref string) { _, _ = runGit(ctx, repoPath, 10*time.Second, "update-ref", "-d", ref) } // CloneBare copies a bare repository to a new location (used when creating forks). func CloneBare(ctx context.Context, srcPath, dstPath string) error { if _, err := runGit(ctx, "", 180*time.Second, "clone", "--bare", "--quiet", srcPath, dstPath); err != nil { return fmt.Errorf("clone bare repository: %w", err) } _, _ = runGit(ctx, dstPath, 10*time.Second, "remote", "remove", "origin") return nil } // BranchExists reports whether a local branch exists in a bare repository. func BranchExists(ctx context.Context, repoPath, branch string) bool { if err := ValidateRef(branch); err != nil { return false } _, err := runGit(ctx, repoPath, 5*time.Second, "show-ref", "--verify", "--quiet", "refs/heads/"+branch) return err == nil } // CountCommits returns the number of commits reachable from ref (0 on error). func CountCommits(ctx context.Context, repoPath, ref string) int { if err := ValidateRef(ref); err != nil { return 0 } out, err := runGit(ctx, repoPath, 10*time.Second, "rev-list", "--count", ref) if err != nil { return 0 } n, _ := strconv.Atoi(strings.TrimSpace(string(out))) return n } func mergeRefs(ctx context.Context, repoPath, baseRef, headRef, commitMsg, authorName, authorEmail string) (string, error) { canMerge, isFF, err := CanMergeBranches(ctx, repoPath, baseRef, headRef) if err != nil { return "", err } if !canMerge { return "", errors.New("cannot merge: conflicting changes detected") } headCommit, err := runGit(ctx, repoPath, 5*time.Second, "rev-parse", headRef) if err != nil { return "", fmt.Errorf("rev-parse head: %w", err) } headHash := strings.TrimSpace(string(headCommit)) if isFF { _, err = runGit(ctx, repoPath, 5*time.Second, "update-ref", "refs/heads/"+baseRef, headHash) if err != nil { return "", fmt.Errorf("update-ref fast-forward: %w", err) } return headHash, nil } wtOut, err := runGit(ctx, repoPath, 5*time.Second, "merge-tree", "--write-tree", baseRef, headRef) var treeHash string if err == nil && len(strings.TrimSpace(string(wtOut))) == 40 { treeHash = strings.TrimSpace(string(wtOut)) } else { headTree, _ := runGit(ctx, repoPath, 5*time.Second, "rev-parse", headRef+"^{tree}") treeHash = strings.TrimSpace(string(headTree)) } baseHeadHash, _ := runGit(ctx, repoPath, 5*time.Second, "rev-parse", baseRef) baseHashStr := strings.TrimSpace(string(baseHeadHash)) if commitMsg == "" { commitMsg = fmt.Sprintf("Merge branch '%s' into %s", headRef, baseRef) } commitArgs := []string{"commit-tree", treeHash, "-p", baseHashStr, "-p", headHash, "-m", commitMsg} cmd := exec.CommandContext(ctx, "git", commitArgs...) cmd.Dir = repoPath cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME="+authorName, "GIT_AUTHOR_EMAIL="+authorEmail, "GIT_COMMITTER_NAME="+authorName, "GIT_COMMITTER_EMAIL="+authorEmail, ) out, err := cmd.CombinedOutput() if err != nil { return "", fmt.Errorf("commit-tree failed: %s (%w)", string(out), err) } mergeHash := strings.TrimSpace(string(out)) _, err = runGit(ctx, repoPath, 5*time.Second, "update-ref", "refs/heads/"+baseRef, mergeHash) if err != nil { return "", fmt.Errorf("update-ref: %w", err) } return mergeHash, nil } // FileToWrite is a single file change for WriteFilesCommit. type FileToWrite struct { Path string Content []byte } // WriteFileCommit creates or replaces filePath on branch with content and // commits the change. It works on a bare repository directly through git // plumbing and a temporary index file, so no working tree is required. func WriteFileCommit(ctx context.Context, repoPath, branch, filePath string, content []byte, commitMsg, authorName, authorEmail string) (string, error) { return WriteFilesCommit(ctx, repoPath, branch, []FileToWrite{{Path: filePath, Content: content}}, commitMsg, authorName, authorEmail) } // WriteFilesCommit creates or replaces several files on branch in a single // commit, again via git plumbing and a temporary index file. func WriteFilesCommit(ctx context.Context, repoPath, branch string, files []FileToWrite, commitMsg, authorName, authorEmail string) (string, error) { if err := ValidateRef(branch); err != nil { return "", err } if len(files) == 0 { return "", ErrInvalidPath } cleaned := make([]FileToWrite, 0, len(files)) seen := make(map[string]bool, len(files)) for _, f := range files { clean, err := CleanSubPath(f.Path) if err != nil || clean == "" { return "", ErrInvalidPath } for _, segment := range strings.Split(clean, "/") { if segment == ".git" { return "", ErrInvalidPath } } if seen[clean] { return "", ErrInvalidPath } seen[clean] = true cleaned = append(cleaned, FileToWrite{Path: clean, Content: f.Content}) } baseHash := "" baseOut, err := runGit(ctx, repoPath, 5*time.Second, "rev-parse", "refs/heads/"+branch) if err != nil { // An empty repository has no branches yet — create the branch with // this commit. A missing branch in a non-empty repository stays an // error so typos never silently spawn new refs. if branches, lerr := ListBranches(ctx, repoPath); lerr != nil || len(branches) > 0 { return "", fmt.Errorf("rev-parse %s: %w", branch, err) } } else { baseHash = strings.TrimSpace(string(baseOut)) } indexPath := filepath.Join(os.TempDir(), fmt.Sprintf("polar-index-%d-%d", time.Now().UnixNano(), os.Getpid())) defer os.Remove(indexPath) indexEnv := []string{"GIT_INDEX_FILE=" + indexPath} if baseHash != "" { if _, err := runGitFull(ctx, repoPath, 10*time.Second, indexEnv, nil, "read-tree", baseHash); err != nil { return "", fmt.Errorf("read-tree: %w", err) } } for _, f := range cleaned { blobOut, err := runGitFull(ctx, repoPath, 10*time.Second, nil, f.Content, "hash-object", "-w", "--stdin") if err != nil { return "", fmt.Errorf("hash-object: %w", err) } blobHash := strings.TrimSpace(string(blobOut)) if _, err := runGitFull(ctx, repoPath, 10*time.Second, indexEnv, nil, "update-index", "--add", "--cacheinfo", "100644,"+blobHash+","+f.Path); err != nil { return "", fmt.Errorf("update-index: %w", err) } } treeOut, err := runGitFull(ctx, repoPath, 10*time.Second, indexEnv, nil, "write-tree") if err != nil { return "", fmt.Errorf("write-tree: %w", err) } treeHash := strings.TrimSpace(string(treeOut)) if commitMsg == "" { commitMsg = "Add " + cleaned[0].Path } authorEnv := []string{ "GIT_AUTHOR_NAME=" + authorName, "GIT_AUTHOR_EMAIL=" + authorEmail, "GIT_COMMITTER_NAME=" + authorName, "GIT_COMMITTER_EMAIL=" + authorEmail, } commitArgs := []string{"commit-tree", treeHash} if baseHash != "" { commitArgs = append(commitArgs, "-p", baseHash) } commitArgs = append(commitArgs, "-m", commitMsg) commitOut, err := runGitFull(ctx, repoPath, 10*time.Second, authorEnv, nil, commitArgs...) if err != nil { return "", fmt.Errorf("commit-tree: %w", err) } commitHash := strings.TrimSpace(string(commitOut)) if _, err := runGit(ctx, repoPath, 5*time.Second, "update-ref", "refs/heads/"+branch, commitHash); err != nil { return "", fmt.Errorf("update-ref: %w", err) } return commitHash, nil }