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