write.go
⎇
Raw
1package gitcmd
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "mime"
8 "os"
9 "os/exec"
10 "strings"
11
12 "hearthforge/internal/util"
13)
14
15// ApplyResult is the outcome of a `git apply --check` preview.
16type ApplyResult struct {
17 Status string // clean or conflict
18 Output string
19}
20
21// withTempDir makes a private 0700 directory and removes it afterwards.
22// A predictable /tmp path would be open to a pre-planted symlink.
23func withTempDir(prefix string, fn func(dir string) error) error {
24 dir, err := os.MkdirTemp("", "hf-"+prefix+"-")
25 if err != nil {
26 return err
27 }
28 defer os.RemoveAll(dir)
29 return fn(dir)
30}
31
32// gitArgs builds the leading arguments. work is the dummy work tree that
33// `update-index --remove` insists on even in a bare repo.
34func gitArgs(p, work string, rest ...string) []string {
35 var args []string
36 if work != "" {
37 args = append(args, "--work-tree="+work)
38 }
39 args = append(args, "-C", p)
40 return append(args, rest...)
41}
42
43// treeFileMode returns the 6-digit octal mode of a path at a ref, or "" when
44// it does not exist there. It preserves the executable bit and symlinks
45// across UI edits.
46func (g *Git) treeFileMode(ctx context.Context, p, ref, filePath string) string {
47 out, err := g.text(ctx, "-C", p, "ls-tree", "--end-of-options", ref, "--", filePath)
48 if err != nil {
49 return ""
50 }
51 fields := strings.Fields(out)
52 if len(fields) == 0 || len(fields[0]) != 6 {
53 return ""
54 }
55 for _, c := range fields[0] {
56 if c < '0' || c > '7' {
57 return ""
58 }
59 }
60 return fields[0]
61}
62
63func (g *Git) writeTree(ctx context.Context, p, work string) (string, error) {
64 return g.line(ctx, gitArgs(p, work, "write-tree")...)
65}
66
67func (g *Git) readTree(ctx context.Context, p, work, ref string) error {
68 _, err := g.run(ctx, runOpts{}, gitArgs(p, work, "read-tree", "--end-of-options", ref)...)
69 return err
70}
71
72// hashObject writes content into the object store and returns its id.
73func (g *Git) hashObject(ctx context.Context, p string, content []byte) (string, error) {
74 out, err := g.run(ctx, runOpts{stdin: content}, "-C", p, "hash-object", "-w", "--stdin")
75 if err != nil {
76 return "", err
77 }
78 return strings.TrimSpace(string(out)), nil
79}
80
81// commitTree creates a signed commit object. parent may be empty for the
82// first commit on a branch.
83func (g *Git) commitTree(ctx context.Context, p, tree, parent, msg string, author, committer Ident) (string, error) {
84 args := append(g.signArgs(), "-C", p, "commit-tree", "-S", tree)
85 if parent != "" {
86 args = append(args, "-p", parent)
87 }
88 args = append(args, "-m", msg)
89 out, err := g.run(ctx, runOpts{extraEnv: identEnv(author, committer)}, args...)
90 if err != nil {
91 return "", fmt.Errorf("commit-tree: %w", err)
92 }
93 return strings.TrimSpace(string(out)), nil
94}
95
96// updateRef moves ref to sha. oldSHA is the value the caller read before it
97// built the new commit; git refuses the update when the ref moved since then.
98// An empty oldSHA means the ref must not exist yet.
99func (g *Git) updateRef(ctx context.Context, p, ref, sha, oldSHA string) error {
100 _, err := g.run(ctx, runOpts{}, "-C", p, "update-ref", ref, sha, oldSHA)
101 if err != nil && isRefRaceError(err) {
102 return fmt.Errorf("%q: %w", ref, ErrRefChanged)
103 }
104 return err
105}
106
107// isRefRaceError recognises the messages git prints when the old value did
108// not match, which means someone else moved the ref first.
109func isRefRaceError(err error) bool {
110 msg := err.Error()
111 return strings.Contains(msg, "but expected") ||
112 strings.Contains(msg, "cannot lock ref") ||
113 strings.Contains(msg, "reference already exists")
114}
115
116// branchTip returns the commit a branch points at, or "" when the branch does
117// not exist. Any other git failure is returned, so an unreadable repository
118// is never mistaken for an empty one.
119func (g *Git) branchTip(ctx context.Context, p, branchRef string) (string, error) {
120 // --quiet makes rev-parse exit 1 without a message when the ref does not
121 // resolve. Any other failure, such as an unreadable repository, exits 128.
122 out, err := g.run(ctx, runOpts{}, "-C", p, "rev-parse", "--verify", "--quiet",
123 "--end-of-options", branchRef)
124 if err != nil {
125 var ee *exec.ExitError
126 if errors.As(err, &ee) && ee.ExitCode() == 1 {
127 return "", nil
128 }
129 return "", err
130 }
131 return strings.TrimSpace(string(out)), nil
132}
133
134// writeOp validates the repo name, takes the per-repo write lock, and runs fn.
135func (g *Git) writeOp(name string, fn func(p string) error) error {
136 p, err := g.repoDir(name)
137 if err != nil {
138 return err
139 }
140 m := g.lock(name)
141 m.Lock()
142 defer m.Unlock()
143 return fn(p)
144}
145
146// CheckPatch previews whether a patch applies. It uses a throwaway index so
147// a read-only preview can never disturb a concurrent write.
148func (g *Git) CheckPatch(ctx context.Context, name, patch string) (ApplyResult, error) {
149 p, err := g.repoDir(name)
150 if err != nil {
151 return ApplyResult{}, err
152 }
153 res := ApplyResult{Status: "conflict"}
154 err = withTempDir("patch", func(dir string) error {
155 env := []string{"GIT_INDEX_FILE=" + dir + "/index"}
156 // A bare repo has no work tree, so seed the index from HEAD and check
157 // against objects with --cached.
158 if _, err := g.run(ctx, runOpts{extraEnv: env}, "-C", p, "read-tree", "HEAD"); err != nil {
159 return err
160 }
161 out, err := g.run(ctx, runOpts{extraEnv: env, stdin: []byte(patch)},
162 "-C", p, "apply", "--check", "--cached")
163 if err != nil {
164 res.Output = err.Error()
165 return nil
166 }
167 res = ApplyResult{Status: "clean", Output: string(out)}
168 return nil
169 })
170 return res, err
171}
172
173// ApplyPatch applies a patch to HEAD and records a signed commit.
174// It returns ErrConflict when the patch does not apply.
175func (g *Git) ApplyPatch(ctx context.Context, name, patch string, author, committer Ident) (string, error) {
176 var sha string
177 err := g.writeOp(name, func(p string) error {
178 // The parent is resolved before the tree is read, so the commit is
179 // built on exactly the commit update-ref then guards against.
180 parent, err := g.line(ctx, "-C", p, "rev-parse", "HEAD")
181 if err != nil {
182 return err
183 }
184 if err := g.readTree(ctx, p, "", parent); err != nil {
185 return err
186 }
187 if _, err := g.run(ctx, runOpts{stdin: []byte(patch)}, "-C", p, "apply", "--cached"); err != nil {
188 return fmt.Errorf("%w: %s", ErrConflict, err)
189 }
190 tree, err := g.writeTree(ctx, p, "")
191 if err != nil {
192 return err
193 }
194 sha, err = g.commitTree(ctx, p, tree, parent, PatchCommitMessage(patch), author, committer)
195 if err != nil {
196 return err
197 }
198 ref, err := g.line(ctx, "-C", p, "symbolic-ref", "HEAD")
199 if err != nil {
200 return err
201 }
202 return g.updateRef(ctx, p, ref, sha, parent)
203 })
204 return sha, err
205}
206
207// EditFile writes content at newPath on branch and commits it.
208// oldPath empty means create. oldPath != newPath means rename.
209func (g *Git) EditFile(ctx context.Context, name, branch, oldPath, newPath string, content []byte, message string, who Ident) (string, error) {
210 if !ValidRef(branch) {
211 return "", fmt.Errorf("%q: %w", branch, ErrInvalidRef)
212 }
213 if !ValidPath(newPath) || (oldPath != "" && !ValidPath(oldPath)) {
214 return "", fmt.Errorf("%w: file path", ErrInvalidRef)
215 }
216 var sha string
217 err := g.writeOp(name, func(p string) error {
218 branchRef := "refs/heads/" + branch
219 parent, err := g.branchTip(ctx, p, branchRef)
220 if err != nil {
221 return err
222 }
223 return withTempDir("edit", func(work string) error {
224 if parent != "" {
225 if err := g.readTree(ctx, p, work, branchRef); err != nil {
226 return err
227 }
228 }
229 mode := "100644"
230 if oldPath != "" {
231 if m := g.treeFileMode(ctx, p, branchRef, oldPath); m != "" {
232 mode = m
233 }
234 if oldPath != newPath {
235 if _, err := g.run(ctx, runOpts{}, gitArgs(p, work, "update-index", "--remove", "--", oldPath)...); err != nil {
236 return err
237 }
238 }
239 }
240 blob, err := g.hashObject(ctx, p, content)
241 if err != nil {
242 return err
243 }
244 if _, err := g.run(ctx, runOpts{}, gitArgs(p, work, "update-index", "--add",
245 "--cacheinfo", mode+","+blob+","+newPath)...); err != nil {
246 return err
247 }
248 tree, err := g.writeTree(ctx, p, work)
249 if err != nil {
250 return err
251 }
252 sha, err = g.commitTree(ctx, p, tree, parent, message, who, who)
253 if err != nil {
254 return err
255 }
256 return g.updateRef(ctx, p, branchRef, sha, parent)
257 })
258 })
259 return sha, err
260}
261
262// MoveFile renames a path on a branch, keeping its contents and mode.
263func (g *Git) MoveFile(ctx context.Context, name, branch, oldPath, newPath, message string, who Ident) (string, error) {
264 content, err := g.Show(ctx, name, "refs/heads/"+branch, oldPath)
265 if err != nil {
266 return "", err
267 }
268 return g.EditFile(ctx, name, branch, oldPath, newPath, content, message, who)
269}
270
271// DeleteFile removes a path on a branch and commits it.
272func (g *Git) DeleteFile(ctx context.Context, name, branch, filePath, message string, who Ident) (string, error) {
273 if !ValidRef(branch) {
274 return "", fmt.Errorf("%q: %w", branch, ErrInvalidRef)
275 }
276 if !ValidPath(filePath) {
277 return "", fmt.Errorf("%w: file path", ErrInvalidRef)
278 }
279 var sha string
280 err := g.writeOp(name, func(p string) error {
281 branchRef := "refs/heads/" + branch
282 // The parent is resolved before the tree is read, so a push that
283 // lands in between is caught by update-ref instead of being reverted.
284 parent, err := g.branchTip(ctx, p, branchRef)
285 if err != nil {
286 return err
287 }
288 if parent == "" {
289 return fmt.Errorf("branch %q: %w", branch, ErrNotFound)
290 }
291 return withTempDir("del", func(work string) error {
292 if err := g.readTree(ctx, p, work, parent); err != nil {
293 return err
294 }
295 if _, err := g.run(ctx, runOpts{}, gitArgs(p, work, "update-index", "--remove", "--", filePath)...); err != nil {
296 return err
297 }
298 tree, err := g.writeTree(ctx, p, work)
299 if err != nil {
300 return err
301 }
302 sha, err = g.commitTree(ctx, p, tree, parent, message, who, who)
303 if err != nil {
304 return err
305 }
306 return g.updateRef(ctx, p, branchRef, sha, parent)
307 })
308 })
309 return sha, err
310}
311
312// CreateBranch points a new branch at sourceRef.
313func (g *Git) CreateBranch(ctx context.Context, name, branch, sourceRef string) error {
314 if !ValidRef(branch) {
315 return fmt.Errorf("%q: %w", branch, ErrInvalidRef)
316 }
317 return g.writeOp(name, func(p string) error {
318 sha, err := g.ResolveRef(ctx, name, sourceRef)
319 if err != nil {
320 return err
321 }
322 if _, err := g.ResolveRef(ctx, name, "refs/heads/"+branch); err == nil {
323 return fmt.Errorf("branch %q: %w", branch, ErrExists)
324 }
325 defer g.InvalidateRefCache(name)
326 return g.updateRef(ctx, p, "refs/heads/"+branch, sha, "")
327 })
328}
329
330// DeleteBranch removes a branch ref.
331func (g *Git) DeleteBranch(ctx context.Context, name, branch string) error {
332 if !ValidRef(branch) {
333 return fmt.Errorf("%q: %w", branch, ErrInvalidRef)
334 }
335 return g.writeOp(name, func(p string) error {
336 if _, err := g.ResolveRef(ctx, name, "refs/heads/"+branch); err != nil {
337 return fmt.Errorf("branch %q: %w", branch, ErrNotFound)
338 }
339 defer g.InvalidateRefCache(name)
340 _, err := g.run(ctx, runOpts{}, "-C", p, "update-ref", "-d", "refs/heads/"+branch)
341 return err
342 })
343}
344
345// RenameBranch moves a branch ref to a new name.
346func (g *Git) RenameBranch(ctx context.Context, name, oldName, newName string) error {
347 if !ValidRef(oldName) || !ValidRef(newName) {
348 return ErrInvalidRef
349 }
350 return g.writeOp(name, func(p string) error {
351 sha, err := g.ResolveRef(ctx, name, "refs/heads/"+oldName)
352 if err != nil {
353 return fmt.Errorf("branch %q: %w", oldName, ErrNotFound)
354 }
355 if _, err := g.ResolveRef(ctx, name, "refs/heads/"+newName); err == nil {
356 return fmt.Errorf("branch %q: %w", newName, ErrExists)
357 }
358 defer g.InvalidateRefCache(name)
359 if err := g.updateRef(ctx, p, "refs/heads/"+newName, sha, ""); err != nil {
360 return err
361 }
362 _, err = g.run(ctx, runOpts{}, "-C", p, "update-ref", "-d", "refs/heads/"+oldName)
363 return err
364 })
365}
366
367// CreateTag makes a lightweight tag, or a signed annotated tag when message
368// is non-empty.
369func (g *Git) CreateTag(ctx context.Context, name, tagName, ref, message string, tagger Ident) error {
370 if !ValidRef(tagName) {
371 return fmt.Errorf("%q: %w", tagName, ErrInvalidRef)
372 }
373 if !ValidRef(ref) {
374 return fmt.Errorf("%q: %w", ref, ErrInvalidRef)
375 }
376 return g.writeOp(name, func(p string) error {
377 args := []string{"-C", p, "tag", "--end-of-options", tagName, ref}
378 opts := runOpts{}
379 if message != "" {
380 args = append(append(g.signArgs(), "-C", p, "tag", "-s", "-m", message, "--end-of-options"), tagName, ref)
381 opts.extraEnv = identEnv(tagger, tagger)
382 }
383 _, err := g.run(ctx, opts, args...)
384 if err != nil {
385 if strings.Contains(err.Error(), "already exists") {
386 return fmt.Errorf("tag %q: %w", tagName, ErrExists)
387 }
388 return asBadRef(ref, err)
389 }
390 g.InvalidateRefCache(name)
391 return nil
392 })
393}
394
395// DeleteTag removes a tag ref.
396func (g *Git) DeleteTag(ctx context.Context, name, tagName string) error {
397 if !ValidRef(tagName) {
398 return fmt.Errorf("%q: %w", tagName, ErrInvalidRef)
399 }
400 return g.writeOp(name, func(p string) error {
401 if _, err := g.ResolveRef(ctx, name, "refs/tags/"+tagName); err != nil {
402 return fmt.Errorf("tag %q: %w", tagName, ErrNotFound)
403 }
404 defer g.InvalidateRefCache(name)
405 _, err := g.run(ctx, runOpts{}, "-C", p, "tag", "-d", "--end-of-options", tagName)
406 return err
407 })
408}
409
410// --- patch text parsing ---
411
412// PatchMeta is the header block of a format-patch mail.
413type PatchMeta struct {
414 Subject string
415 Body string
416 Author string
417 Email string
418 Date string
419}
420
421func stripPatchTag(s string) string {
422 if !strings.HasPrefix(s, "[PATCH") {
423 return s
424 }
425 if i := strings.IndexByte(s, ']'); i >= 0 {
426 return strings.TrimLeft(s[i+1:], " \t")
427 }
428 return s
429}
430
431// PatchCommitMessage is the commit message a patch should record: its
432// subject, then a blank line and the body when the patch carries one.
433func PatchCommitMessage(patch string) string {
434 m := ExtractPatchMeta(patch)
435 if m.Body == "" {
436 return m.Subject
437 }
438 return m.Subject + "\n\n" + m.Body
439}
440
441// decodeWords decodes RFC 2047 encoded words such as "=?UTF-8?q?J=C3=B6rg?=".
442// A header git wrote plain, or one in a charset the decoder does not know,
443// is kept as it is.
444func decodeWords(s string) string {
445 out, err := new(mime.WordDecoder).DecodeHeader(s)
446 if err != nil {
447 return s
448 }
449 return out
450}
451
452// ExtractPatchMeta parses the mail headers and the commit message body.
453func ExtractPatchMeta(patch string) PatchMeta {
454 var m PatchMeta
455 var body []string
456 inHeaders, pastSubject := true, false
457 header := ""
458 // takeHeader reads one unfolded header line.
459 takeHeader := func() {
460 switch {
461 case strings.HasPrefix(header, "From: "):
462 m.Author, m.Email = parseFrom(header[6:])
463 case strings.HasPrefix(header, "Date: "):
464 m.Date = strings.TrimSpace(header[6:])
465 case strings.HasPrefix(header, "Subject: "):
466 m.Subject = decodeWords(stripPatchTag(header[9:]))
467 pastSubject = true
468 }
469 header = ""
470 }
471 for _, line := range strings.Split(patch, "\n") {
472 if inHeaders {
473 // RFC 5322 folding: a line starting with space or tab continues
474 // the header above it. Unfolding keeps that whitespace.
475 if header != "" && (strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t")) {
476 header += strings.TrimRight(line, "\r")
477 continue
478 }
479 takeHeader()
480 if pastSubject && line == "" {
481 inHeaders = false
482 continue
483 }
484 header = strings.TrimRight(line, "\r")
485 continue
486 }
487 if line == "---" {
488 break
489 }
490 body = append(body, line)
491 }
492 if header != "" {
493 takeHeader()
494 }
495 for len(body) > 0 && strings.TrimSpace(body[len(body)-1]) == "" {
496 body = body[:len(body)-1]
497 }
498 m.Body = strings.Join(body, "\n")
499 return m
500}
501
502// parseFrom splits `Name <mail@host>` into its two parts.
503func parseFrom(s string) (string, string) {
504 open := strings.IndexByte(s, '<')
505 closeIdx := strings.IndexByte(s, '>')
506 if open < 0 || closeIdx < open {
507 return decodeWords(strings.TrimSpace(s)), ""
508 }
509 return decodeWords(strings.TrimSpace(s[:open])), s[open+1 : closeIdx]
510}
511
512// --- patch apply cache ---
513
514// PatchCache remembers `git apply --check` results so the patch page does not
515// re-run git on every view.
516type PatchCache = util.Cache[int64, ApplyResult]
517
518func NewPatchCache() *PatchCache {
519 return util.NewCache[int64, ApplyResult](maxPatchCache, patchCacheTTL)
520}
521