@@ -180,7 +180,7 @@ export function RevisionSelectorBar({
type="button"
onClick={onSwap}
className="bg-medium-gray flex h-10 w-10 items-center justify-center rounded-full"
- aria-label="Swap sides"
+ aria-label="Swap base and target"
>
diff --git a/dashboard/src/utils/treeCompareDiff.test.ts b/dashboard/src/utils/treeCompareDiff.test.ts
index 46d388d83..95a77df6b 100644
--- a/dashboard/src/utils/treeCompareDiff.test.ts
+++ b/dashboard/src/utils/treeCompareDiff.test.ts
@@ -7,6 +7,7 @@ import {
mapBuildDiffRows,
compareRowNav,
parseStatusPairs,
+ resolveCompareHashes,
resolveStatusPairs,
serializeStatusPairs,
toggleChangeTypePairs,
@@ -44,6 +45,43 @@ describe('deriveCompareChange', () => {
});
});
+describe('resolveCompareHashes', () => {
+ const revisions = [{ hash: 'new' }, { hash: 'mid' }, { hash: 'old' }];
+
+ it('defaults to the head on B and the revision before it on A', () => {
+ expect(resolveCompareHashes(revisions, '', '')).toEqual({
+ hashA: 'mid',
+ hashB: 'new',
+ });
+ });
+
+ it('keeps A one revision older than an explicit B', () => {
+ expect(resolveCompareHashes(revisions, '', 'mid')).toEqual({
+ hashA: 'old',
+ hashB: 'mid',
+ });
+ });
+
+ it('never defaults B to the same revision as A', () => {
+ expect(resolveCompareHashes(revisions, 'new', '')).toEqual({
+ hashA: 'new',
+ hashB: 'mid',
+ });
+ });
+
+ it('leaves explicit hashes alone and copes with too few revisions', () => {
+ expect(resolveCompareHashes(revisions, 'old', 'new')).toEqual({
+ hashA: 'old',
+ hashB: 'new',
+ });
+ expect(resolveCompareHashes([{ hash: 'only' }], '', '')).toEqual({
+ hashA: '',
+ hashB: 'only',
+ });
+ expect(resolveCompareHashes([], '', '')).toEqual({ hashA: '', hashB: '' });
+ });
+});
+
describe('applyStatusPairFilter', () => {
const rows = [
{ id: '1', sideA: 'PASS' as const, sideB: 'FAIL' as const },
diff --git a/dashboard/src/utils/treeCompareDiff.ts b/dashboard/src/utils/treeCompareDiff.ts
index cb313c7cd..87dbde194 100644
--- a/dashboard/src/utils/treeCompareDiff.ts
+++ b/dashboard/src/utils/treeCompareDiff.ts
@@ -28,7 +28,7 @@ export function apiStatusToItemStatus(
return status;
}
-/** Mirror backend _CHANGE_COUNT_SELECT categories for A→B transitions. */
+/** Mirror backend _CHANGE_COUNT_SELECT categories for base→target transitions. */
export function deriveCompareChange(
statusA: CompareItemStatus,
statusB: CompareItemStatus,
@@ -300,6 +300,25 @@ export function applyStatusPairFilter<
);
}
+/**
+ * Fill in missing sides so the default comparison runs oldest → newest:
+ * B defaults to the branch head, A to the revision right before B.
+ * `revisions` is newest-first.
+ */
+export function resolveCompareHashes(
+ revisions: readonly { hash: string }[],
+ hashA: string,
+ hashB: string,
+): { hashA: string; hashB: string } {
+ const resolvedB =
+ hashB || revisions.find(revision => revision.hash !== hashA)?.hash || '';
+ const indexB = revisions.findIndex(revision => revision.hash === resolvedB);
+ return {
+ hashA: hashA || revisions[indexB + 1]?.hash || '',
+ hashB: resolvedB,
+ };
+}
+
/** Next/prev over the currently visible (searched/sorted) table rows. */
export function compareRowNav(
rows: T[],
diff --git a/docs/tree-compare.md b/docs/tree-compare.md
index ca455cdaa..90bd8efbf 100644
--- a/docs/tree-compare.md
+++ b/docs/tree-compare.md
@@ -4,9 +4,9 @@ High-level overview of the Tree Compare feature: side-by-side comparison of buil
## Purpose
-Given a tree name and branch, pick two commit hashes (side A and side B) and answer:
+Given a tree name and branch, pick two commit hashes (base and target) and answer:
-- How did overall pass/fail/inconclusive counts move from A → B?
+- How did overall pass/fail/inconclusive counts move from base → target?
- Which individual builds / boots / tests changed category (regressions, fixes, new failures, etc.)?
Entry point: **Compare revisions** on Tree Details (`TreeCompareLink`), which opens:
@@ -15,14 +15,14 @@ Entry point: **Compare revisions** on Tree Details (`TreeCompareLink`), which op
## User flow
-1. Open compare from Tree Details (current revision pre-fills as side A).
-2. Choose / swap revisions via the revision selector (commit history + shortcuts: previous commit, branch head, swap sides).
+1. Open compare from Tree Details (current revision pre-fills as **Target**, **Base** defaults to the revision before it, so base → target reads oldest → newest). URL params stay `hashA` (base) and `hashB` (target).
+2. Choose / swap revisions via the revision selector (commit history + shortcuts: previous commit, branch head, swap base and target).
3. Read the summary matrix (fixes, regressions, pass/fail/other counts per builds / boots / tests).
4. Drill into **Changed results** tabs (Builds / Boots / Tests). Quick change-type chips add or remove their status pairs; custom From/To pairs can be added too (default: `PASS → FAIL` and `FAIL → PASS`). Last edited pairs are remembered in localStorage when the URL omits `statusPair`.
URL search state owns: `hashA`, `hashB`, `origin`, `currentPageTab`, and optional `statusPair`.
-## Change categories (A → B)
+## Change categories (base → target)
Statuses are grouped into **PASS**, **FAIL**, and **INCONCLUSIVE** (everything else, including null). Absent on one side is treated as missing (`null` / `—`).
@@ -34,7 +34,7 @@ Statuses are grouped into **PASS**, **FAIL**, and **INCONCLUSIVE** (everything e
| `stillFailing` | FAIL → FAIL |
| `newPass` | missing/INCONCLUSIVE → PASS |
| `appeared` | missing → INCONCLUSIVE |
-| `disappeared` | present on A, missing on B |
+| `disappeared` | present on base, missing on target |
| `unchanged` | same status on both sides (except FAIL → FAIL); frontend-only, has no backend count and no filter chip |
Backend SQL aggregates (`_CHANGE_COUNT_SELECT` in `queries/tree.py`) and the frontend `deriveCompareChange` helper must stay in sync.