package gitcmd import ( "context" "errors" "fmt" "mime" "os" "os/exec" "path/filepath" "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) } // idx runs git in repo p against a private index file inside work. work is // the dummy work tree that `update-index` insists on even in a bare repo. // Writes never share the repo index, which may hold stale entries. func (g *Git) idx(ctx context.Context, p, work string, opt runOpts, rest ...string) ([]byte, error) { opt.extraEnv = append(opt.extraEnv, "GIT_INDEX_FILE="+filepath.Join(work, "index")) return g.run(ctx, opt, append([]string{"--work-tree=" + work, "-C", p}, 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] } // branchRef returns the full ref of a branch as Branches lists it. Branches // prints "heads/v1" when a tag v1 exists too, so "refs/heads/"+branch would // name a different ref. func (g *Git) branchRef(ctx context.Context, p, branch string) string { out, err := g.line(ctx, "-C", p, "rev-parse", "--verify", "--quiet", "--symbolic-full-name", "--end-of-options", branch) if err == nil && strings.HasPrefix(out, "refs/heads/") { return out } return "refs/heads/" + branch } func (g *Git) writeTree(ctx context.Context, p, work string) (string, error) { out, err := g.idx(ctx, p, work, runOpts{}, "write-tree") return strings.TrimSpace(string(out)), err } func (g *Git) readTree(ctx context.Context, p, work, ref string) error { _, err := g.idx(ctx, p, work, runOpts{}, "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, name, 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) } if err == nil { g.refUpdated(name, ref, sha) } return err } func (g *Git) refUpdated(name, ref, rev string) { if g.OnRefUpdate != nil { g.OnRefUpdate(name, ref, rev) } } // 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 { // A bare repo has no work tree, so seed the index from HEAD and check // against objects with --cached. if err := g.readTree(ctx, p, dir, "HEAD"); err != nil { return err } out, err := g.idx(ctx, p, dir, runOpts{stdin: []byte(patch)}, "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 } err = withTempDir("patch", func(work string) error { if err := g.readTree(ctx, p, work, parent); err != nil { return err } if _, err := g.idx(ctx, p, work, runOpts{stdin: []byte(patch)}, "apply", "--cached"); err != nil { return fmt.Errorf("%w: %s", ErrConflict, err) } tree, err := g.writeTree(ctx, p, work) if err != nil { return err } sha, err = g.commitTree(ctx, p, tree, parent, PatchCommitMessage(patch), author, committer) return err }) if err != nil { return err } ref, err := g.line(ctx, "-C", p, "symbolic-ref", "HEAD") if err != nil { return err } return g.updateRef(ctx, name, p, ref, sha, parent) }) return sha, err } // StaleError reports that the edited file changed on the branch after the // editor loaded it. Tip is the current branch tip, which the editor offers as // the new base. type StaleError struct{ Tip string } func (e *StaleError) Error() string { return "file changed on the branch (tip " + e.Tip + ")" } // EditFile writes content at newPath on branch and commits it. // oldPath empty means create. oldPath != newPath means rename. // base is the commit the edit was made on. When the branch moved past base, // the edit lands on the new tip only if oldPath is unchanged there. // Otherwise it returns a *StaleError. An empty base skips the check. func (g *Git) EditFile(ctx context.Context, name, branch, base, oldPath, newPath string, content []byte, message string, who Ident) (string, error) { if !ValidRef(branch) || (base != "" && !ValidRef(base)) { 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 := g.branchRef(ctx, p, branch) parent, err := g.branchTip(ctx, p, branchRef) if err != nil { return err } if parent == "" && oldPath != "" { return fmt.Errorf("branch %q: %w", branch, ErrNotFound) } // A create has no old file. The check below keeps its path free. if base != "" && base != parent && oldPath != "" { if err := g.checkUnchanged(ctx, p, base, parent, oldPath); err != nil { return err } } if newPath != oldPath { if _, typ, _, err := g.objectInfo(ctx, p, parent, newPath); err != nil { return err } else if typ != "" { return fmt.Errorf("path %q: %w", newPath, ErrExists) } } return withTempDir("edit", func(work string) error { if parent != "" { if err := g.readTree(ctx, p, work, parent); err != nil { return err } } mode := "100644" if oldPath != "" { if m := g.treeFileMode(ctx, p, parent, oldPath); m != "" { mode = m } if oldPath != newPath { if _, err := g.idx(ctx, p, work, runOpts{}, "update-index", "--force-remove", "--", oldPath); err != nil { return err } } } blob, err := g.hashObject(ctx, p, content) if err != nil { return err } if _, err := g.idx(ctx, p, work, runOpts{}, "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, name, p, branchRef, sha, parent) }) }) return sha, err } // checkUnchanged returns a *StaleError unless filePath is the same object at // base and tip. func (g *Git) checkUnchanged(ctx context.Context, p, base, tip, filePath string) error { was, _, _, err := g.objectInfo(ctx, p, base, filePath) if err != nil { return err } now, _, _, err := g.objectInfo(ctx, p, tip, filePath) if err != nil { return err } if was == "" || was != now { return &StaleError{Tip: tip} } return nil } // DeleteFile removes a file on a branch and commits it. base follows the // EditFile rule for a branch that moved past it. func (g *Git) DeleteFile(ctx context.Context, name, branch, base, filePath, message string, who Ident) (string, error) { if !ValidRef(branch) || (base != "" && !ValidRef(base)) { 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 := g.branchRef(ctx, p, 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) } if _, typ, _, err := g.objectInfo(ctx, p, parent, filePath); err != nil { return err } else if typ != "blob" { return fmt.Errorf("file %q: %w", filePath, ErrNotFound) } if base != "" && base != parent { if err := g.checkUnchanged(ctx, p, base, parent, filePath); err != nil { return err } } return withTempDir("del", func(work string) error { if err := g.readTree(ctx, p, work, parent); err != nil { return err } if _, err := g.idx(ctx, p, work, runOpts{}, "update-index", "--force-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, name, 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 { // ^{commit} peels an annotated tag. sha, err := g.ResolveRef(ctx, name, sourceRef+"^{commit}") 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, name, 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 { ref := g.branchRef(ctx, p, branch) if _, err := g.ResolveRef(ctx, name, ref); err != nil { return fmt.Errorf("branch %q: %w", branch, ErrNotFound) } defer g.InvalidateRefCache(name) _, err := g.run(ctx, runOpts{}, "-C", p, "update-ref", "-d", ref) 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 { oldRef := g.branchRef(ctx, p, oldName) sha, err := g.ResolveRef(ctx, name, oldRef) 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, name, p, "refs/heads/"+newName, sha, ""); err != nil { return err } _, err = g.run(ctx, runOpts{}, "-C", p, "update-ref", "-d", oldRef) 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) g.refUpdated(name, "refs/tags/"+tagName, "refs/tags/"+tagName) 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) }