Files
kandev/apps/backend/internal/common/gitref/gitref.go
T
2026-06-29 14:17:14 +01:00

250 lines
7.8 KiB
Go

// Package gitref reads git ref state directly from the on-disk .git
// directory, without invoking the git binary. The functions here are kept
// minimal and stdlib-only so they can be imported from both the task service
// and the sqlite migration layer (where adding a service dependency would
// invert the existing layering).
package gitref
import (
"fmt"
"os"
"path/filepath"
"strings"
)
const headFile = "HEAD"
// DefaultBranch returns the repository's *integration* branch — the branch
// that work is meant to merge back into. It is intentionally NOT the current
// HEAD: a developer who runs the dialog while checked out on a feature
// branch must still get "main" (or "master") back, otherwise downstream
// consumers (changes-panel merge-base, executor BaseBranch fallback) anchor
// to the wrong ref.
//
// Resolution order:
// 1. refs/remotes/origin/HEAD when set, except origin/HEAD=master yields to
// origin/main when it exists
// 2. origin/main
// 3. origin/master
// 4. local main
// 5. local master
// 6. The current HEAD as a last resort, so brand-new repos with only a
// feature branch still produce a value — callers that care about
// correctness can override.
func DefaultBranch(repoPath string) (string, error) {
safe, err := guardRepoPath(repoPath)
if err != nil {
return "", err
}
gitDir, err := ResolveGitDir(safe)
if err != nil {
return "", err
}
commonDir := ResolveCommonGitDir(gitDir)
commonDir, err = guardRepoPath(commonDir)
if err != nil {
return "", err
}
if branch := readOriginHEAD(commonDir); branch != "" {
if branch == "master" {
if refExists(commonDir, "refs/remotes/origin/main") {
return "main", nil
}
}
return branch, nil
}
for _, candidate := range []struct {
ref string
branch string
}{
{"refs/remotes/origin/main", "main"},
{"refs/remotes/origin/master", "master"},
{"refs/heads/main", "main"},
{"refs/heads/master", "master"},
} {
if refExists(commonDir, candidate.ref) {
return candidate.branch, nil
}
}
return readHEADBranchFallback(gitDir)
}
// DefaultBranchOrEmpty returns DefaultBranch, but collapses detached HEAD's
// literal "HEAD" sentinel to empty for callers that persist branch names.
func DefaultBranchOrEmpty(repoPath string) (string, error) {
branch, err := DefaultBranch(repoPath)
if err != nil || branch == headFile {
return "", err
}
return branch, nil
}
// guardRepoPath rejects relative paths and any path containing `..` segments
// before passing the value into the file-reading helpers below. Callers
// already validate against an allowlist (see service.resolveAllowedLocalPath),
// but CodeQL's go/path-injection taint analysis doesn't trace that as a
// sanitizer, so we re-check here at the I/O boundary. The check is also
// genuinely useful: any caller — migration code, tests, future callers —
// must hand us an absolute, traversal-free path or we refuse to read.
func guardRepoPath(repoPath string) (string, error) {
if repoPath == "" {
return "", fmt.Errorf("repository path is required")
}
if !filepath.IsAbs(repoPath) {
return "", fmt.Errorf("repository path must be absolute")
}
// Check the RAW input for '..' segments before cleaning. filepath.Clean
// resolves traversal away from absolute paths (`/allowed/../etc` →
// `/etc`), so iterating the cleaned path would silently pass attempted
// escapes. Splitting on both separators handles cross-platform inputs
// without depending on the OS-native separator.
for _, part := range strings.FieldsFunc(repoPath, func(r rune) bool {
return r == '/' || r == filepath.Separator
}) {
if part == ".." {
return "", fmt.Errorf("repository path must not contain '..' segments")
}
}
return filepath.Clean(repoPath), nil
}
// ResolveGitDir returns the actual git directory for repoPath, following
// `.git` files (worktree pointers) when present.
func ResolveGitDir(repoPath string) (string, error) {
gitPath := filepath.Join(repoPath, ".git")
info, err := os.Stat(gitPath)
if err != nil {
return "", err
}
if info.IsDir() {
return gitPath, nil
}
content, err := os.ReadFile(gitPath)
if err != nil {
return "", err
}
line := strings.TrimSpace(string(content))
if !strings.HasPrefix(line, "gitdir:") {
return "", fmt.Errorf("invalid gitdir reference")
}
gitDir := strings.TrimSpace(strings.TrimPrefix(line, "gitdir:"))
if filepath.IsAbs(gitDir) {
return gitDir, nil
}
return filepath.Clean(filepath.Join(repoPath, gitDir)), nil
}
// ResolveCommonGitDir returns the shared git dir for a worktree, or gitDir
// itself for a regular repo. Refs (refs/heads/*, refs/remotes/*, packed-refs)
// live under the common dir, not the worktree's gitDir.
func ResolveCommonGitDir(gitDir string) string {
commonFile := filepath.Join(gitDir, "commondir")
content, err := os.ReadFile(commonFile)
if err != nil {
return gitDir
}
commonDir := strings.TrimSpace(string(content))
if commonDir == "" {
return gitDir
}
if filepath.IsAbs(commonDir) {
return filepath.Clean(commonDir)
}
return filepath.Clean(filepath.Join(gitDir, commonDir))
}
func readOriginHEAD(commonDir string) string {
headPath := filepath.Join(commonDir, "refs", "remotes", "origin", headFile)
content, err := os.ReadFile(headPath)
if err != nil {
// origin/HEAD only lives in packed-refs after a `git pack-refs --all`,
// and the on-disk symref format there is gnarly to parse correctly.
// We deliberately skip that case rather than maintain a broken parser:
// the named main/master fallbacks cover every realistic clone in
// practice.
return ""
}
return parseSymbolicRefToBranch(strings.TrimSpace(string(content)))
}
func parseSymbolicRefToBranch(line string) string {
ref, ok := strings.CutPrefix(line, "ref: ")
if !ok {
ref = line
}
switch {
case strings.HasPrefix(ref, "refs/remotes/origin/"):
return strings.TrimPrefix(ref, "refs/remotes/origin/")
case strings.HasPrefix(ref, "refs/heads/"):
return strings.TrimPrefix(ref, "refs/heads/")
}
return ""
}
func refExists(commonDir, ref string) bool {
refPath, ok := safeRefPath(commonDir, ref)
if !ok {
return false
}
if info, err := os.Stat(refPath); err == nil {
return !info.IsDir()
}
content, err := os.ReadFile(filepath.Join(commonDir, "packed-refs"))
if err != nil {
return false
}
for _, line := range strings.Split(string(content), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "^") {
continue
}
_, after, ok := strings.Cut(line, " ")
if !ok {
continue
}
if after == ref {
return true
}
}
return false
}
func safeRefPath(commonDir, ref string) (string, bool) {
if ref == "" || filepath.IsAbs(ref) {
return "", false
}
for _, part := range strings.FieldsFunc(ref, func(r rune) bool {
return r == '/' || r == filepath.Separator
}) {
if part == "" || part == "." || part == ".." {
return "", false
}
}
cleanCommon := filepath.Clean(commonDir)
candidate := filepath.Join(cleanCommon, filepath.Clean(ref))
rel, err := filepath.Rel(cleanCommon, candidate)
if err != nil || rel == "." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." {
return "", false
}
return candidate, true
}
func readHEADBranchFallback(gitDir string) (string, error) {
headPath := filepath.Join(gitDir, headFile)
content, err := os.ReadFile(headPath)
if err != nil {
return "", err
}
trimmed := strings.TrimSpace(string(content))
if ref, ok := strings.CutPrefix(trimmed, "ref: "); ok {
// Strip refs/heads/ as a prefix rather than splitting on "/" — branch
// names legally contain slashes (e.g. "feature/my-feature"), so taking
// the last path component would silently corrupt every nested branch.
return strings.TrimPrefix(ref, "refs/heads/"), nil
}
if trimmed != "" {
return headFile, nil
}
return "", fmt.Errorf("unable to determine branch")
}