Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **The Pulls screen now lists the commits a pull request would land.** A new
"Commits" tab, counted next to its name, shows every commit oldest first: the
subject line, who committed it and when, and clicking a row opens its message
body — the branch's story, which the diff and the file list never tell. The commits ride along with the lookup the screen already makes,
so it costs no extra round-trip.

### Fixed

- **A new directory of files now counts as the files it holds, not as one.** The
Expand Down
16 changes: 12 additions & 4 deletions frontend/src/components/pulls/Pulls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Skeleton } from "@/components/ui/skeleton"
import { PullsChecks } from "./PullsChecks"
import { PullsCommits } from "./PullsCommits"
import { PullsFiles } from "./PullsFiles"

// Ragged widths, so the body placeholder reads as prose instead of a block.
Expand Down Expand Up @@ -213,7 +214,8 @@ function PullRequestView({
}: PullRequestViewProps) {
const [merging, setMerging] = useState(false)
const [edit, setEdit] = useState<EditState | null>(null)
const [tab, setTab] = useState<"overview" | "files" | "checks">("overview")
const [tab, setTab] = useState<"overview" | "commits" | "files" | "checks">("overview")
const commitCount = detail.commits?.length ?? 0
const blocked = detail.isDraft
? "Pull request is a draft"
: detail.mergeable === "CONFLICTING"
Expand Down Expand Up @@ -338,6 +340,12 @@ function PullRequestView({
<TabButton active={tab === "overview"} onClick={() => setTab("overview")}>
Overview
</TabButton>
<TabButton active={tab === "commits"} onClick={() => setTab("commits")}>
Commits
{commitCount > 0 && (
<span className="tabular-nums text-muted-foreground">{commitCount}</span>
)}
</TabButton>
<TabButton active={tab === "files"} onClick={() => setTab("files")}>
Files changed
{detail.changedFiles > 0 && (
Expand All @@ -358,9 +366,9 @@ function PullRequestView({
<PullsFiles path={path} head={head} pullRequest={detail.url} onInject={onInject} />
) : (
<div className="h-full overflow-y-auto">
{tab === "checks" ? (
<PullsChecks checks={detail.checkRuns} />
) : (
{tab === "checks" && <PullsChecks checks={detail.checkRuns} />}
{tab === "commits" && <PullsCommits commits={detail.commits} />}
{tab === "overview" && (
<div className="max-w-3xl px-6 py-5">
{detail.body.trim() !== "" ? (
<Markdown>{detail.body}</Markdown>
Expand Down
76 changes: 76 additions & 0 deletions frontend/src/components/pulls/PullsCommits.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { useState } from "react"
import { ChevronRight } from "lucide-react"
import { Notice } from "@/components/common/Notice"
import type { PullRequestCommit } from "@/lib/api-types"
import { cn } from "@/lib/utils"

// PullsCommits is the "Commits" tab of the Pulls screen: every commit the pull
// request would land, oldest first as gh lists them — the story of the branch,
// which the diff never tells. A row is its subject line and clicking it opens
// the message body, so a branch of fifteen commits stays a list you can scan.
export function PullsCommits({ commits }: { commits: PullRequestCommit[] | null }) {
if (!commits || commits.length === 0) {
return <Notice className="px-6 py-5 text-sm">No commits.</Notice>
}
return (
<div className="flex flex-col py-1">
{commits.map((commit) => (
<CommitRow key={commit.oid} commit={commit} />
))}
</div>
)
}

function CommitRow({ commit }: { commit: PullRequestCommit }) {
const [open, setOpen] = useState(false)
const body = commit.body.trim()
return (
<div>
{/* The whole row is the affordance — a body is read by clicking the commit
it belongs to, not a control beside it. A one-line commit has nothing
to open, so its row stays inert rather than offering an empty toggle. */}
<button
type="button"
onClick={() => setOpen(!open)}
disabled={body === ""}
aria-expanded={body === "" ? undefined : open}
className="flex w-full cursor-default items-center gap-2 px-6 py-2 text-left transition-colors hover:bg-accent/50 disabled:hover:bg-transparent"
>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium" title={commit.headline}>
{commit.headline}
</span>
<span className="block text-xs text-muted-foreground">{commitMeta(commit)}</span>
</span>
{body !== "" && (
<ChevronRight
className={cn(
"size-3.5 shrink-0 text-muted-foreground transition-transform",
open && "rotate-90",
)}
/>
)}
<span className="shrink-0 font-mono text-xs text-muted-foreground" title={commit.oid}>
{commit.oid.slice(0, 7)}
</span>
</button>
{open && (
<p className="whitespace-pre-wrap px-6 pb-3 text-xs leading-relaxed text-muted-foreground">
{body}
</p>
)}
</div>
)
}

// Who landed it and when, in git's own phrasing. Either half can be missing — a
// merge commit from the web flow carries no author — so the line is built from
// what is there instead of printing an empty "committed on".
function commitMeta(commit: PullRequestCommit): string {
const at = new Date(commit.date)
const date = Number.isNaN(at.getTime()) ? "" : at.toLocaleDateString()
if (commit.author && date) {
return `${commit.author} committed ${date}`
}
return commit.author || (date && `Committed ${date}`) || ""
}
15 changes: 15 additions & 0 deletions frontend/src/lib/api-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,19 @@ export interface CheckItem {
completedAt: string
}

/** internal/project.PRCommit — one commit of the PR, for the Commits tab. */
export interface PullRequestCommit {
oid: string
/** The message's subject line. */
headline: string
/** The rest of the message; "" for a one-line commit. */
body: string
/** The first author's login, or their name; "" when gh reports none. */
author: string
/** gh's ISO committedDate. */
date: string
}

/** internal/project.PRDetail — the branch's open PR in full, for the Pulls panel. */
export interface PullRequestDetail {
number: number
Expand All @@ -64,6 +77,8 @@ export interface PullRequestDetail {
checks: ChecksRollup
/** The rollup itself, worst state first; null when the PR reports no checks. */
checkRuns: CheckItem[] | null
/** Every commit the PR would land, oldest first; null when gh reports none. */
commits: PullRequestCommit[] | null
}

/** internal/project.Worktree — a git worktree checkout: branch and path. */
Expand Down
54 changes: 53 additions & 1 deletion internal/project/pr.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ func runGH(timeout time.Duration, dir string, args ...string) ([]byte, error) {
}

// prViewFields is the gh `pr view --json` selection backing the Pulls panel.
const prViewFields = "number,url,state,title,body,isDraft,mergeable,baseRefName,headRefName,statusCheckRollup,changedFiles"
const prViewFields = "number,url,state,title,body,isDraft,mergeable,baseRefName,headRefName,statusCheckRollup,changedFiles,commits"

// PRDetail is the full view of a branch's open pull request — richer than the
// footer badge's PullRequest — driving the dock's Pulls panel: the title, body,
Expand All @@ -73,6 +73,17 @@ type PRDetail struct {
ChangedFiles int `json:"changedFiles"`
Checks ChecksRollup `json:"checks"`
CheckRuns []CheckItem `json:"checkRuns"`
Commits []PRCommit `json:"commits"`
}

// PRCommit is one commit the pull request would land, split the way git itself
// splits a message: the subject line, then the body (empty for a one-liner).
type PRCommit struct {
OID string `json:"oid"`
Headline string `json:"headline"`
Body string `json:"body"`
Author string `json:"author"`
Date string `json:"date"` // gh's ISO committedDate
}

// ChecksRollup collapses gh's statusCheckRollup array into the counts the panel
Expand Down Expand Up @@ -111,6 +122,22 @@ type ghPRView struct {
HeadRefName string `json:"headRefName"`
ChangedFiles int `json:"changedFiles"`
StatusCheckRollup []checkItem `json:"statusCheckRollup"`
Commits []ghCommit `json:"commits"`
}

// ghCommit is one entry of gh's commits array. Authors is a list because of
// co-authored commits; the first one is the one the list names.
type ghCommit struct {
OID string `json:"oid"`
MessageHeadline string `json:"messageHeadline"`
MessageBody string `json:"messageBody"`
CommittedDate string `json:"committedDate"`
Authors []ghCommitAuthor `json:"authors"`
}

type ghCommitAuthor struct {
Login string `json:"login"`
Name string `json:"name"`
}

// checkItem is one statusCheckRollup entry. gh emits two shapes: a CheckRun
Expand Down Expand Up @@ -171,9 +198,34 @@ func parsePRDetail(out []byte) (*PRDetail, error) {
ChangedFiles: v.ChangedFiles,
Checks: reduceChecks(v.StatusCheckRollup),
CheckRuns: toCheckItems(v.StatusCheckRollup),
Commits: toCommits(v.Commits),
}, nil
}

// toCommits flattens gh's commits into the list the Commits tab renders, in
// gh's own order — oldest first, the order the branch built them. Returns nil
// when gh reports none, so the tab can tell "no commits" from a list.
func toCommits(commits []ghCommit) []PRCommit {
if len(commits) == 0 {
return nil
}
out := make([]PRCommit, 0, len(commits))
for _, c := range commits {
author := ""
if len(c.Authors) > 0 {
author = firstNonEmpty(c.Authors[0].Login, c.Authors[0].Name)
}
out = append(out, PRCommit{
OID: c.OID,
Headline: c.MessageHeadline,
Body: c.MessageBody,
Author: author,
Date: c.CommittedDate,
})
}
return out
}

// The verdict of one check, shared by the rollup counts and the Checks tab.
const (
checkPassed = "passed"
Expand Down
55 changes: 55 additions & 0 deletions internal/project/pr_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ func TestParsePRDetail(t *testing.T) {
"statusCheckRollup": [
{"status": "COMPLETED", "conclusion": "SUCCESS"},
{"state": "SUCCESS"}
],
"commits": [
{"oid": "a4dbc1f", "messageHeadline": "feat: the panel", "messageBody": "why"}
]
}`)
pr, err := parsePRDetail(out)
Expand All @@ -45,6 +48,9 @@ func TestParsePRDetail(t *testing.T) {
if pr.ChangedFiles != 6 {
t.Errorf("wrong changed files: %d", pr.ChangedFiles)
}
if len(pr.Commits) != 1 || pr.Commits[0].Headline != "feat: the panel" {
t.Errorf("wrong commits: %+v", pr.Commits)
}
})

t.Run("draft flag survives", func(t *testing.T) {
Expand Down Expand Up @@ -221,6 +227,55 @@ func TestToCheckItems(t *testing.T) {
})
}

func TestToCommits(t *testing.T) {
t.Run("no commits yields no list", func(t *testing.T) {
if got := toCommits(nil); got != nil {
t.Errorf("toCommits(nil) = %+v, want nil", got)
}
})

t.Run("subject, body and author survive in gh's order", func(t *testing.T) {
got := toCommits([]ghCommit{
{
OID: "a4dbc1f",
MessageHeadline: "fix: retire the badge",
MessageBody: "The footer badge only looked up again on HEAD moves.",
CommittedDate: "2026-07-25T22:32:47Z",
Authors: []ghCommitAuthor{{Login: "omartelo", Name: "omartelo"}},
},
{OID: "f55ebe9", MessageHeadline: "docs: changelog"},
})
if len(got) != 2 {
t.Fatalf("want 2 commits, got %+v", got)
}
first := got[0]
if first.OID != "a4dbc1f" || first.Headline != "fix: retire the badge" {
t.Errorf("wrong header: %+v", first)
}
if !strings.Contains(first.Body, "HEAD moves") || first.Author != "omartelo" {
t.Errorf("lost body or author: %+v", first)
}
if first.Date != "2026-07-25T22:32:47Z" {
t.Errorf("wrong date: %q", first.Date)
}
// A one-line commit has no body and a web-flow merge commit no author;
// both are rows, not errors.
if got[1].Body != "" || got[1].Author != "" {
t.Errorf("second commit should be bare: %+v", got[1])
}
})

t.Run("a nameless login falls back to the name", func(t *testing.T) {
got := toCommits([]ghCommit{{
OID: "c0ffee0",
Authors: []ghCommitAuthor{{Name: "Ada Lovelace"}},
}})
if len(got) != 1 || got[0].Author != "Ada Lovelace" {
t.Errorf("author fallback failed: %+v", got)
}
})
}

func TestMergeArgs(t *testing.T) {
// An unrecognised method must be refused before any gh shell-out.
if _, err := mergeArgs("force", "", ""); err == nil {
Expand Down