~gitpocket/gitpocket

feat(web): fast forward merges that keep signed commits, wrap long diff lines

commit
7c7724c4769647a77f129d7b6bc2e84057cb2cec browse tree
parent
f8490fe61f0e69d81acdb1472120d243529ac699
author
james <j@cpl0.zip>
signature
Verified ~james signed with a key registered by ~james
date
2026-11-13 09:54 UTC

3 files changed, +47 −3

pull.go modified

@@ -670,6 +670,8 @@ func (s *Server) pull(w http.ResponseWriter, r *http.Request, repo Repo, rest st
670670 hash, err = g.Squash(pull.TargetBranch, pull.Ref(), message, pull.Author.Name, pull.Author.Name+"@"+s.host, user.Name, user.Name+"@"+s.host)
671671 case "rebase":
672672 hash, err = g.Rebase(pull.TargetBranch, pull.Ref(), user.Name, user.Name+"@"+s.host)
673+ case "ff":
674+ hash, err = g.FastForward(pull.TargetBranch, pull.Ref(), pull.baseRef())
673675 default:
674676 hash, err = g.Merge(pull.TargetBranch, pull.Ref(), message, user.Name, user.Name+"@"+s.host)
675677 }
@@ -714,7 +716,7 @@ func (s *Server) pull(w http.ResponseWriter, r *http.Request, repo Repo, rest st
714716 }
715717 base := pull.TargetBranch
716718 if pull.State == "merged" && pull.Merged != "" {
717- base = pull.Merged + "^1"
719+ base = g.mergedBase(pull)
718720 }
719721 if mb, err := g.MergeBase(base, head); err == nil {
720722 data["Base"] = mb
@@ -771,6 +773,7 @@ func (s *Server) pull(w http.ResponseWriter, r *http.Request, repo Repo, rest st
771773 data["Blocked"] = fmt.Sprintf("needs %d approving reviews of the current head and no change requests", prot.Approvals)
772774 data["Mergeable"] = false
773775 }
776+ data["FastForward"] = g.IsAncestor(pull.TargetBranch, head)
774777 if isOwner && data["Mergeable"] == true && r.URL.Query().Get("merge") == "1" {
775778 data["Confirm"] = true
776779 data["MergeTitle"] = mergeTitle(pull)
@@ -880,7 +883,7 @@ func (s *Server) interdiff(w http.ResponseWriter, r *http.Request, rp repoPage,
880883 }
881884 target := pull.TargetBranch
882885 if pull.State == "merged" && pull.Merged != "" {
883- target = pull.Merged + "^1"
886+ target = g.mergedBase(pull)
884887 }
885888 baseA, errA := g.MergeBase(target, revs[from-1])
886889 baseB, errB := g.MergeBase(target, revs[to-1])
@@ -925,3 +928,40 @@ func (s *Server) interdiff(w http.ResponseWriter, r *http.Request, rp repoPage,
925928 rp.Tab = "pulls"
926929 s.render(w, r, "interdiff", page{Title: fmt.Sprintf("%s #%d v%d to v%d", rp.Repo.Path(), pull.Number, from, to), Data: map[string]any{"Page": rp, "Pull": pull, "Revisions": revs, "From": from, "To": to, "Lines": lines}})
927930 }
931+
932+func (p Pull) baseRef() string {
933+ return fmt.Sprintf("refs/pull/%d/base", p.Number)
934+}
935+
936+func (g Git) mergedBase(p Pull) string {
937+ if base, err := g.Resolve(p.baseRef()); err == nil {
938+ return base
939+ }
940+ return p.Merged + "^1"
941+}
942+
943+func (g Git) IsAncestor(a, b string) bool {
944+ _, err := g.run(nil, "merge-base", "--is-ancestor", "--end-of-options", a, b)
945+ return err == nil
946+}
947+
948+func (g Git) FastForward(target, head, baseRef string) (string, error) {
949+ targetHash, err := g.Resolve("refs/heads/" + target)
950+ if err != nil {
951+ return "", errors.New("no such target branch")
952+ }
953+ headHash, err := g.Resolve(head)
954+ if err != nil {
955+ return "", err
956+ }
957+ if headHash == targetHash || !g.IsAncestor(targetHash, headHash) {
958+ return "", errors.New("the target has moved on, so this cannot fast forward")
959+ }
960+ if _, err := g.run(nil, "update-ref", baseRef, targetHash); err != nil {
961+ return "", err
962+ }
963+ if _, err := g.run(nil, "update-ref", "refs/heads/"+target, headHash, targetHash); err != nil {
964+ return "", errors.New("target branch moved, try again")
965+ }
966+ return headHash, nil
967+}

static/style.css modified

@@ -326,3 +326,6 @@ pre.rangediff{line-height:1.45}
326326 .rangediff .changed{font-weight:700;color:#8a5a00}
327327 .rangediff .new{font-weight:700;color:var(--add)}
328328 .rangediff .gone{font-weight:700;color:var(--del)}
329+table.hunk td.l,.hunk .head td{white-space:pre-wrap;overflow-wrap:anywhere;width:100%}
330+table.hunk{table-layout:auto}
331+pre.rangediff,pre.body{white-space:pre-wrap;overflow-wrap:anywhere}

templates/pull.html modified

@@ -34,7 +34,8 @@
3434 {{else if .Data.Mergeable}}<p>This branch can be merged automatically.</p>{{else if .Data.Blocked}}<p>Merging is blocked, {{.Data.Blocked}}.</p>{{else if $pr.Draft}}<p>Drafts cannot be merged.</p>{{else}}<p>This branch has conflicts or nothing new to merge. Resolve locally, push, then update.</p>{{end}}
3535 {{if .Data.Owner}}{{if .Data.Confirm}}
3636 <form method="post" action="/{{$p.Repo.Path}}/pulls/{{$pr.Number}}/merge" class="stack mergeform">
37-<label class="radio"><input type="radio" name="strategy" value="merge" checked> Merge commit<small>Both parents kept</small></label>
37+<label class="radio"><input type="radio" name="strategy" value="ff"{{if .Data.FastForward}} checked{{else}} disabled{{end}}> Fast forward<small>{{if .Data.FastForward}}{{$pr.TargetBranch}} moves to the head as is, commits and signatures untouched, message below ignored{{else}}Only possible while {{$pr.TargetBranch}} has no commits the branch lacks{{end}}</small></label>
38+<label class="radio"><input type="radio" name="strategy" value="merge"{{if not .Data.FastForward}} checked{{end}}> Merge commit<small>Both parents kept</small></label>
3839 <label class="radio"><input type="radio" name="strategy" value="squash"> Squash<small>One commit, authored by ~{{$pr.Author.Name}}</small></label>
3940 <label class="radio"><input type="radio" name="strategy" value="rebase"> Rebase<small>Commits replayed onto {{$pr.TargetBranch}}, message below ignored</small></label>
4041 <label>Commit message <input name="title" value="{{.Data.MergeTitle}}" maxlength="200" required></label>