Files
Brandon OxendineandRūdolfs Ošiņš 8e5089cbdc desktop: Explain revisions with no changes
When a revision's head is already part of its base, the Changes view
showed an empty commits column and "0 files modified". In practice this
is a patch branch pushed after its work already landed, which also marks
the patch merged at that commit even when the work landed after it. Show
an empty state that says so and links to the last revision with changes.

This also fixes switching revisions leaving the previous revision's
single commit selected.

Co-authored-by: Rūdolfs Ošiņš <rudolfs@osins.org>
2026-10-29 10:48:03 +01:00

191 lines
5.4 KiB
TypeScript

import type { Commit } from "@bindings/repo/Commit";
import { describe, expect, test } from "vitest";
import {
autoSelectedCommit,
lastRevisionWithChanges,
newCommitsByRevision,
orderRevisions,
revisionNumbers,
} from "@app/lib/revisionList";
import type { RevisionListSettings } from "@app/lib/revisionListSettings";
import { author, revision } from "./support/cobs";
const alice = author("alice");
const bob = author("bob");
const carol = author("carol");
// Timeline order: bob and carol both push before the patch author's second
// revision, so grouping has to move revisions around.
const revisions = [
revision({ id: "a1", author: alice }),
revision({ id: "b1", author: bob }),
revision({ id: "c1", author: carol }),
revision({ id: "a2", author: alice }),
revision({ id: "b2", author: bob }),
];
function settings(props: Partial<RevisionListSettings>): RevisionListSettings {
return {
sortDesc: false,
groupByAuthor: false,
showNumber: false,
showStats: false,
showReviewers: true,
...props,
};
}
function ids(ordered: { id: string }[]) {
return ordered.map(r => r.id);
}
test("revisionNumbers numbers revisions by timeline position from 1", () => {
expect(revisionNumbers(revisions)).toEqual({
a1: 1,
b1: 2,
c1: 3,
a2: 4,
b2: 5,
});
});
describe("orderRevisions", () => {
test("keeps the timeline order by default", () => {
expect(ids(orderRevisions(revisions, alice.did, settings({})))).toEqual([
"a1",
"b1",
"c1",
"a2",
"b2",
]);
});
test("reverses the timeline when sorting descending", () => {
expect(
ids(orderRevisions(revisions, alice.did, settings({ sortDesc: true }))),
).toEqual(["b2", "a2", "c1", "b1", "a1"]);
});
test("groups by author, patch author first, others by first appearance", () => {
expect(
ids(
orderRevisions(revisions, carol.did, settings({ groupByAuthor: true })),
),
).toEqual(["c1", "a1", "a2", "b1", "b2"]);
});
test("sorting descending reverses within groups, not the group order", () => {
expect(
ids(
orderRevisions(
revisions,
alice.did,
settings({ groupByAuthor: true, sortDesc: true }),
),
),
).toEqual(["a2", "a1", "b2", "b1", "c1"]);
});
test("does not reorder the caller's array", () => {
const input = [...revisions];
orderRevisions(input, alice.did, settings({ sortDesc: true }));
orderRevisions(
input,
alice.did,
settings({ groupByAuthor: true, sortDesc: true }),
);
expect(ids(input)).toEqual(ids(revisions));
});
});
test("newCommitsByRevision keeps only commits not in an earlier revision", () => {
const c = (id: string) => ({ id }) as Commit;
const commits: Record<string, Commit[]> = {
a1: [c("1"), c("2")],
b1: [c("1"), c("2"), c("3")],
c1: [c("2"), c("4"), c("3"), c("5")],
};
const added = newCommitsByRevision(
[{ id: "a1" }, { id: "b1" }, { id: "c1" }],
id => commits[id],
);
expect(
Object.fromEntries(
Object.entries(added).map(([id, list]) => [id, list.map(x => x.id)]),
),
).toEqual({ a1: ["1", "2"], b1: ["3"], c1: ["4", "5"] });
});
describe("lastRevisionWithChanges", () => {
// Revisions with `base === head` are empty: their head was already part of
// their base when they were pushed.
const changed = (id: string) => revision({ id, base: "b", head: id });
const empty = (id: string) => revision({ id, base: id, head: id });
test("finds the latest earlier revision with changes, numbered", () => {
const list = [changed("r1"), changed("r2"), empty("r3"), empty("r4")];
expect(lastRevisionWithChanges(list, "r4")).toEqual({
id: "r2",
number: 2,
});
});
test("can point at the first revision", () => {
expect(lastRevisionWithChanges([changed("r1"), empty("r2")], "r2")).toEqual(
{ id: "r1", number: 1 },
);
});
test("ignores revisions after the selected one", () => {
const list = [empty("r1"), empty("r2"), changed("r3")];
expect(lastRevisionWithChanges(list, "r2")).toBeUndefined();
});
test("has nothing to point at for the first revision", () => {
expect(lastRevisionWithChanges([empty("r1")], "r1")).toBeUndefined();
});
test("has nothing to point at for an unknown revision", () => {
expect(
lastRevisionWithChanges([changed("r1"), empty("r2")], "gone"),
).toBeUndefined();
});
});
describe("autoSelectedCommit", () => {
const c = (id: string) => ({ id }) as Commit;
test("selects the only commit of the listed revision", () => {
expect(autoSelectedCommit([c("1")], "b..h", "b..h", undefined)?.id).toBe(
"1",
);
});
// Regression: switching revisions left the previous revision's single
// commit selected, before the new revision's list had loaded.
test("doesn't select from a list loaded for another revision", () => {
expect(
autoSelectedCommit([c("1")], "b..old", "b..new", undefined),
).toBeUndefined();
});
test("keeps a selection that is already made", () => {
expect(autoSelectedCommit([c("1")], "b..h", "b..h", "1")).toBeUndefined();
});
test("selects nothing when there is nothing or more than one", () => {
expect(autoSelectedCommit([], "b..h", "b..h", undefined)).toBeUndefined();
expect(
autoSelectedCommit([c("1"), c("2")], "b..h", "b..h", undefined),
).toBeUndefined();
});
});