krz/gitbay

A CLI-first git forge.

clone: git clone https://gitbay.org/krz/gitbay.git

b6b09c54da3f200e5a91d2be1c0e6902314ed098

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-08-24T03:14:26Z

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.
 e2e/mr_test.go                                      | 10 ++++++++++
 internal/control/mr.go                              | 14 ++++++++++----
 internal/httpd/web.go                               |  8 +++++++-
 internal/store/migrations/0006_merged_base.down.sql |  1 +
 internal/store/migrations/0006_merged_base.up.sql   |  1 +
 internal/store/mrs.go                               | 14 ++++++++++++--
 6 files changed, 41 insertions(+), 7 deletions(-)

diff --git a/e2e/mr_test.go b/e2e/mr_test.go
index 53167e1..10801a8 100644
--- a/e2e/mr_test.go
+++ b/e2e/mr_test.go
@@ -248,6 +248,13 @@ func TestMergeRequests(t *testing.T) {
 		t.Fatalf("merge after fork deletion: %s", errOut)
 	}
 
+	// Merged MRs keep their historical diff: after the fast-forward the
+	// live merge-base equals the head, so the recorded base must be used.
+	diffOut2, _, code := inst.ssh(t, aliceKey, "", "mr", "diff", "alice/lib", "1")
+	if code != 0 || !strings.Contains(diffOut2, "feature.txt") {
+		t.Fatalf("post-merge diff empty: %d\n%s", code, diffOut2)
+	}
+
 	// Web read views.
 	status, body := inst.get(t, "/alice/lib/mrs?state=all")
 	if status != 200 || !strings.Contains(body, "add feature") || !strings.Contains(body, "second") {
@@ -257,4 +264,7 @@ func TestMergeRequests(t *testing.T) {
 	if status != 200 || !strings.Contains(body, "stale") || !strings.Contains(body, "merged") {
 		t.Fatalf("mr detail: %d\n%s", status, body)
 	}
+	if !strings.Contains(body, "feature.txt") {
+		t.Fatalf("merged MR web diff empty:\n%s", body)
+	}
 }
diff --git a/internal/control/mr.go b/internal/control/mr.go
index 553ec23..d972487 100644
--- a/internal/control/mr.go
+++ b/internal/control/mr.go
@@ -352,9 +352,15 @@ func runMRDiff(c *Ctx, args []string) int {
 	}
 	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
 	head := mrHeadRef(mr.Number)
-	base, err := gitutil.MergeBase(dir, "refs/heads/"+mr.TargetRef, head)
-	if err != nil {
-		return c.fail(protocol.ExitFailure, "%v", err)
+	// After a merge (especially fast-forward) the live merge-base equals
+	// the head and the diff would vanish; use the recorded base instead.
+	base := mr.MergedBase
+	if base == "" {
+		b, err := gitutil.MergeBase(dir, "refs/heads/"+mr.TargetRef, head)
+		if err != nil {
+			return c.fail(protocol.ExitFailure, "%v", err)
+		}
+		base = b
 	}
 	patch, err := gitutil.Diff(dir, base, head, 4<<20)
 	if err != nil {
@@ -663,7 +669,7 @@ func runMRMerge(c *Ctx, args []string) int {
 	if err := gitutil.UpdateRefCAS(dir, targetRef, newSHA, targetSHA); err != nil {
 		return c.fail(protocol.ExitFailure, "target branch moved during merge; retry: %v", err)
 	}
-	if err := c.Store.SetMRState(mr.ID, "merged"); err != nil {
+	if err := c.Store.MarkMerged(mr.ID, targetSHA); err != nil {
 		return c.fail(protocol.ExitFailure, "%v", err)
 	}
 	c.Store.RecordEvent(repo.ID, c.User.ID, "mr.merged", fmt.Sprintf(`{"number":%d,"sha":%q}`, mr.Number, newSHA))
diff --git a/internal/httpd/web.go b/internal/httpd/web.go
index 7f76977..4fbd299 100644
--- a/internal/httpd/web.go
+++ b/internal/httpd/web.go
@@ -642,7 +642,13 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
 
 	headRef := fmt.Sprintf("refs/merge-requests/%d/head", m.Number)
 	var lines []diffLine
-	if base, err := gitutil.MergeBase(p.Dir, "refs/heads/"+m.TargetRef, headRef); err == nil {
+	base := m.MergedBase
+	if base == "" {
+		if b, err := gitutil.MergeBase(p.Dir, "refs/heads/"+m.TargetRef, headRef); err == nil {
+			base = b
+		}
+	}
+	if base != "" {
 		if patch, err := gitutil.Diff(p.Dir, base, headRef, 4<<20); err == nil {
 			lines = classifyDiff(patch)
 		}
diff --git a/internal/store/migrations/0006_merged_base.down.sql b/internal/store/migrations/0006_merged_base.down.sql
new file mode 100644
index 0000000..6b8edcd
--- /dev/null
+++ b/internal/store/migrations/0006_merged_base.down.sql
@@ -0,0 +1 @@
+ALTER TABLE merge_requests DROP COLUMN merged_base;
diff --git a/internal/store/migrations/0006_merged_base.up.sql b/internal/store/migrations/0006_merged_base.up.sql
new file mode 100644
index 0000000..b4bf487
--- /dev/null
+++ b/internal/store/migrations/0006_merged_base.up.sql
@@ -0,0 +1 @@
+ALTER TABLE merge_requests ADD COLUMN merged_base TEXT NOT NULL DEFAULT '';
diff --git a/internal/store/mrs.go b/internal/store/mrs.go
index b344cb2..b950b6d 100644
--- a/internal/store/mrs.go
+++ b/internal/store/mrs.go
@@ -18,6 +18,7 @@ type MR struct {
 	Body         string
 	State        string // open | merged | closed | source_gone
 	HeadSHA      string
+	MergedBase   string // target tip at merge time; base for historical diffs
 	CreatedAt    string
 	UpdatedAt    string
 }
@@ -57,7 +58,7 @@ const mrSelect = `
 	       COALESCE(m.source_repo_id, 0),
 	       COALESCE(COALESCE(su.username, so.name) || '/' || sr.name, ''),
 	       m.source_ref, m.target_ref, m.title, m.body, m.state, m.head_sha,
-	       m.created_at, m.updated_at
+	       m.merged_base, m.created_at, m.updated_at
 	FROM merge_requests m
 	JOIN users u ON u.id = m.author_id
 	LEFT JOIN repos sr ON sr.id = m.source_repo_id
@@ -67,7 +68,7 @@ const mrSelect = `
 func scanMR(row interface{ Scan(...any) error }) (MR, error) {
 	var m MR
 	err := row.Scan(&m.ID, &m.RepoID, &m.Number, &m.Author, &m.SourceRepoID, &m.SourcePath,
-		&m.SourceRef, &m.TargetRef, &m.Title, &m.Body, &m.State, &m.HeadSHA, &m.CreatedAt, &m.UpdatedAt)
+		&m.SourceRef, &m.TargetRef, &m.Title, &m.Body, &m.State, &m.HeadSHA, &m.MergedBase, &m.CreatedAt, &m.UpdatedAt)
 	return m, err
 }
 
@@ -124,6 +125,15 @@ func (s *Store) OpenMRsBySource(sourceRepoID int64, sourceRef string) ([]MR, err
 	return out, rows.Err()
 }
 
+// MarkMerged records the merge along with the target tip it landed on, so
+// the MR's diff stays reconstructable after fast-forwards.
+func (s *Store) MarkMerged(mrID int64, baseSHA string) error {
+	_, err := s.DB.Exec(
+		"UPDATE merge_requests SET state = 'merged', merged_base = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
+		baseSHA, mrID)
+	return err
+}
+
 func (s *Store) SetMRState(mrID int64, state string) error {
 	res, err := s.DB.Exec(
 		"UPDATE merge_requests SET state = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",