package gitcmd import ( "context" "errors" "fmt" "mime" "os" "os/exec" "strings" "hearthforge/internal/util" ) // ApplyResult is the outcome of a `git apply --check` preview. type ApplyResult struct { Status string // clean or conflict Output string } // withTempDir makes a private 0700 directory and removes it afterwards. // A predictable /tmp path would be open to a pre-planted symlink. func withTempDir(prefix string, fn func(dir string) error) error { dir, err := os.MkdirTemp("", "hf-"+prefix+"-") if err != nil { return err } defer os.RemoveAll(dir) return fn(dir) } // gitArgs builds the leading arguments. work is the dummy work tree that // `update-index --remove` insists on even in a bare repo. func gitArgs(p, work string, rest ...string) []string { var args []string if work != "" { args = append(args, "--work-tree="+work) } args = append(args, "-C", p) return append(args, rest...) } // treeFileMode returns the 6-digit octal mode of a path at a ref, or "" when // it does not exist there. It preserves the executable bit and symlinks // across UI edits. func (g *Git) treeFileMode(ctx context.Context, p, ref, filePath string) string { out, err := g.text(ctx, "-C", p, "ls-tree", "--end-of-options", ref, "--", filePath) if err != nil { return "" } fields := strings.Fields(out) if len(fields) == 0 || len(fields[0]) != 6 { return "" } for _, c := range fields[0] { if c < '0' || c > '7' { return "" } } return fields[0] } func (g *Git) writeTree(ctx context.Context, p, work string) (string, error) { return g.line(ctx, gitArgs(p, work, "write-tree")...) } func (g *Git) readTree(ctx context.Context, p, work, ref string) error { _, err := g.run(ctx, runOpts{}, gitArgs(p, work, "read-tree", "--end-of-options", ref)...) return err } // hashObject writes content into the object store and returns its id. func (g *Git) hashObject(ctx context.Context, p string, content []byte) (string, error) { out, err := g.run(ctx, runOpts{stdin: content}, "-C", p, "hash-object", "-w", "--stdin") if err != nil { return "", err } return strings.TrimSpace(string(out)), nil } // commitTree creates a signed commit object. parent may be empty for the // first commit on a branch. func (g *Git) commitTree(ctx context.Context, p, tree, parent, msg string, author, committer Ident) (string, error) { args := append(g.signArgs(), "-C", p, "commit-tree", "-S", tree) if parent != "" { args = append(args, "-p", parent) } args = append(args, "-m", msg) out, err := g.run(ctx, runOpts{extraEnv: identEnv(author, committer)}, args...) if err != nil { return "", fmt.Errorf("commit-tree: %w", err) } return strings.TrimSpace(string(out)), nil } // updateRef moves ref to sha. oldSHA is the value the caller read before it // built the new commit; git refuses the update when the ref moved since then. // An empty oldSHA means the ref must not exist yet. func (g *Git) updateRef(ctx context.Context, p, ref, sha, oldSHA string) error { _, err := g.run(ctx, runOpts{}, "-C", p, "update-ref", ref, sha, oldSHA) if err != nil && isRefRaceError(err) { return fmt.Errorf("%q: %w", ref, ErrRefChanged) } return err } // isRefRaceError recognises the messages git prints when the old value did // not match, which means someone else moved the ref first. func isRefRaceError(err error) bool { msg := err.Error() return strings.Contains(msg, "but expected") || strings.Contains(msg, "cannot lock ref") || strings.Contains(msg, "reference already exists") } // branchTip returns the commit a branch points at, or "" when the branch does // not exist. Any other git failure is returned, so an unreadable repository // is never mistaken for an empty one. func (g *Git) branchTip(ctx context.Context, p, branchRef string) (string, error) { // --quiet makes rev-parse exit 1 without a message when the ref does not // resolve. Any other failure, such as an unreadable repository, exits 128. out, err := g.run(ctx, runOpts{}, "-C", p, "rev-parse", "--verify", "--quiet", "--end-of-options", branchRef) if err != nil { var ee *exec.ExitError if errors.As(err, &ee) && ee.ExitCode() == 1 { return "", nil } return "", err } return strings.TrimSpace(string(out)), nil } // writeOp validates the repo name, takes the per-repo write lock, and runs fn. func (g *Git) writeOp(name string, fn func(p string) error) error { p, err := g.repoDir(name) if err != nil { return err } m := g.lock(name) m.Lock() defer m.Unlock() return fn(p) } // CheckPatch previews whether a patch applies. It uses a throwaway index so // a read-only preview can never disturb a concurrent write. func (g *Git) CheckPatch(ctx context.Context, name, patch string) (ApplyResult, error) { p, err := g.repoDir(name) if err != nil { return ApplyResult{}, err } res := ApplyResult{Status: "conflict"} err = withTempDir("patch", func(dir string) error { env := []string{"GIT_INDEX_FILE=" + dir + "/index"} // A bare repo has no work tree, so seed the index from HEAD and check // against objects with --cached. if _, err := g.run(ctx, runOpts{extraEnv: env}, "-C", p, "read-tree", "HEAD"); err != nil { return err } out, err := g.run(ctx, runOpts{extraEnv: env, stdin: []byte(patch)}, "-C", p, "apply", "--check", "--cached") if err != nil { res.Output = err.Error() return nil } res = ApplyResult{Status: "clean", Output: string(out)} return nil }) return res, err } // ApplyPatch applies a patch to HEAD and records a signed commit. // It returns ErrConflict when the patch does not apply. func (g *Git) ApplyPatch(ctx context.Context, name, patch string, author, committer Ident) (string, error) { var sha string err := g.writeOp(name, func(p string) error { // The parent is resolved before the tree is read, so the commit is // built on exactly the commit update-ref then guards against. parent, err := g.line(ctx, "-C", p, "rev-parse", "HEAD") if err != nil { return err } if err := g.readTree(ctx, p, "", parent); err != nil { return err } if _, err := g.run(ctx, runOpts{stdin: []byte(patch)}, "-C", p, "apply", "--cached"); err != nil { return fmt.Errorf("%w: %s", ErrConflict, err) } tree, err := g.writeTree(ctx, p, "") if err != nil { return err } sha, err = g.commitTree(ctx, p, tree, parent, PatchCommitMessage(patch), author, committer) if err != nil { return err } ref, err := g.line(ctx, "-C", p, "symbolic-ref", "HEAD") if err != nil { return err } return g.updateRef(ctx, p, ref, sha, parent) }) return sha, err } // EditFile writes content at newPath on branch and commits it. // oldPath empty means create. oldPath != newPath means rename. func (g *Git) EditFile(ctx context.Context, name, branch, oldPath, newPath string, content []byte, message string, who Ident) (string, error) { if !ValidRef(branch) { return "", fmt.Errorf("%q: %w", branch, ErrInvalidRef) } if !ValidPath(newPath) || (oldPath != "" && !ValidPath(oldPath)) { return "", fmt.Errorf("%w: file path", ErrInvalidRef) } var sha string err := g.writeOp(name, func(p string) error { branchRef := "refs/heads/" + branch parent, err := g.branchTip(ctx, p, branchRef) if err != nil { return err } return withTempDir("edit", func(work string) error { if parent != "" { if err := g.readTree(ctx, p, work, branchRef); err != nil { return err } } mode := "100644" if oldPath != "" { if m := g.treeFileMode(ctx, p, branchRef, oldPath); m != "" { mode = m } if oldPath != newPath { if _, err := g.run(ctx, runOpts{}, gitArgs(p, work, "update-index", "--remove", "--", oldPath)...); err != nil { return err } } } blob, err := g.hashObject(ctx, p, content) if err != nil { return err } if _, err := g.run(ctx, runOpts{}, gitArgs(p, work, "update-index", "--add", "--cacheinfo", mode+","+blob+","+newPath)...); err != nil { return err } tree, err := g.writeTree(ctx, p, work) if err != nil { return err } sha, err = g.commitTree(ctx, p, tree, parent, message, who, who) if err != nil { return err } return g.updateRef(ctx, p, branchRef, sha, parent) }) }) return sha, err } // MoveFile renames a path on a branch, keeping its contents and mode. func (g *Git) MoveFile(ctx context.Context, name, branch, oldPath, newPath, message string, who Ident) (string, error) { content, err := g.Show(ctx, name, "refs/heads/"+branch, oldPath) if err != nil { return "", err } return g.EditFile(ctx, name, branch, oldPath, newPath, content, message, who) } // DeleteFile removes a path on a branch and commits it. func (g *Git) DeleteFile(ctx context.Context, name, branch, filePath, message string, who Ident) (string, error) { if !ValidRef(branch) { return "", fmt.Errorf("%q: %w", branch, ErrInvalidRef) } if !ValidPath(filePath) { return "", fmt.Errorf("%w: file path", ErrInvalidRef) } var sha string err := g.writeOp(name, func(p string) error { branchRef := "refs/heads/" + branch // The parent is resolved before the tree is read, so a push that // lands in between is caught by update-ref instead of being reverted. parent, err := g.branchTip(ctx, p, branchRef) if err != nil { return err } if parent == "" { return fmt.Errorf("branch %q: %w", branch, ErrNotFound) } return withTempDir("del", func(work string) error { if err := g.readTree(ctx, p, work, parent); err != nil { return err } if _, err := g.run(ctx, runOpts{}, gitArgs(p, work, "update-index", "--remove", "--", filePath)...); err != nil { return err } tree, err := g.writeTree(ctx, p, work) if err != nil { return err } sha, err = g.commitTree(ctx, p, tree, parent, message, who, who) if err != nil { return err } return g.updateRef(ctx, p, branchRef, sha, parent) }) }) return sha, err } // CreateBranch points a new branch at sourceRef. func (g *Git) CreateBranch(ctx context.Context, name, branch, sourceRef string) error { if !ValidRef(branch) { return fmt.Errorf("%q: %w", branch, ErrInvalidRef) } return g.writeOp(name, func(p string) error { sha, err := g.ResolveRef(ctx, name, sourceRef) if err != nil { return err } if _, err := g.ResolveRef(ctx, name, "refs/heads/"+branch); err == nil { return fmt.Errorf("branch %q: %w", branch, ErrExists) } defer g.InvalidateRefCache(name) return g.updateRef(ctx, p, "refs/heads/"+branch, sha, "") }) } // DeleteBranch removes a branch ref. func (g *Git) DeleteBranch(ctx context.Context, name, branch string) error { if !ValidRef(branch) { return fmt.Errorf("%q: %w", branch, ErrInvalidRef) } return g.writeOp(name, func(p string) error { if _, err := g.ResolveRef(ctx, name, "refs/heads/"+branch); err != nil { return fmt.Errorf("branch %q: %w", branch, ErrNotFound) } defer g.InvalidateRefCache(name) _, err := g.run(ctx, runOpts{}, "-C", p, "update-ref", "-d", "refs/heads/"+branch) return err }) } // RenameBranch moves a branch ref to a new name. func (g *Git) RenameBranch(ctx context.Context, name, oldName, newName string) error { if !ValidRef(oldName) || !ValidRef(newName) { return ErrInvalidRef } return g.writeOp(name, func(p string) error { sha, err := g.ResolveRef(ctx, name, "refs/heads/"+oldName) if err != nil { return fmt.Errorf("branch %q: %w", oldName, ErrNotFound) } if _, err := g.ResolveRef(ctx, name, "refs/heads/"+newName); err == nil { return fmt.Errorf("branch %q: %w", newName, ErrExists) } defer g.InvalidateRefCache(name) if err := g.updateRef(ctx, p, "refs/heads/"+newName, sha, ""); err != nil { return err } _, err = g.run(ctx, runOpts{}, "-C", p, "update-ref", "-d", "refs/heads/"+oldName) return err }) } // CreateTag makes a lightweight tag, or a signed annotated tag when message // is non-empty. func (g *Git) CreateTag(ctx context.Context, name, tagName, ref, message string, tagger Ident) error { if !ValidRef(tagName) { return fmt.Errorf("%q: %w", tagName, ErrInvalidRef) } if !ValidRef(ref) { return fmt.Errorf("%q: %w", ref, ErrInvalidRef) } return g.writeOp(name, func(p string) error { args := []string{"-C", p, "tag", "--end-of-options", tagName, ref} opts := runOpts{} if message != "" { args = append(append(g.signArgs(), "-C", p, "tag", "-s", "-m", message, "--end-of-options"), tagName, ref) opts.extraEnv = identEnv(tagger, tagger) } _, err := g.run(ctx, opts, args...) if err != nil { if strings.Contains(err.Error(), "already exists") { return fmt.Errorf("tag %q: %w", tagName, ErrExists) } return asBadRef(ref, err) } g.InvalidateRefCache(name) return nil }) } // DeleteTag removes a tag ref. func (g *Git) DeleteTag(ctx context.Context, name, tagName string) error { if !ValidRef(tagName) { return fmt.Errorf("%q: %w", tagName, ErrInvalidRef) } return g.writeOp(name, func(p string) error { if _, err := g.ResolveRef(ctx, name, "refs/tags/"+tagName); err != nil { return fmt.Errorf("tag %q: %w", tagName, ErrNotFound) } defer g.InvalidateRefCache(name) _, err := g.run(ctx, runOpts{}, "-C", p, "tag", "-d", "--end-of-options", tagName) return err }) } // --- patch text parsing --- // PatchMeta is the header block of a format-patch mail. type PatchMeta struct { Subject string Body string Author string Email string Date string } func stripPatchTag(s string) string { if !strings.HasPrefix(s, "[PATCH") { return s } if i := strings.IndexByte(s, ']'); i >= 0 { return strings.TrimLeft(s[i+1:], " \t") } return s } // PatchCommitMessage is the commit message a patch should record: its // subject, then a blank line and the body when the patch carries one. func PatchCommitMessage(patch string) string { m := ExtractPatchMeta(patch) if m.Body == "" { return m.Subject } return m.Subject + "\n\n" + m.Body } // decodeWords decodes RFC 2047 encoded words such as "=?UTF-8?q?J=C3=B6rg?=". // A header git wrote plain, or one in a charset the decoder does not know, // is kept as it is. func decodeWords(s string) string { out, err := new(mime.WordDecoder).DecodeHeader(s) if err != nil { return s } return out } // ExtractPatchMeta parses the mail headers and the commit message body. func ExtractPatchMeta(patch string) PatchMeta { var m PatchMeta var body []string inHeaders, pastSubject := true, false header := "" // takeHeader reads one unfolded header line. takeHeader := func() { switch { case strings.HasPrefix(header, "From: "): m.Author, m.Email = parseFrom(header[6:]) case strings.HasPrefix(header, "Date: "): m.Date = strings.TrimSpace(header[6:]) case strings.HasPrefix(header, "Subject: "): m.Subject = decodeWords(stripPatchTag(header[9:])) pastSubject = true } header = "" } for _, line := range strings.Split(patch, "\n") { if inHeaders { // RFC 5322 folding: a line starting with space or tab continues // the header above it. Unfolding keeps that whitespace. if header != "" && (strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t")) { header += strings.TrimRight(line, "\r") continue } takeHeader() if pastSubject && line == "" { inHeaders = false continue } header = strings.TrimRight(line, "\r") continue } if line == "---" { break } body = append(body, line) } if header != "" { takeHeader() } for len(body) > 0 && strings.TrimSpace(body[len(body)-1]) == "" { body = body[:len(body)-1] } m.Body = strings.Join(body, "\n") return m } // parseFrom splits `Name ` into its two parts. func parseFrom(s string) (string, string) { open := strings.IndexByte(s, '<') closeIdx := strings.IndexByte(s, '>') if open < 0 || closeIdx < open { return decodeWords(strings.TrimSpace(s)), "" } return decodeWords(strings.TrimSpace(s[:open])), s[open+1 : closeIdx] } // --- patch apply cache --- // PatchCache remembers `git apply --check` results so the patch page does not // re-run git on every view. type PatchCache = util.Cache[int64, ApplyResult] func NewPatchCache() *PatchCache { return util.NewCache[int64, ApplyResult](maxPatchCache, patchCacheTTL) }