Skip to content
Open
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
188 changes: 188 additions & 0 deletions packages/core/src/integrations/branches.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,15 @@ import { describe, expect, it } from "vitest";
import {
BRANCHES_FIRST_PAGE_SIZE,
BRANCHES_PAGE_SIZE,
type BranchCacheUpdateInputs,
branchPageSizeForOffset,
type CachedCloudBranchMap,
computeNextBranchOffset,
flattenBranchPages,
type GithubBranchesPage,
MAX_CACHED_BRANCH_REPOS,
resolveBranchCacheUpdate,
resolveEffectiveBranches,
} from "./branches";

const page = (
Expand Down Expand Up @@ -50,3 +55,186 @@ describe("flattenBranchPages", () => {
});
});
});

function cacheUpdateInputs(
overrides: Partial<BranchCacheUpdateInputs> = {},
): BranchCacheUpdateInputs {
return {
repoKey: "a/x",
searchActive: false,
livePending: false,
liveErrored: false,
liveBranches: { branches: ["main", "dev"], defaultBranch: "main" },
cachedBranchMap: {},
...overrides,
};
}

describe("resolveBranchCacheUpdate", () => {
it.each([
["no repo key", { repoKey: null }],
["a search is active", { searchActive: true }],
["the live query is pending", { livePending: true }],
["the live query errored", { liveErrored: true }],
["there is no live data", { liveBranches: null }],
] as Array<[string, Partial<BranchCacheUpdateInputs>]>)(
"skips when %s",
(_name, overrides) => {
expect(resolveBranchCacheUpdate(cacheUpdateInputs(overrides))).toBeNull();
},
);

it("writes a settled first page, capped to the first-page size", () => {
const branches = Array.from({ length: 80 }, (_, i) => `branch-${i}`);
const next = resolveBranchCacheUpdate(
cacheUpdateInputs({
liveBranches: { branches, defaultBranch: "main" },
}),
);
expect(next).toEqual({
"a/x": {
branches: branches.slice(0, BRANCHES_FIRST_PAGE_SIZE),
defaultBranch: "main",
},
});
});

it("skips when the entry is unchanged and already most recent", () => {
const cachedBranchMap: CachedCloudBranchMap = {
"a/y": { branches: ["main"], defaultBranch: "main" },
"a/x": { branches: ["main", "dev"], defaultBranch: "main" },
};
expect(
resolveBranchCacheUpdate(cacheUpdateInputs({ cachedBranchMap })),
).toBeNull();
});

it("moves an unchanged entry to most recent when it is not already", () => {
const cachedBranchMap: CachedCloudBranchMap = {
"a/x": { branches: ["main", "dev"], defaultBranch: "main" },
"a/y": { branches: ["main"], defaultBranch: "main" },
};
const next = resolveBranchCacheUpdate(
cacheUpdateInputs({ cachedBranchMap }),
);
expect(next).not.toBeNull();
expect(Object.keys(next ?? {})).toEqual(["a/y", "a/x"]);
});

it("evicts the least recently written repos beyond the cap", () => {
const cachedBranchMap: CachedCloudBranchMap = {};
for (let i = 0; i < MAX_CACHED_BRANCH_REPOS; i++) {
cachedBranchMap[`a/repo-${i}`] = {
branches: ["main"],
defaultBranch: "main",
};
}
const next = resolveBranchCacheUpdate(
cacheUpdateInputs({ cachedBranchMap }),
);
expect(Object.keys(next ?? {})).toHaveLength(MAX_CACHED_BRANCH_REPOS);
expect(next?.["a/repo-0"]).toBeUndefined();
expect(Object.keys(next ?? {}).at(-1)).toBe("a/x");
});

it("removes the entry when a clean fetch returns no branches", () => {
const cachedBranchMap: CachedCloudBranchMap = {
"a/x": { branches: ["main"], defaultBranch: "main" },
"a/y": { branches: ["main"], defaultBranch: "main" },
};
const next = resolveBranchCacheUpdate(
cacheUpdateInputs({
liveBranches: { branches: [], defaultBranch: null },
cachedBranchMap,
}),
);
expect(next).toEqual({
"a/y": { branches: ["main"], defaultBranch: "main" },
});
});

it("skips when a clean empty fetch has no cached entry to remove", () => {
expect(
resolveBranchCacheUpdate(
cacheUpdateInputs({
liveBranches: { branches: [], defaultBranch: null },
}),
),
).toBeNull();
});
});

