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