diff --git a/src/api/http/git/operations.test.ts b/src/api/http/git/operations.test.ts new file mode 100644 index 000000000..fdd9b332b --- /dev/null +++ b/src/api/http/git/operations.test.ts @@ -0,0 +1,48 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { BRANCH_REMOTE_MUTATION_EVENT } from "@src/util/git/branchRemoteMutation"; + +import { fetchRustApi } from "./client"; +import { gitPush } from "./operations"; + +vi.mock("./client", () => ({ + fetchRustApi: vi.fn(), + gitRepoUrl: (repoId: string) => `/git/repos/${repoId}`, +})); + +const fetchRustApiMock = vi.mocked(fetchRustApi); + +describe("gitPush", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it("announces the pushed repo and branch after success", async () => { + fetchRustApiMock.mockResolvedValue({ data: { success: true } } as never); + const details: unknown[] = []; + const listener = (event: Event) => { + details.push((event as CustomEvent).detail); + }; + window.addEventListener(BRANCH_REMOTE_MUTATION_EVENT, listener); + + try { + await gitPush({ + repo_id: "repo-1", + repo_path: "/repo", + branch: "feature", + }); + } finally { + window.removeEventListener(BRANCH_REMOTE_MUTATION_EVENT, listener); + } + + expect(details).toEqual([ + { + repoId: "repo-1", + repoPath: "/repo", + branchName: "feature", + reason: "push", + }, + ]); + }); +}); diff --git a/src/api/http/git/operations.ts b/src/api/http/git/operations.ts index a2330a25d..9677d6725 100644 --- a/src/api/http/git/operations.ts +++ b/src/api/http/git/operations.ts @@ -3,6 +3,8 @@ * * Fetch, pull, and push operations. */ +import { announceBranchRemoteMutation } from "@src/util/git/branchRemoteMutation"; + import { fetchRustApi, gitRepoUrl } from "./client"; import type { GitErrorType } from "./streaming"; import type { GitOperationResponse, GitPullResponse } from "./types"; @@ -150,5 +152,12 @@ export const gitPush = async (params: { throwGitRemoteOperationError(result, "Push failed"); } + announceBranchRemoteMutation({ + repoId: params.repo_id, + repoPath: params.repo_path, + branchName: params.branch, + reason: "push", + }); + return result; }; diff --git a/src/hooks/git/useBranchPullRequestStatus.test.ts b/src/hooks/git/useBranchPullRequestStatus.test.ts index a0cfbaa8b..69ea45df3 100644 --- a/src/hooks/git/useBranchPullRequestStatus.test.ts +++ b/src/hooks/git/useBranchPullRequestStatus.test.ts @@ -23,8 +23,10 @@ import { import { BRANCH_CI_POLL_BASE_MS, BRANCH_CI_POLL_MAX_MS, + BRANCH_CI_SAFETY_POLL_MS, clearBranchPullRequestStatusCache, } from "@src/services/git/branchPullRequestStatus"; +import { announceBranchRemoteMutation } from "@src/util/git/branchRemoteMutation"; import { type UseBranchPullRequestStatusOptions, @@ -339,6 +341,187 @@ describe("useBranchPullRequestStatus", () => { expect(getChecksLocalMock).toHaveBeenCalledTimes(3); }); + it("discovers a newly-created PR immediately after branch invalidation", async () => { + findPullRequestLocalMock.mockResolvedValueOnce(null); + let latest!: UseBranchPullRequestStatusResult; + + await act(async () => { + root.render( + createElement(Probe, { + options: { + repoId: "repo-1", + repoPath: "/repo", + branchName: "feature", + poll: true, + }, + onValue: (value) => { + latest = value; + }, + }) + ); + }); + expect(latest.pr).toBeNull(); + + await act(async () => { + announceBranchRemoteMutation({ + repoId: "repo-1", + repoPath: "/repo", + branchName: "feature", + reason: "pull-request-created", + }); + }); + + expect(findPullRequestLocalMock).toHaveBeenCalledTimes(2); + expect(latest.pr?.number).toBe(12); + expect(latest.ciStatus).toBe("success"); + }); + + it("defers a hidden push invalidation and forces it on visibility return", async () => { + getPRLocalMock + .mockResolvedValueOnce({ head: { sha: "abc" } }) + .mockResolvedValueOnce({ head: { sha: "def" } }); + getChecksLocalMock + .mockResolvedValueOnce({ + ...runningChecks(), + sha: "abc", + state: "success", + check_runs: [ + { + ...runningChecks().check_runs[0], + status: "completed", + conclusion: "success", + }, + ], + }) + .mockResolvedValueOnce({ ...runningChecks(), sha: "def" }); + let latest!: UseBranchPullRequestStatusResult; + + await act(async () => { + root.render( + createElement(Probe, { + options: { + repoId: "repo-1", + repoPath: "/repo", + branchName: "feature", + poll: true, + }, + onValue: (value) => { + latest = value; + }, + }) + ); + }); + expect(latest.ciStatus).toBe("success"); + + visibilityState = "hidden"; + document.dispatchEvent(new Event("visibilitychange")); + await act(async () => { + announceBranchRemoteMutation({ + repoId: "repo-1", + repoPath: "/repo", + branchName: "feature", + reason: "push", + }); + }); + expect(getChecksLocalMock).toHaveBeenCalledTimes(1); + + visibilityState = "visible"; + await act(async () => { + document.dispatchEvent(new Event("visibilitychange")); + }); + + expect(getChecksLocalMock).toHaveBeenLastCalledWith("acme/repo", "def"); + expect(latest.ciStatus).toBe("pending"); + }); + + it("forces a new PR-head read when local HEAD changes", async () => { + getPRLocalMock + .mockResolvedValueOnce({ head: { sha: "abc" } }) + .mockResolvedValueOnce({ head: { sha: "def" } }); + getChecksLocalMock + .mockResolvedValueOnce({ + ...runningChecks(), + sha: "abc", + state: "success", + check_runs: [ + { + ...runningChecks().check_runs[0], + status: "completed", + conclusion: "success", + }, + ], + }) + .mockResolvedValueOnce({ ...runningChecks(), sha: "def" }); + let latest!: UseBranchPullRequestStatusResult; + const onValue = (value: UseBranchPullRequestStatusResult) => { + latest = value; + }; + + await act(async () => { + root.render( + createElement(Probe, { + options: { + repoId: "repo-1", + repoPath: "/repo", + branchName: "feature", + headRevision: "abc1234", + poll: true, + }, + onValue, + }) + ); + }); + expect(latest.ciStatus).toBe("success"); + + await act(async () => { + root.render( + createElement(Probe, { + options: { + repoId: "repo-1", + repoPath: "/repo", + branchName: "feature", + headRevision: "def5678", + poll: true, + }, + onValue, + }) + ); + }); + + expect(getPRLocalMock).toHaveBeenCalledTimes(2); + expect(getChecksLocalMock).toHaveBeenLastCalledWith("acme/repo", "def"); + expect(latest.ciStatus).toBe("pending"); + }); + + it("uses only a slow safety refresh after settled CI", async () => { + vi.useFakeTimers(); + + await act(async () => { + root.render( + createElement(Probe, { + options: { + repoId: "repo-1", + repoPath: "/repo", + branchName: "feature", + poll: true, + }, + onValue: () => undefined, + }) + ); + }); + expect(getChecksLocalMock).toHaveBeenCalledTimes(1); + + await act(async () => { + await vi.advanceTimersByTimeAsync(BRANCH_CI_SAFETY_POLL_MS - 1); + }); + expect(getChecksLocalMock).toHaveBeenCalledTimes(1); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1); + }); + expect(getChecksLocalMock).toHaveBeenCalledTimes(2); + }); + it("never schedules a poll when tracing is not requested", async () => { vi.useFakeTimers(); getChecksLocalMock.mockResolvedValue(runningChecks()); diff --git a/src/hooks/git/useBranchPullRequestStatus.ts b/src/hooks/git/useBranchPullRequestStatus.ts index 28b164907..cff8cd9c6 100644 --- a/src/hooks/git/useBranchPullRequestStatus.ts +++ b/src/hooks/git/useBranchPullRequestStatus.ts @@ -23,6 +23,11 @@ import { setCachedBranchPullRequestStatus, } from "@src/services/git/branchPullRequestStatus"; import { parseGithubRepoFullName } from "@src/services/git/operations/createPullRequest"; +import { + BRANCH_REMOTE_MUTATION_EVENT, + type BranchRemoteMutationDetail, + isMatchingBranchRemoteMutation, +} from "@src/util/git/branchRemoteMutation"; const GITHUB_ENDPOINT = "https://github.com"; @@ -49,6 +54,8 @@ const EMPTY_STATE: BranchPullRequestStatusState = { export interface UseBranchPullRequestStatusOptions { branchName?: string; + /** Local HEAD identity; a change forces a fresh PR-head/check read. */ + headRevision?: string; repoId?: string; repoPath?: string; /** @@ -119,6 +126,7 @@ async function fetchStatusSnapshot( export function useBranchPullRequestStatus({ branchName, + headRevision, repoId, repoPath, poll = false, @@ -131,6 +139,12 @@ export function useBranchPullRequestStatus({ const pollTimerRef = useRef(null); const pollAttemptRef = useRef(0); const pollHeadShaRef = useRef(null); + const remoteMutationVersionRef = useRef(0); + const appliedRemoteMutationVersionRef = useRef(0); + const observedHeadRef = useRef<{ + scopeKey: string | null; + revision?: string; + } | null>(null); const scopeKey = repoPath && branchName ? `${repoId ?? "default"}|${repoPath}|${branchName}` @@ -139,6 +153,14 @@ export function useBranchPullRequestStatus({ useEffect(() => { let disposed = false; + const previousHead = observedHeadRef.current; + const localHeadChanged = + previousHead?.scopeKey === scopeKey && + previousHead.revision !== undefined && + previousHead.revision !== headRevision; + observedHeadRef.current = { scopeKey, revision: headRevision }; + if (localHeadChanged) remoteMutationVersionRef.current += 1; + const clearPollTimer = () => { if (pollTimerRef.current != null) { window.clearTimeout(pollTimerRef.current); @@ -189,6 +211,7 @@ export function useBranchPullRequestStatus({ return; } const force = options?.force === true; + const mutationVersion = remoteMutationVersionRef.current; const generation = ++generationRef.current; const isCurrent = () => !disposed && generation === generationRef.current; @@ -259,8 +282,12 @@ export function useBranchPullRequestStatus({ } try { + // A mutation version change uses a new coalescing lane. If a push + // lands while an older GitHub request is in flight, the forced read + // must not join that pre-push promise and preserve stale green CI. + const requestKey = `${cacheKey}|remote:${remoteMutationVersionRef.current}`; const snapshot = await loadBranchPullRequestStatusCoalesced( - cacheKey, + requestKey, () => fetchStatusSnapshot(repoFullName, branchName) ); if (!isCurrent()) return; @@ -274,6 +301,7 @@ export function useBranchPullRequestStatus({ refreshing: false, scopeKey, }); + appliedRemoteMutationVersionRef.current = mutationVersion; scheduleNextPoll(snapshot); } catch { if (!isCurrent()) return; @@ -294,22 +322,44 @@ export function useBranchPullRequestStatus({ const handleVisibilityChange = () => { if (document.visibilityState === "visible") { - void load(); + void load({ + force: + appliedRemoteMutationVersionRef.current !== + remoteMutationVersionRef.current, + }); } else { // Nothing to trace behind a hidden window; the return trip re-reads. clearPollTimer(); } }; + const handleRemoteMutation = (event: Event) => { + const detail = (event as CustomEvent).detail; + if ( + !isMatchingBranchRemoteMutation(detail, { + repoId: repoId ?? "default", + repoPath, + branchName, + }) + ) { + return; + } + remoteMutationVersionRef.current += 1; + pollAttemptRef.current = 0; + pollHeadShaRef.current = null; + loadRef.current?.({ force: true }); + }; + if ( typeof document === "undefined" || document.visibilityState !== "hidden" ) { - void load(); + void load({ force: localHeadChanged }); } if (typeof document !== "undefined") { document.addEventListener("visibilitychange", handleVisibilityChange); } + window.addEventListener(BRANCH_REMOTE_MUTATION_EVENT, handleRemoteMutation); return () => { disposed = true; @@ -322,8 +372,12 @@ export function useBranchPullRequestStatus({ handleVisibilityChange ); } + window.removeEventListener( + BRANCH_REMOTE_MUTATION_EVENT, + handleRemoteMutation + ); }; - }, [branchName, poll, repoId, repoPath, scopeKey]); + }, [branchName, headRevision, poll, repoId, repoPath, scopeKey]); const visibleState = state.scopeKey === scopeKey diff --git a/src/modules/WorkStation/shared/StatusBar/CiStatusMenu.tsx b/src/modules/WorkStation/shared/StatusBar/CiStatusMenu.tsx index c53f1d824..2c2a34092 100644 --- a/src/modules/WorkStation/shared/StatusBar/CiStatusMenu.tsx +++ b/src/modules/WorkStation/shared/StatusBar/CiStatusMenu.tsx @@ -59,6 +59,7 @@ const SECTION_ORDER: CiCheckState[] = [ interface CiStatusMenuProps { branchName?: string; + headRevision?: string; } function CheckStateIcon({ @@ -174,13 +175,14 @@ const CheckRow: React.FC = memo(({ item, onOpenDetails }) => { CheckRow.displayName = "CheckRow"; export const CiStatusMenu: React.FC = memo( - ({ branchName }) => { + ({ branchName, headRevision }) => { const { t } = useTranslation(); const { repoId, repoPath } = useActiveRepoRef(); const { checks, ciStatus, pr, refresh, refreshing } = useBranchPullRequestStatus({ branchName, + headRevision, repoId, repoPath, poll: true, diff --git a/src/modules/WorkStation/shared/StatusBar/EditorStatusBar.tsx b/src/modules/WorkStation/shared/StatusBar/EditorStatusBar.tsx index f9f6db707..1d4499a85 100644 --- a/src/modules/WorkStation/shared/StatusBar/EditorStatusBar.tsx +++ b/src/modules/WorkStation/shared/StatusBar/EditorStatusBar.tsx @@ -312,7 +312,10 @@ export const EditorStatusBar: React.FC = memo( )} {showGitControls && branchName && ( - + )} {showGitControls && branchName && ( @@ -421,6 +424,7 @@ export const EditorStatusBar: React.FC = memo( aheadCount, workingAdditions, workingDeletions, + commitInfo?.shortSha, onRepoClick, onBranchClick, onWorktreeClick, diff --git a/src/services/git/branchPullRequestStatus.test.ts b/src/services/git/branchPullRequestStatus.test.ts index 7ba4befb8..3c2d8cbf6 100644 --- a/src/services/git/branchPullRequestStatus.test.ts +++ b/src/services/git/branchPullRequestStatus.test.ts @@ -10,6 +10,7 @@ import { BRANCH_CI_EMPTY_POLL_MS, BRANCH_CI_POLL_BASE_MS, BRANCH_CI_POLL_MAX_MS, + BRANCH_CI_SAFETY_POLL_MS, BRANCH_PULL_REQUEST_STATUS_CACHE_MAX_ENTRIES, BRANCH_PULL_REQUEST_STATUS_TTL_MS, branchPullRequestStatusCacheSize, @@ -63,13 +64,13 @@ describe("branch pull request status", () => { vi.useRealTimers(); }); - it("stops polling when there is nothing left to trace", () => { + it("cools terminal and no-PR states to the safety interval", () => { const base = { attempt: 0, checksUnavailable: false }; - // No PR, unreadable checks, and settled checks all end the schedule. + // These states leave the fast loop but retain a remote-change fallback. expect( nextBranchCiPollDelayMs({ ...base, pr: null, checks: checks("pending") }) - ).toBeNull(); + ).toBe(BRANCH_CI_SAFETY_POLL_MS); expect( nextBranchCiPollDelayMs({ ...base, @@ -77,13 +78,13 @@ describe("branch pull request status", () => { checks: null, checksUnavailable: true, }) - ).toBeNull(); + ).toBe(BRANCH_CI_SAFETY_POLL_MS); expect( nextBranchCiPollDelayMs({ ...base, pr, checks: checks("success") }) - ).toBeNull(); + ).toBe(BRANCH_CI_SAFETY_POLL_MS); expect( nextBranchCiPollDelayMs({ ...base, pr, checks: checks("failure") }) - ).toBeNull(); + ).toBe(BRANCH_CI_SAFETY_POLL_MS); }); it("backs off while checks run and caps the interval", () => { @@ -121,7 +122,7 @@ describe("branch pull request status", () => { ...empty, attempt: BRANCH_CI_EMPTY_POLL_MAX_ATTEMPTS, }) - ).toBeNull(); + ).toBe(BRANCH_CI_SAFETY_POLL_MS); }); it("builds GitHub compare links and falls back to the compare picker", () => { diff --git a/src/services/git/branchPullRequestStatus.ts b/src/services/git/branchPullRequestStatus.ts index b250e8611..b3e44e1d6 100644 --- a/src/services/git/branchPullRequestStatus.ts +++ b/src/services/git/branchPullRequestStatus.ts @@ -15,6 +15,12 @@ export const BRANCH_CI_POLL_MAX_MS = 60_000; export const BRANCH_CI_EMPTY_POLL_MS = 30_000; /** How many times to re-ask before accepting that a PR simply has no CI. */ export const BRANCH_CI_EMPTY_POLL_MAX_ATTEMPTS = 3; +/** + * Slow safety refresh after a terminal/no-PR result. Push and PR creation + * normally invalidate immediately; this bounded fallback covers remote-only + * changes and missed events without keeping the fast CI loop alive forever. + */ +export const BRANCH_CI_SAFETY_POLL_MS = 5 * 60_000; export interface BranchPullRequestStatusSnapshot { pr: LocalFindPRResponse | null; @@ -153,13 +159,13 @@ export function resolveBranchCiStatus({ } /** - * Delay before the next branch-CI poll, or `null` to stop polling. + * Delay before the next branch-CI poll. * * Tracing a branch's CI the way GitHub Desktop does, without its polling cost: - * we only keep asking while something can still change. Once every run has - * reported — or there is no PR and no CI to watch at all — the schedule ends - * and the next read comes from an explicit trigger (opening the menu, - * switching branch, or the window becoming visible again). + * we ask quickly only while something can still change. Once every run has + * reported — or there is no PR yet — the schedule cools to a five-minute + * safety refresh. Local pushes and PR creation trigger immediate invalidation, + * so the safety timer is for remote-only changes and missed events. * * @param attempt Consecutive polls already scheduled for this head commit. * Callers reset it whenever the head SHA changes, so a new push restarts at @@ -171,18 +177,17 @@ export function nextBranchCiPollDelayMs({ checksUnavailable, pr, }: BranchPullRequestStatusSnapshot & { attempt: number }): number | null { - // No PR to trace, or checks we couldn't read — nothing a timer would fix. - if (!pr || checksUnavailable || !checks) return null; + if (!pr || checksUnavailable || !checks) return BRANCH_CI_SAFETY_POLL_MS; if (checks.check_runs.length === 0 && checks.statuses.length === 0) { // CI may not have registered its runs yet; give it a bounded grace period // rather than polling an un-CI'd repository forever. return attempt < BRANCH_CI_EMPTY_POLL_MAX_ATTEMPTS ? BRANCH_CI_EMPTY_POLL_MS - : null; + : BRANCH_CI_SAFETY_POLL_MS; } - if (areChecksSettled(checks)) return null; + if (areChecksSettled(checks)) return BRANCH_CI_SAFETY_POLL_MS; return Math.min(BRANCH_CI_POLL_BASE_MS * 2 ** attempt, BRANCH_CI_POLL_MAX_MS); } diff --git a/src/services/git/operations/createPullRequest.test.ts b/src/services/git/operations/createPullRequest.test.ts new file mode 100644 index 000000000..233332bd8 --- /dev/null +++ b/src/services/git/operations/createPullRequest.test.ts @@ -0,0 +1,74 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { fetchRustApi } from "@src/api/http/git/client"; +import { gitPush } from "@src/api/http/git/operations"; +import { getGitRemotes } from "@src/api/http/git/remotes"; +import { createPRLocal } from "@src/api/tauri/github"; +import { + BRANCH_REMOTE_MUTATION_EVENT, + type BranchRemoteMutationDetail, +} from "@src/util/git/branchRemoteMutation"; + +import { createPullRequest } from "./createPullRequest"; + +vi.mock("@src/api/http/git/client", () => ({ + fetchRustApi: vi.fn(), + gitRepoUrl: (repoId: string) => `/git/repos/${repoId}`, +})); +vi.mock("@src/api/http/git/operations", () => ({ gitPush: vi.fn() })); +vi.mock("@src/api/http/git/remotes", () => ({ getGitRemotes: vi.fn() })); +vi.mock("@src/api/tauri/github", () => ({ createPRLocal: vi.fn() })); + +describe("createPullRequest", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it("invalidates branch status after GitHub creates the PR", async () => { + vi.mocked(getGitRemotes).mockResolvedValue({ + remotes: [ + { + name: "origin", + url: "git@github.com:acme/repo.git", + fetch_url: "git@github.com:acme/repo.git", + push_url: "git@github.com:acme/repo.git", + }, + ], + }); + vi.mocked(gitPush).mockResolvedValue({ success: true } as never); + vi.mocked(fetchRustApi).mockResolvedValue({ + data: { name: "develop" }, + } as never); + vi.mocked(createPRLocal).mockResolvedValue({ + url: "https://github.com/acme/repo/pull/12", + } as never); + const details: BranchRemoteMutationDetail[] = []; + const listener = (event: Event) => { + details.push((event as CustomEvent).detail); + }; + window.addEventListener(BRANCH_REMOTE_MUTATION_EVENT, listener); + + try { + await expect( + createPullRequest({ + repoId: "repo-1", + repoPath: "/repo", + branch: "feature", + title: "Feature", + }) + ).resolves.toEqual({ url: "https://github.com/acme/repo/pull/12" }); + } finally { + window.removeEventListener(BRANCH_REMOTE_MUTATION_EVENT, listener); + } + + expect(details).toEqual([ + { + repoId: "repo-1", + repoPath: "/repo", + branchName: "feature", + reason: "pull-request-created", + }, + ]); + }); +}); diff --git a/src/services/git/operations/createPullRequest.ts b/src/services/git/operations/createPullRequest.ts index 04fb7e637..3a981da4a 100644 --- a/src/services/git/operations/createPullRequest.ts +++ b/src/services/git/operations/createPullRequest.ts @@ -3,6 +3,7 @@ import { gitPush } from "@src/api/http/git/operations"; import { getGitRemotes } from "@src/api/http/git/remotes"; import { createPRLocal } from "@src/api/tauri/github"; import { createLogger } from "@src/hooks/logger"; +import { announceBranchRemoteMutation } from "@src/util/git/branchRemoteMutation"; import { parseRepoFullNameFromRemote } from "@src/util/git/githubRemote"; const logger = createLogger("createPullRequest"); @@ -82,6 +83,13 @@ export async function createPullRequest( baseBranch ); + announceBranchRemoteMutation({ + repoId, + repoPath, + branchName: branch, + reason: "pull-request-created", + }); + return { url: prResponse.url }; } catch (error) { const msg = error instanceof Error ? error.message : String(error); diff --git a/src/util/git/branchRemoteMutation.ts b/src/util/git/branchRemoteMutation.ts new file mode 100644 index 000000000..f64bf9331 --- /dev/null +++ b/src/util/git/branchRemoteMutation.ts @@ -0,0 +1,40 @@ +export const BRANCH_REMOTE_MUTATION_EVENT = "orgii:git-branch-remote-mutation"; + +export interface BranchRemoteMutationDetail { + repoId?: string; + repoPath?: string; + branchName?: string; + reason: "push" | "pull-request-created"; +} + +/** + * Announce that GitHub-visible state for a local branch may have changed. + * Consumers still match repo/branch before refreshing, so a push in another + * workspace cannot wake every status surface in the app. + */ +export function announceBranchRemoteMutation( + detail: BranchRemoteMutationDetail +): void { + if (typeof window === "undefined") return; + window.dispatchEvent( + new CustomEvent(BRANCH_REMOTE_MUTATION_EVENT, { + detail, + }) + ); +} + +export function isMatchingBranchRemoteMutation( + detail: BranchRemoteMutationDetail | undefined, + target: { + repoId: string; + repoPath: string; + branchName: string; + } +): boolean { + if (!detail) return false; + if (detail.repoId && detail.repoId !== target.repoId) return false; + if (detail.repoPath && detail.repoPath !== target.repoPath) return false; + if (detail.branchName && detail.branchName !== target.branchName) + return false; + return true; +}