describe("resolveEffectiveBranches", () => {
const cached = { branches: ["cached-main"], defaultBranch: "cached-main" };
const live = { branches: ["main"], defaultBranch: "main" };

it("prefers live data even when a cache exists", () => {
const result = resolveEffectiveBranches({
liveLoading: false,
liveErrored: false,
searchActive: false,
liveBranches: live,
cachedBranches: cached,
});
expect(result.servingFromCache).toBe(false);
expect(result.effectiveBranches).toBe(live);
});

it.each([
["loading", { liveLoading: true, liveErrored: false }],
["errored", { liveLoading: false, liveErrored: true }],
])(
"serves the cache while the live query is %s with no data",
(_name, flags) => {
const result = resolveEffectiveBranches({
...flags,
searchActive: false,
liveBranches: null,
cachedBranches: cached,
});
expect(result.servingFromCache).toBe(true);
expect(result.effectiveBranches).toEqual(cached);
},
);

it("does not serve the cache while a search is active", () => {
const result = resolveEffectiveBranches({
liveLoading: true,
liveErrored: false,
searchActive: true,
liveBranches: null,
cachedBranches: cached,
});
expect(result.servingFromCache).toBe(false);
expect(result.effectiveBranches).toEqual({
branches: [],
defaultBranch: null,
});
});

it("does not serve an empty cached entry", () => {
const result = resolveEffectiveBranches({
liveLoading: true,
liveErrored: false,
searchActive: false,
liveBranches: null,
cachedBranches: { branches: [], defaultBranch: null },
});
expect(result.servingFromCache).toBe(false);
});

it("returns empty defaults when neither source has data", () => {
const result = resolveEffectiveBranches({
liveLoading: true,
liveErrored: false,
searchActive: false,
liveBranches: null,
cachedBranches: undefined,
});
expect(result.servingFromCache).toBe(false);
expect(result.effectiveBranches).toEqual({
branches: [],
defaultBranch: null,
});
});
});
144 changes: 144 additions & 0 deletions packages/core/src/integrations/branches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,147 @@ export function flattenBranchPages(
defaultBranch: pages[0]?.defaultBranch ?? null,
};
}

export interface CachedRepoBranches {
branches: string[];
defaultBranch: string | null;
}

/**
* Persisted cold-start branch cache, keyed by normalized repo key. Key order
* is least- to most-recently written; `resolveBranchCacheUpdate` evicts from
* the front once the map exceeds `MAX_CACHED_BRANCH_REPOS`.
*/
export type CachedCloudBranchMap = Record<string, CachedRepoBranches>;

export const MAX_CACHED_BRANCH_REPOS = 20;

function isEmptyCachedBranches(entry: CachedRepoBranches | undefined): boolean {
return !entry || (entry.branches.length === 0 && !entry.defaultBranch);
}

function sameCachedBranches(
a: CachedRepoBranches,
b: CachedRepoBranches,
): boolean {
return (
a.defaultBranch === b.defaultBranch &&
a.branches.length === b.branches.length &&
a.branches.every((branch, index) => branch === b.branches[index])
);
}

export interface BranchCacheUpdateInputs {
repoKey: string | null;
searchActive: boolean;
livePending: boolean;
liveErrored: boolean;
liveBranches: FlattenedBranches | null;
cachedBranchMap: CachedCloudBranchMap;
}

/**
* Decides how the persisted cold-start branch cache should track a settled
* live fetch: returns the next map to persist, or null to leave the cache
* untouched. Only the unsearched first page is cached, and the map is kept to
* the `MAX_CACHED_BRANCH_REPOS` most recently fetched repos.
*/
export function resolveBranchCacheUpdate({
repoKey,
searchActive,
livePending,
liveErrored,
liveBranches,
cachedBranchMap,
}: BranchCacheUpdateInputs): CachedCloudBranchMap | null {
if (!repoKey || searchActive || livePending || liveErrored || !liveBranches) {
return null;
}

const entry: CachedRepoBranches = {
branches: liveBranches.branches.slice(0, BRANCHES_FIRST_PAGE_SIZE),
defaultBranch: liveBranches.defaultBranch,
};

const existing = cachedBranchMap[repoKey];
if (isEmptyCachedBranches(entry)) {
// A repo that cleanly reports no branches should not flash a stale list on
// the next cold start.
if (!existing) return null;
const next: CachedCloudBranchMap = {};
for (const key of Object.keys(cachedBranchMap)) {
if (key !== repoKey) next[key] = cachedBranchMap[key];
}
return next;
}

const keys = Object.keys(cachedBranchMap);
if (
existing &&
sameCachedBranches(existing, entry) &&
keys.at(-1) === repoKey
) {
return null;
}

const next: CachedCloudBranchMap = {};
for (const key of keys) {
if (key !== repoKey) next[key] = cachedBranchMap[key];
}
next[repoKey] = entry;

const nextKeys = Object.keys(next);
for (const key of nextKeys.slice(
0,
Math.max(0, nextKeys.length - MAX_CACHED_BRANCH_REPOS),
)) {
delete next[key];
}
return next;
}

export interface EffectiveBranches {
effectiveBranches: FlattenedBranches;
servingFromCache: boolean;
}

/**
* Picks the branch list the selector should render: the cached entry stands in
* only while the live query has produced nothing yet (loading or errored) and
* no search is active, so the selector shows the last-known-good list instead
* of a loading state.
*/
export function resolveEffectiveBranches({
liveLoading,
liveErrored,
searchActive,
liveBranches,
cachedBranches,
}: {
liveLoading: boolean;
liveErrored: boolean;
searchActive: boolean;
liveBranches: FlattenedBranches | null;
cachedBranches: CachedRepoBranches | undefined;
}): EffectiveBranches {
if (liveBranches) {
return { effectiveBranches: liveBranches, servingFromCache: false };
}
const servingFromCache =
!searchActive &&
(liveLoading || liveErrored) &&
!isEmptyCachedBranches(cachedBranches);
if (servingFromCache && cachedBranches) {
return {
effectiveBranches: {
branches: cachedBranches.branches,
defaultBranch: cachedBranches.defaultBranch,
},
servingFromCache: true,
};
}
return {
effectiveBranches: { branches: [], defaultBranch: null },
servingFromCache: false,
};
}
Loading
Loading