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
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(-) @@ -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) + } } @@ -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)) @@ -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) } new file mode 100644 @@ -0,0 +1 @@ +ALTER TABLE merge_requests DROP COLUMN merged_base; new file mode 100644 @@ -0,0 +1 @@ +ALTER TABLE merge_requests ADD COLUMN merged_base TEXT NOT NULL DEFAULT ''; @@ -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 = ?",