Commit b6b09c54da

b6b09c54da3f200e5a91d2be1c0e6902314ed098

parent: 85a32ad0bd

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-24 03:14 UTC

mrs: preserve merged diffs

A fast-forward makes the live merge-base equal the MR head, so merged
MRs showed an empty diff. Record the target tip at merge time
(merged_base, migration 0006) and use it as the diff base for merged
MRs in mr diff and the web view; open MRs keep the live computation.

Layout: unified · split

e2e/mr_test.go +10
@@ -248,6 +248,13 @@ func TestMergeRequests(t *testing.T) {
248248 t.Fatalf("merge after fork deletion: %s", errOut)
249249 }
250250
251 // Merged MRs keep their historical diff: after the fast-forward the
252 // live merge-base equals the head, so the recorded base must be used.
253 diffOut2, _, code := inst.ssh(t, aliceKey, "", "mr", "diff", "alice/lib", "1")
254 if code != 0 || !strings.Contains(diffOut2, "feature.txt") {
255 t.Fatalf("post-merge diff empty: %d\n%s", code, diffOut2)
256 }
257
251258 // Web read views.
252259 status, body := inst.get(t, "/alice/lib/mrs?state=all")
253260 if status != 200 || !strings.Contains(body, "add feature") || !strings.Contains(body, "second") {
@@ -257,4 +264,7 @@ func TestMergeRequests(t *testing.T) {
257264 if status != 200 || !strings.Contains(body, "stale") || !strings.Contains(body, "merged") {
258265 t.Fatalf("mr detail: %d\n%s", status, body)
259266 }
267 if !strings.Contains(body, "feature.txt") {
268 t.Fatalf("merged MR web diff empty:\n%s", body)
269 }
260270}
internal/control/mr.go +10 −4
@@ -352,9 +352,15 @@ func runMRDiff(c *Ctx, args []string) int {
352352 }
353353 dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
354354 head := mrHeadRef(mr.Number)
355 base, err := gitutil.MergeBase(dir, "refs/heads/"+mr.TargetRef, head)
356 if err != nil {
357 return c.fail(protocol.ExitFailure, "%v", err)
355 // After a merge (especially fast-forward) the live merge-base equals
356 // the head and the diff would vanish; use the recorded base instead.
357 base := mr.MergedBase
358 if base == "" {
359 b, err := gitutil.MergeBase(dir, "refs/heads/"+mr.TargetRef, head)
360 if err != nil {
361 return c.fail(protocol.ExitFailure, "%v", err)
362 }
363 base = b
358364 }
359365 patch, err := gitutil.Diff(dir, base, head, 4<<20)
360366 if err != nil {
@@ -663,7 +669,7 @@ func runMRMerge(c *Ctx, args []string) int {
663669 if err := gitutil.UpdateRefCAS(dir, targetRef, newSHA, targetSHA); err != nil {
664670 return c.fail(protocol.ExitFailure, "target branch moved during merge; retry: %v", err)
665671 }
666 if err := c.Store.SetMRState(mr.ID, "merged"); err != nil {
672 if err := c.Store.MarkMerged(mr.ID, targetSHA); err != nil {
667673 return c.fail(protocol.ExitFailure, "%v", err)
668674 }
669675 c.Store.RecordEvent(repo.ID, c.User.ID, "mr.merged", fmt.Sprintf(`{"number":%d,"sha":%q}`, mr.Number, newSHA))
internal/httpd/web.go +7 −1
@@ -642,7 +642,13 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
642642
643643 headRef := fmt.Sprintf("refs/merge-requests/%d/head", m.Number)
644644 var lines []diffLine
645 if base, err := gitutil.MergeBase(p.Dir, "refs/heads/"+m.TargetRef, headRef); err == nil {
645 base := m.MergedBase
646 if base == "" {
647 if b, err := gitutil.MergeBase(p.Dir, "refs/heads/"+m.TargetRef, headRef); err == nil {
648 base = b
649 }
650 }
651 if base != "" {
646652 if patch, err := gitutil.Diff(p.Dir, base, headRef, 4<<20); err == nil {
647653 lines = classifyDiff(patch)
648654 }
internal/store/migrations/0006_merged_base.down.sql added +1
@@ -0,0 +1 @@
1ALTER TABLE merge_requests DROP COLUMN merged_base;
internal/store/migrations/0006_merged_base.up.sql added +1
@@ -0,0 +1 @@
1ALTER TABLE merge_requests ADD COLUMN merged_base TEXT NOT NULL DEFAULT '';
internal/store/mrs.go +12 −2
@@ -18,6 +18,7 @@ type MR struct {
1818 Body string
1919 State string // open | merged | closed | source_gone
2020 HeadSHA string
21 MergedBase string // target tip at merge time; base for historical diffs
2122 CreatedAt string
2223 UpdatedAt string
2324}
@@ -57,7 +58,7 @@ const mrSelect = `
5758 COALESCE(m.source_repo_id, 0),
5859 COALESCE(COALESCE(su.username, so.name) || '/' || sr.name, ''),
5960 m.source_ref, m.target_ref, m.title, m.body, m.state, m.head_sha,
60 m.created_at, m.updated_at
61 m.merged_base, m.created_at, m.updated_at
6162 FROM merge_requests m
6263 JOIN users u ON u.id = m.author_id
6364 LEFT JOIN repos sr ON sr.id = m.source_repo_id
@@ -67,7 +68,7 @@ const mrSelect = `
6768func scanMR(row interface{ Scan(...any) error }) (MR, error) {
6869 var m MR
6970 err := row.Scan(&m.ID, &m.RepoID, &m.Number, &m.Author, &m.SourceRepoID, &m.SourcePath,
70 &m.SourceRef, &m.TargetRef, &m.Title, &m.Body, &m.State, &m.HeadSHA, &m.CreatedAt, &m.UpdatedAt)
71 &m.SourceRef, &m.TargetRef, &m.Title, &m.Body, &m.State, &m.HeadSHA, &m.MergedBase, &m.CreatedAt, &m.UpdatedAt)
7172 return m, err
7273}
7374
@@ -124,6 +125,15 @@ func (s *Store) OpenMRsBySource(sourceRepoID int64, sourceRef string) ([]MR, err
124125 return out, rows.Err()
125126}
126127
128// MarkMerged records the merge along with the target tip it landed on, so
129// the MR's diff stays reconstructable after fast-forwards.
130func (s *Store) MarkMerged(mrID int64, baseSHA string) error {
131 _, err := s.DB.Exec(
132 "UPDATE merge_requests SET state = 'merged', merged_base = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
133 baseSHA, mrID)
134 return err
135}
136
127137func (s *Store) SetMRState(mrID int64, state string) error {
128138 res, err := s.DB.Exec(
129139 "UPDATE merge_requests SET state = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",