feat(server,app): redesign git/GitHub refresh architecture with server authority
Centralizes refresh orchestration in WorkspaceGitService and GitHubService: server coalesces, deduplicates, and throttles git/GitHub reads; clients peek snapshots (cold reads fall through to a one-shot fetch) and never drive refreshes. Introduces a two-TTL model (consumer vs internal), force-emit bypass so fingerprint-matched refreshes still propagate, notifyGitMutation wiring at ~13 action handlers, adaptive GitHub polling based on check state, and checkout_status_update / prStatus push-to-cache wire messages. Fixes the stale diff-stat bug and the inverted base-branch diff direction, and flips next-action affordances promptly after commit / PR merge.
This commit is contained in:
@@ -28,7 +28,6 @@ import {
|
||||
parseWorktreeList,
|
||||
isPaseoWorktreePath,
|
||||
isDescendantPath,
|
||||
searchGitHubIssuesAndPrs,
|
||||
warmCheckoutShortstatInBackground,
|
||||
} from "./checkout-git.js";
|
||||
import {
|
||||
@@ -96,6 +95,7 @@ function createGitHubServiceForStatus(
|
||||
return {
|
||||
listPullRequests: async () => [],
|
||||
listIssues: async () => [],
|
||||
searchIssuesAndPrs: async () => ({ items: [], githubFeaturesEnabled: true }),
|
||||
getPullRequest: async () => ({
|
||||
number: 1,
|
||||
title: "PR",
|
||||
@@ -135,6 +135,27 @@ function createPullRequestStatus(overrides?: Partial<GitHubCurrentPullRequestSta
|
||||
};
|
||||
}
|
||||
|
||||
function setupRemoteTrackingMain(
|
||||
repoDir: string,
|
||||
tempDir: string,
|
||||
): { remoteDir: string; cloneDir: string } {
|
||||
const remoteDir = join(tempDir, "remote.git");
|
||||
const cloneDir = join(tempDir, "upstream-clone");
|
||||
execSync(`git init --bare -b main ${remoteDir}`);
|
||||
execSync(`git remote add origin ${remoteDir}`, { cwd: repoDir });
|
||||
execSync("git push -u origin main", { cwd: repoDir });
|
||||
execSync(`git clone ${remoteDir} ${cloneDir}`);
|
||||
execSync("git config user.email 'test@test.com'", { cwd: cloneDir });
|
||||
execSync("git config user.name 'Test'", { cwd: cloneDir });
|
||||
return { remoteDir, cloneDir };
|
||||
}
|
||||
|
||||
function commitFile(cwd: string, path: string, content: string, message: string): void {
|
||||
writeFileSync(join(cwd, path), content);
|
||||
execSync(`git add ${path}`, { cwd });
|
||||
execSync(`git -c commit.gpgsign=false commit -m '${message}'`, { cwd });
|
||||
}
|
||||
|
||||
describe("checkout git utilities", () => {
|
||||
let tempDir: string;
|
||||
let repoDir: string;
|
||||
@@ -313,6 +334,164 @@ const x = 1;
|
||||
expect(divergedStatus.behindOfOrigin).toBe(1);
|
||||
});
|
||||
|
||||
it("reports incoming additions when the base branch is behind its remote", async () => {
|
||||
const { cloneDir } = setupRemoteTrackingMain(repoDir, tempDir);
|
||||
commitFile(cloneDir, "file.txt", "remote one\nremote two\n", "remote update");
|
||||
execSync("git push", { cwd: cloneDir });
|
||||
execSync("git fetch origin", { cwd: repoDir });
|
||||
|
||||
const shortstat = await getCheckoutShortstat(repoDir);
|
||||
|
||||
expect(shortstat).toEqual({ additions: 2, deletions: 1 });
|
||||
});
|
||||
|
||||
it("reports incoming deletions when the base branch is behind its remote", async () => {
|
||||
const { cloneDir } = setupRemoteTrackingMain(repoDir, tempDir);
|
||||
commitFile(cloneDir, "file.txt", "", "remote deletion");
|
||||
execSync("git push", { cwd: cloneDir });
|
||||
execSync("git fetch origin", { cwd: repoDir });
|
||||
|
||||
const shortstat = await getCheckoutShortstat(repoDir);
|
||||
|
||||
expect(shortstat).toEqual({ additions: 0, deletions: 1 });
|
||||
});
|
||||
|
||||
it("reports outgoing changes when the base branch is ahead of its remote", async () => {
|
||||
setupRemoteTrackingMain(repoDir, tempDir);
|
||||
commitFile(repoDir, "file.txt", "local one\nlocal two\n", "local update");
|
||||
|
||||
const shortstat = await getCheckoutShortstat(repoDir);
|
||||
|
||||
expect(shortstat).toEqual({ additions: 2, deletions: 1 });
|
||||
});
|
||||
|
||||
it("uses the merge-base for shortstat when the base branch diverged from its remote", async () => {
|
||||
const { cloneDir } = setupRemoteTrackingMain(repoDir, tempDir);
|
||||
commitFile(cloneDir, "file.txt", "remote one\nremote two\n", "remote update");
|
||||
execSync("git push", { cwd: cloneDir });
|
||||
execSync("git fetch origin", { cwd: repoDir });
|
||||
commitFile(repoDir, "local.txt", "local\n", "local update");
|
||||
|
||||
const shortstat = await getCheckoutShortstat(repoDir);
|
||||
|
||||
expect(shortstat).toEqual({ additions: 1, deletions: 0 });
|
||||
});
|
||||
|
||||
it("keeps base branch divergence pointed at local work when the remote has more commits", async () => {
|
||||
const { cloneDir } = setupRemoteTrackingMain(repoDir, tempDir);
|
||||
commitFile(cloneDir, "remote-one.txt", "remote one\n", "remote update one");
|
||||
commitFile(cloneDir, "remote-two.txt", "remote two\n", "remote update two");
|
||||
execSync("git push", { cwd: cloneDir });
|
||||
execSync("git fetch origin", { cwd: repoDir });
|
||||
commitFile(repoDir, "local.txt", "local\n", "local update");
|
||||
|
||||
const shortstat = await getCheckoutShortstat(repoDir);
|
||||
|
||||
expect(shortstat).toEqual({ additions: 1, deletions: 0 });
|
||||
});
|
||||
|
||||
it("adds working tree changes to incoming shortstat when the base branch is behind", async () => {
|
||||
commitFile(repoDir, "tracked.txt", "tracked base\n", "add tracked file");
|
||||
const { cloneDir } = setupRemoteTrackingMain(repoDir, tempDir);
|
||||
commitFile(cloneDir, "incoming.txt", "incoming\n", "remote incoming");
|
||||
execSync("git push", { cwd: cloneDir });
|
||||
execSync("git fetch origin", { cwd: repoDir });
|
||||
writeFileSync(join(repoDir, "tracked.txt"), "local one\nlocal two\n");
|
||||
|
||||
const shortstat = await getCheckoutShortstat(repoDir);
|
||||
|
||||
expect(shortstat).toEqual({ additions: 3, deletions: 1 });
|
||||
});
|
||||
|
||||
it("keeps feature shortstat scoped to feature changes when the base remote is ahead", async () => {
|
||||
const { cloneDir } = setupRemoteTrackingMain(repoDir, tempDir);
|
||||
execSync("git checkout -b feature", { cwd: repoDir });
|
||||
commitFile(repoDir, "feature.txt", "feature\n", "feature update");
|
||||
execSync("git checkout main", { cwd: cloneDir });
|
||||
commitFile(cloneDir, "base.txt", "base\n", "base update");
|
||||
execSync("git push", { cwd: cloneDir });
|
||||
execSync("git fetch origin", { cwd: repoDir });
|
||||
|
||||
const shortstat = await getCheckoutShortstat(repoDir);
|
||||
|
||||
expect(shortstat).toEqual({ additions: 1, deletions: 0 });
|
||||
});
|
||||
|
||||
it("reports incoming base changes when a feature branch has no local work beyond merge-base", async () => {
|
||||
const { cloneDir } = setupRemoteTrackingMain(repoDir, tempDir);
|
||||
execSync("git checkout -b feature", { cwd: repoDir });
|
||||
execSync("git checkout main", { cwd: cloneDir });
|
||||
commitFile(cloneDir, "incoming.txt", "incoming\n", "remote incoming");
|
||||
execSync("git push", { cwd: cloneDir });
|
||||
execSync("git fetch origin", { cwd: repoDir });
|
||||
|
||||
const shortstat = await getCheckoutShortstat(repoDir);
|
||||
|
||||
expect(shortstat).toEqual({ additions: 1, deletions: 0 });
|
||||
});
|
||||
|
||||
it("reports feature shortstat ahead of the comparison merge-base", async () => {
|
||||
setupRemoteTrackingMain(repoDir, tempDir);
|
||||
execSync("git checkout -b feature", { cwd: repoDir });
|
||||
commitFile(repoDir, "feature.txt", "feature\n", "feature update");
|
||||
|
||||
const shortstat = await getCheckoutShortstat(repoDir);
|
||||
|
||||
expect(shortstat).toEqual({ additions: 1, deletions: 0 });
|
||||
});
|
||||
|
||||
it("uses the merge-base for shortstat when a feature branch diverged from its tracked remote", async () => {
|
||||
setupRemoteTrackingMain(repoDir, tempDir);
|
||||
execSync("git checkout -b feature", { cwd: repoDir });
|
||||
execSync("git push -u origin feature", { cwd: repoDir });
|
||||
const featureCloneDir = join(tempDir, "feature-clone");
|
||||
execSync(`git clone ${join(tempDir, "remote.git")} ${featureCloneDir}`);
|
||||
execSync("git config user.email 'test@test.com'", { cwd: featureCloneDir });
|
||||
execSync("git config user.name 'Test'", { cwd: featureCloneDir });
|
||||
execSync("git checkout feature", { cwd: featureCloneDir });
|
||||
commitFile(featureCloneDir, "remote-feature.txt", "remote feature\n", "remote feature update");
|
||||
execSync("git push", { cwd: featureCloneDir });
|
||||
commitFile(repoDir, "local-feature.txt", "local feature\n", "local feature update");
|
||||
execSync("git fetch origin", { cwd: repoDir });
|
||||
|
||||
const shortstat = await getCheckoutShortstat(repoDir);
|
||||
|
||||
expect(shortstat).toEqual({ additions: 1, deletions: 0 });
|
||||
});
|
||||
|
||||
it("uses the remote-only base branch as the feature shortstat comparison", async () => {
|
||||
const { cloneDir } = setupRemoteTrackingMain(repoDir, tempDir);
|
||||
execSync("git remote set-head origin main", { cwd: repoDir });
|
||||
execSync("git checkout -b feature", { cwd: repoDir });
|
||||
commitFile(repoDir, "feature.txt", "feature\n", "feature update");
|
||||
execSync("git branch -D main", { cwd: repoDir });
|
||||
commitFile(cloneDir, "base.txt", "base\n", "base update");
|
||||
execSync("git push", { cwd: cloneDir });
|
||||
execSync("git fetch origin", { cwd: repoDir });
|
||||
|
||||
const shortstat = await getCheckoutShortstat(repoDir);
|
||||
|
||||
expect(shortstat).toEqual({ additions: 1, deletions: 0 });
|
||||
});
|
||||
|
||||
it("returns no shortstat for a clean base branch that is up to date with its remote", async () => {
|
||||
setupRemoteTrackingMain(repoDir, tempDir);
|
||||
execSync("git fetch origin", { cwd: repoDir });
|
||||
|
||||
const shortstat = await getCheckoutShortstat(repoDir);
|
||||
|
||||
expect(shortstat).toBeNull();
|
||||
});
|
||||
|
||||
it("reports working tree changes when the base branch has no ahead commits", async () => {
|
||||
setupRemoteTrackingMain(repoDir, tempDir);
|
||||
writeFileSync(join(repoDir, "file.txt"), "local one\nlocal two\n");
|
||||
|
||||
const shortstat = await getCheckoutShortstat(repoDir);
|
||||
|
||||
expect(shortstat).toEqual({ additions: 2, deletions: 1 });
|
||||
});
|
||||
|
||||
it("uses the freshest comparison base for status and shortstat when local main is stale", async () => {
|
||||
const remoteDir = join(tempDir, "remote.git");
|
||||
const cloneDir = join(tempDir, "clone");
|
||||
@@ -1125,129 +1304,6 @@ const x = 1;
|
||||
expect(status.status).toBeNull();
|
||||
});
|
||||
|
||||
it("searches GitHub issues and PRs through the GitHub service", async () => {
|
||||
let issueCalls = 0;
|
||||
let pullRequestCalls = 0;
|
||||
const github = createGitHubServiceForStatus(null);
|
||||
github.listIssues = async (options) => {
|
||||
issueCalls += 1;
|
||||
expect(options).toEqual({ cwd: repoDir, query: "cache", limit: 5 });
|
||||
return [
|
||||
{
|
||||
number: 55,
|
||||
title: "Issue title",
|
||||
url: "https://github.com/getpaseo/paseo/issues/55",
|
||||
state: "OPEN",
|
||||
body: "issue body",
|
||||
labels: ["bug"],
|
||||
updatedAt: "2026-04-18T12:00:00Z",
|
||||
},
|
||||
];
|
||||
};
|
||||
github.listPullRequests = async (options) => {
|
||||
pullRequestCalls += 1;
|
||||
expect(options).toEqual({ cwd: repoDir, query: "cache", limit: 5 });
|
||||
return [
|
||||
{
|
||||
number: 123,
|
||||
title: "PR title",
|
||||
url: "https://github.com/getpaseo/paseo/pull/123",
|
||||
state: "OPEN",
|
||||
body: "pr body",
|
||||
baseRefName: "main",
|
||||
headRefName: "feature",
|
||||
labels: ["enhancement"],
|
||||
updatedAt: "2026-04-18T13:00:00Z",
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const result = await searchGitHubIssuesAndPrs(repoDir, "cache", 5, github);
|
||||
|
||||
expect(issueCalls).toBe(1);
|
||||
expect(pullRequestCalls).toBe(1);
|
||||
expect(result).toEqual({
|
||||
githubFeaturesEnabled: true,
|
||||
items: [
|
||||
{
|
||||
kind: "issue",
|
||||
number: 55,
|
||||
title: "Issue title",
|
||||
url: "https://github.com/getpaseo/paseo/issues/55",
|
||||
state: "OPEN",
|
||||
body: "issue body",
|
||||
labels: ["bug"],
|
||||
baseRefName: null,
|
||||
headRefName: null,
|
||||
updatedAt: "2026-04-18T12:00:00Z",
|
||||
},
|
||||
{
|
||||
kind: "pr",
|
||||
number: 123,
|
||||
title: "PR title",
|
||||
url: "https://github.com/getpaseo/paseo/pull/123",
|
||||
state: "OPEN",
|
||||
body: "pr body",
|
||||
labels: ["enhancement"],
|
||||
baseRefName: "main",
|
||||
headRefName: "feature",
|
||||
updatedAt: "2026-04-18T13:00:00Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("searches only GitHub PRs when the search kinds request excludes issues", async () => {
|
||||
let issueCalls = 0;
|
||||
let pullRequestCalls = 0;
|
||||
const github = createGitHubServiceForStatus(null);
|
||||
github.listIssues = async () => {
|
||||
issueCalls += 1;
|
||||
return [];
|
||||
};
|
||||
github.listPullRequests = async (options) => {
|
||||
pullRequestCalls += 1;
|
||||
expect(options).toEqual({ cwd: repoDir, query: "cache", limit: 5 });
|
||||
return [
|
||||
{
|
||||
number: 123,
|
||||
title: "PR title",
|
||||
url: "https://github.com/getpaseo/paseo/pull/123",
|
||||
state: "OPEN",
|
||||
body: "pr body",
|
||||
baseRefName: "main",
|
||||
headRefName: "feature",
|
||||
labels: ["enhancement"],
|
||||
updatedAt: "2026-04-18T13:00:00Z",
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const result = await searchGitHubIssuesAndPrs(repoDir, "cache", 5, github, {
|
||||
kinds: ["github-pr"],
|
||||
});
|
||||
|
||||
expect(issueCalls).toBe(0);
|
||||
expect(pullRequestCalls).toBe(1);
|
||||
expect(result).toEqual({
|
||||
githubFeaturesEnabled: true,
|
||||
items: [
|
||||
{
|
||||
kind: "pr",
|
||||
number: 123,
|
||||
title: "PR title",
|
||||
url: "https://github.com/getpaseo/paseo/pull/123",
|
||||
state: "OPEN",
|
||||
body: "pr body",
|
||||
labels: ["enhancement"],
|
||||
baseRefName: "main",
|
||||
headRefName: "feature",
|
||||
updatedAt: "2026-04-18T13:00:00Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns merged PR status when no open PR exists for the current branch", async () => {
|
||||
execSync("git checkout -b feature", { cwd: repoDir });
|
||||
execSync("git remote add origin https://github.com/getpaseo/paseo.git", { cwd: repoDir });
|
||||
|
||||
@@ -3,13 +3,13 @@ import { existsSync, realpathSync } from "fs";
|
||||
import { open as openFile, stat as statFile } from "fs/promises";
|
||||
import { TTLCache } from "@isaacs/ttlcache";
|
||||
import type { ParsedDiffFile } from "../server/utils/diff-highlighter.js";
|
||||
import type { GitHubSearchKind } from "../shared/messages.js";
|
||||
import { parseAndHighlightDiff } from "../server/utils/diff-highlighter.js";
|
||||
import {
|
||||
GitHubAuthenticationError,
|
||||
GitHubCliMissingError,
|
||||
createGitHubService,
|
||||
resolveGitHubRepo,
|
||||
type GitHubRepoRemoteUrlResolver,
|
||||
type GitHubService,
|
||||
} from "../services/github-service.js";
|
||||
import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js";
|
||||
@@ -33,6 +33,11 @@ let shortstatCacheTtlMs = DEFAULT_SHORTSTAT_CACHE_TTL_MS;
|
||||
let shortstatCache = createShortstatCache(shortstatCacheTtlMs);
|
||||
const shortstatInFlight = new Map<string, Promise<CheckoutShortstat | null>>();
|
||||
|
||||
interface CheckoutReadCacheOptions {
|
||||
force?: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
function createPullRequestStatusCache(ttlMs: number) {
|
||||
return new TTLCache<string, PullRequestStatusResult>({
|
||||
ttl: ttlMs,
|
||||
@@ -1317,6 +1322,44 @@ export interface CheckoutShortstat {
|
||||
deletions: number;
|
||||
}
|
||||
|
||||
function parseCheckoutShortstat(text: string): CheckoutShortstat | null {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let additions = 0;
|
||||
let deletions = 0;
|
||||
const addMatch = trimmed.match(/(\d+)\s+insertion/);
|
||||
if (addMatch) {
|
||||
additions = Number.parseInt(addMatch[1]!, 10);
|
||||
}
|
||||
const delMatch = trimmed.match(/(\d+)\s+deletion/);
|
||||
if (delMatch) {
|
||||
deletions = Number.parseInt(delMatch[1]!, 10);
|
||||
}
|
||||
|
||||
if (additions === 0 && deletions === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { additions, deletions };
|
||||
}
|
||||
|
||||
function combineCheckoutShortstats(
|
||||
first: CheckoutShortstat | null,
|
||||
second: CheckoutShortstat | null,
|
||||
): CheckoutShortstat | null {
|
||||
const additions = (first?.additions ?? 0) + (second?.additions ?? 0);
|
||||
const deletions = (first?.deletions ?? 0) + (second?.deletions ?? 0);
|
||||
|
||||
if (additions === 0 && deletions === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { additions, deletions };
|
||||
}
|
||||
|
||||
async function getCheckoutShortstatUncached(
|
||||
cwd: string,
|
||||
context?: CheckoutContext,
|
||||
@@ -1331,63 +1374,65 @@ async function getCheckoutShortstatUncached(
|
||||
const localBaseRef = configured.baseRef ?? (await resolveBaseRef(cwd));
|
||||
const currentBranch = await getCurrentBranch(cwd);
|
||||
|
||||
let diffTarget: string;
|
||||
let comparisonRef: string;
|
||||
|
||||
if (currentBranch && localBaseRef && currentBranch !== localBaseRef) {
|
||||
// Feature branch: diff against the merge-base with the base branch
|
||||
const comparisonBaseRef = await resolveBestComparisonBaseRef(cwd, localBaseRef);
|
||||
|
||||
try {
|
||||
const { stdout } = await runGitCommand(["merge-base", "HEAD", comparisonBaseRef], {
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
const mergeBase = stdout.trim();
|
||||
if (!mergeBase) {
|
||||
return null;
|
||||
}
|
||||
diffTarget = mergeBase;
|
||||
comparisonRef = await resolveBestComparisonBaseRef(cwd, localBaseRef);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
} else if (currentBranch) {
|
||||
// On the base branch (or no base ref configured): diff against remote tracking branch
|
||||
const hasOrigin = await doesGitRefExist(cwd, `refs/remotes/origin/${currentBranch}`);
|
||||
if (!hasOrigin) {
|
||||
return null;
|
||||
}
|
||||
diffTarget = `origin/${currentBranch}`;
|
||||
comparisonRef = `origin/${currentBranch}`;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Omit HEAD so the diff includes uncommitted (staged + unstaged) changes
|
||||
const { stdout } = await runGitCommand(["diff", "--shortstat", diffTarget], {
|
||||
const { stdout: mergeBaseOut } = await runGitCommand(["merge-base", "HEAD", comparisonRef], {
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
const text = stdout.trim();
|
||||
if (!text) {
|
||||
const mergeBase = mergeBaseOut.trim();
|
||||
if (!mergeBase) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let additions = 0;
|
||||
let deletions = 0;
|
||||
const addMatch = text.match(/(\d+)\s+insertion/);
|
||||
if (addMatch) {
|
||||
additions = Number.parseInt(addMatch[1]!, 10);
|
||||
}
|
||||
const delMatch = text.match(/(\d+)\s+deletion/);
|
||||
if (delMatch) {
|
||||
deletions = Number.parseInt(delMatch[1]!, 10);
|
||||
}
|
||||
|
||||
if (additions === 0 && deletions === 0) {
|
||||
const { stdout: aheadBehindOut } = await runGitCommand(
|
||||
["rev-list", "--left-right", "--count", `HEAD...${comparisonRef}`],
|
||||
{ cwd, env: READ_ONLY_GIT_ENV },
|
||||
);
|
||||
const [headOnlyRaw, comparisonOnlyRaw] = aheadBehindOut.trim().split(/\s+/);
|
||||
const headOnly = Number.parseInt(headOnlyRaw ?? "0", 10);
|
||||
const comparisonOnly = Number.parseInt(comparisonOnlyRaw ?? "0", 10);
|
||||
if (Number.isNaN(headOnly) || Number.isNaN(comparisonOnly)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { additions, deletions };
|
||||
// Incoming uses a two-ref diff, so add the working tree diff separately.
|
||||
const showIncoming = headOnly === 0 && comparisonOnly > 0;
|
||||
const { stdout } = await runGitCommand(
|
||||
["diff", "--shortstat", mergeBase, ...(showIncoming ? [comparisonRef] : [])],
|
||||
{
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
},
|
||||
);
|
||||
const committedShortstat = parseCheckoutShortstat(stdout);
|
||||
|
||||
if (!showIncoming) {
|
||||
return committedShortstat;
|
||||
}
|
||||
|
||||
const { stdout: worktreeOut } = await runGitCommand(["diff", "--shortstat", "HEAD"], {
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
return combineCheckoutShortstats(committedShortstat, parseCheckoutShortstat(worktreeOut));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -1396,16 +1441,19 @@ async function getCheckoutShortstatUncached(
|
||||
function getOrLoadCheckoutShortstat(
|
||||
cwd: string,
|
||||
context?: CheckoutContext,
|
||||
options?: CheckoutReadCacheOptions,
|
||||
): Promise<CheckoutShortstat | null> {
|
||||
const cacheKey = getShortstatCacheKey(cwd);
|
||||
const cached = shortstatCache.get(cacheKey);
|
||||
if (cached !== undefined) {
|
||||
return Promise.resolve(cached);
|
||||
}
|
||||
if (!options?.force) {
|
||||
const cached = shortstatCache.get(cacheKey);
|
||||
if (cached !== undefined) {
|
||||
return Promise.resolve(cached);
|
||||
}
|
||||
|
||||
const existing = shortstatInFlight.get(cacheKey);
|
||||
if (existing) {
|
||||
return existing;
|
||||
const existing = shortstatInFlight.get(cacheKey);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
|
||||
const load = getCheckoutShortstatUncached(cwd, context)
|
||||
@@ -1424,8 +1472,9 @@ function getOrLoadCheckoutShortstat(
|
||||
export async function getCheckoutShortstat(
|
||||
cwd: string,
|
||||
context?: CheckoutContext,
|
||||
options?: CheckoutReadCacheOptions,
|
||||
): Promise<CheckoutShortstat | null> {
|
||||
return getOrLoadCheckoutShortstat(cwd, context);
|
||||
return getOrLoadCheckoutShortstat(cwd, context, options);
|
||||
}
|
||||
|
||||
export function getCachedCheckoutShortstat(cwd: string): CheckoutShortstat | null | undefined {
|
||||
@@ -1996,9 +2045,10 @@ export async function createPullRequest(
|
||||
cwd: string,
|
||||
options: CreatePullRequestOptions,
|
||||
github: GitHubService = createGitHubService(),
|
||||
workspaceGitService: GitHubRepoRemoteUrlResolver,
|
||||
): Promise<{ url: string; number: number }> {
|
||||
await requireGitRepo(cwd);
|
||||
const repo = await resolveGitHubRepo(cwd);
|
||||
const repo = await resolveGitHubRepo(cwd, { workspaceGitService });
|
||||
if (!repo) {
|
||||
throw new Error("Unable to determine GitHub repo from git remote");
|
||||
}
|
||||
@@ -2034,19 +2084,22 @@ export async function createPullRequest(
|
||||
export async function getPullRequestStatus(
|
||||
cwd: string,
|
||||
github: GitHubService = createGitHubService(),
|
||||
options?: CheckoutReadCacheOptions,
|
||||
): Promise<PullRequestStatusResult> {
|
||||
const cacheKey = getPullRequestStatusCacheKey(cwd);
|
||||
const cached = pullRequestStatusCache.get(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
if (!options?.force) {
|
||||
const cached = pullRequestStatusCache.get(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const existing = pullRequestStatusInFlight.get(cacheKey);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
|
||||
const existing = pullRequestStatusInFlight.get(cacheKey);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const lookup = getPullRequestStatusUncached(cwd, github)
|
||||
const lookup = getPullRequestStatusUncached(cwd, github, options)
|
||||
.then((status) => {
|
||||
pullRequestStatusCache.set(cacheKey, status);
|
||||
return status;
|
||||
@@ -2062,6 +2115,7 @@ export async function getPullRequestStatus(
|
||||
async function getPullRequestStatusUncached(
|
||||
cwd: string,
|
||||
github: GitHubService,
|
||||
options?: CheckoutReadCacheOptions,
|
||||
): Promise<PullRequestStatusResult> {
|
||||
await requireGitRepo(cwd);
|
||||
const head = await getCurrentBranch(cwd);
|
||||
@@ -2072,7 +2126,11 @@ async function getPullRequestStatusUncached(
|
||||
};
|
||||
}
|
||||
try {
|
||||
const status = await github.getCurrentPullRequestStatus({ cwd, headRef: head });
|
||||
const status = await github.getCurrentPullRequestStatus({
|
||||
cwd,
|
||||
headRef: head,
|
||||
reason: options?.reason,
|
||||
});
|
||||
return {
|
||||
status,
|
||||
githubFeaturesEnabled: true,
|
||||
@@ -2084,107 +2142,3 @@ async function getPullRequestStatusUncached(
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export interface GitHubSearchResult {
|
||||
items: Array<{
|
||||
kind: "issue" | "pr";
|
||||
number: number;
|
||||
title: string;
|
||||
url: string;
|
||||
state: string;
|
||||
body: string | null;
|
||||
labels: string[];
|
||||
baseRefName?: string | null;
|
||||
headRefName?: string | null;
|
||||
updatedAt?: string;
|
||||
}>;
|
||||
githubFeaturesEnabled: boolean;
|
||||
}
|
||||
|
||||
interface SearchGitHubIssuesAndPrsOptions {
|
||||
kinds?: GitHubSearchKind[];
|
||||
}
|
||||
|
||||
export async function searchGitHubIssuesAndPrs(
|
||||
cwd: string,
|
||||
query: string,
|
||||
limit = 20,
|
||||
github: GitHubService = createGitHubService(),
|
||||
options: SearchGitHubIssuesAndPrsOptions = {},
|
||||
): Promise<GitHubSearchResult> {
|
||||
await requireGitRepo(cwd);
|
||||
try {
|
||||
const kinds = options.kinds ?? ["github-issue", "github-pr"];
|
||||
const shouldFetchIssues = kinds.includes("github-issue");
|
||||
const shouldFetchPullRequests = kinds.includes("github-pr");
|
||||
const issuesResult = shouldFetchIssues
|
||||
? await github.listIssues({ cwd, query, limit }).then(
|
||||
(value) => ({ status: "fulfilled" as const, value }),
|
||||
(reason) => ({ status: "rejected" as const, reason }),
|
||||
)
|
||||
: null;
|
||||
const prsResult = shouldFetchPullRequests
|
||||
? await github.listPullRequests({ cwd, query, limit }).then(
|
||||
(value) => ({ status: "fulfilled" as const, value }),
|
||||
(reason) => ({ status: "rejected" as const, reason }),
|
||||
)
|
||||
: null;
|
||||
|
||||
const items: GitHubSearchResult["items"] = [];
|
||||
const requestedResults = [issuesResult, prsResult].filter((result) => result !== null);
|
||||
const failures = requestedResults.filter((result) => result.status === "rejected");
|
||||
if (
|
||||
requestedResults.length > 0 &&
|
||||
failures.length === requestedResults.length &&
|
||||
failures.every(
|
||||
(result) =>
|
||||
result.status === "rejected" &&
|
||||
(result.reason instanceof GitHubCliMissingError ||
|
||||
result.reason instanceof GitHubAuthenticationError),
|
||||
)
|
||||
) {
|
||||
return { items: [], githubFeaturesEnabled: false };
|
||||
}
|
||||
|
||||
if (issuesResult?.status === "fulfilled") {
|
||||
for (const item of issuesResult.value) {
|
||||
items.push({
|
||||
kind: "issue",
|
||||
number: item.number,
|
||||
title: item.title,
|
||||
url: item.url,
|
||||
state: item.state,
|
||||
body: item.body,
|
||||
labels: item.labels,
|
||||
baseRefName: null,
|
||||
headRefName: null,
|
||||
updatedAt: item.updatedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (prsResult?.status === "fulfilled") {
|
||||
for (const item of prsResult.value) {
|
||||
items.push({
|
||||
kind: "pr",
|
||||
number: item.number,
|
||||
title: item.title,
|
||||
url: item.url,
|
||||
state: item.state,
|
||||
body: item.body,
|
||||
labels: item.labels,
|
||||
baseRefName: item.baseRefName,
|
||||
headRefName: item.headRefName,
|
||||
updatedAt: item.updatedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { items, githubFeaturesEnabled: true };
|
||||
} catch (error) {
|
||||
if (error instanceof GitHubCliMissingError || error instanceof GitHubAuthenticationError) {
|
||||
return { items: [], githubFeaturesEnabled: false };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user