Prevent unrelated PR merges from archiving workspaces (#3425)
* fix(server): bind auto-archive to workspace PR state Managed worktrees previously derived PR status from whichever branch was currently checked out, so checkout drift could attach an unrelated merged PR to the sidebar and archive flow. Pin PR lookup identity at worktree creation, fail closed on drift, and fan the same fresh PR snapshot shown in the sidebar to every workspace sharing that cwd. * fix(server): serialize shared workspace auto-archive * refactor(server): archive from one fresh git observation
This commit is contained in:
@@ -57,8 +57,9 @@ function createSnapshot(overrides?: {
|
|||||||
diffStat: { additions: 0, deletions: 0 },
|
diffStat: { additions: 0, deletions: 0 },
|
||||||
...overrides?.git,
|
...overrides?.git,
|
||||||
},
|
},
|
||||||
github: {
|
forge: {
|
||||||
featuresEnabled: true,
|
featuresEnabled: true,
|
||||||
|
authState: "authenticated",
|
||||||
pullRequest:
|
pullRequest:
|
||||||
overrides && "pullRequest" in overrides
|
overrides && "pullRequest" in overrides
|
||||||
? (overrides.pullRequest ?? null)
|
? (overrides.pullRequest ?? null)
|
||||||
@@ -78,18 +79,13 @@ function createLogger(): Logger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createHarness(overrides?: {
|
function createHarness(overrides?: {
|
||||||
autoArchiveAfterMerge?: boolean;
|
|
||||||
autoArchivedChangeRequestUrl?: string | null;
|
autoArchivedChangeRequestUrl?: string | null;
|
||||||
getSnapshot?: () => Promise<WorkspaceGitRuntimeSnapshot | null>;
|
snapshot?: WorkspaceGitRuntimeSnapshot;
|
||||||
isPaseoOwnedWorktreeCwd?: ArchiveIfSafeDependencies["isPaseoOwnedWorktreeCwd"];
|
isPaseoOwnedWorktreeCwd?: ArchiveIfSafeDependencies["isPaseoOwnedWorktreeCwd"];
|
||||||
archiveByScope?: ArchiveIfSafeDependencies["archiveByScope"];
|
archiveByScope?: ArchiveIfSafeDependencies["archiveByScope"];
|
||||||
resolveWorkspaceIdAtPath?: ArchiveIfSafeDependencies["resolveWorkspaceIdAtPath"];
|
|
||||||
}) {
|
}) {
|
||||||
const getConfig = vi.fn(() => ({
|
const getSnapshot = vi.fn(async () =>
|
||||||
autoArchiveAfterMerge: overrides?.autoArchiveAfterMerge ?? true,
|
createSnapshot(),
|
||||||
}));
|
|
||||||
const getSnapshot = vi.fn(
|
|
||||||
overrides?.getSnapshot ?? (async () => createSnapshot()),
|
|
||||||
) as unknown as AutoArchiveArchiveOptions["workspaceGitService"]["getSnapshot"];
|
) as unknown as AutoArchiveArchiveOptions["workspaceGitService"]["getSnapshot"];
|
||||||
const workspaceGitService = {
|
const workspaceGitService = {
|
||||||
getSnapshot,
|
getSnapshot,
|
||||||
@@ -97,7 +93,7 @@ function createHarness(overrides?: {
|
|||||||
const options: AutoArchiveArchiveOptions = {
|
const options: AutoArchiveArchiveOptions = {
|
||||||
paseoHome: PASEO_HOME,
|
paseoHome: PASEO_HOME,
|
||||||
daemonConfigStore: {
|
daemonConfigStore: {
|
||||||
get: getConfig,
|
get: () => ({ autoArchiveAfterMerge: true }),
|
||||||
} as unknown as AutoArchiveArchiveOptions["daemonConfigStore"],
|
} as unknown as AutoArchiveArchiveOptions["daemonConfigStore"],
|
||||||
workspaceGitService,
|
workspaceGitService,
|
||||||
github: {} as AutoArchiveArchiveOptions["github"],
|
github: {} as AutoArchiveArchiveOptions["github"],
|
||||||
@@ -123,9 +119,6 @@ function createHarness(overrides?: {
|
|||||||
removedDirectory: false,
|
removedDirectory: false,
|
||||||
}) satisfies ArchiveResult),
|
}) satisfies ArchiveResult),
|
||||||
) as unknown as ArchiveIfSafeDependencies["archiveByScope"];
|
) as unknown as ArchiveIfSafeDependencies["archiveByScope"];
|
||||||
const resolveWorkspaceIdAtPath = vi.fn(
|
|
||||||
overrides?.resolveWorkspaceIdAtPath ?? (async () => "ws-auto-archive"),
|
|
||||||
) as unknown as ArchiveIfSafeDependencies["resolveWorkspaceIdAtPath"];
|
|
||||||
const isPaseoOwnedWorktreeCwd = vi.fn(
|
const isPaseoOwnedWorktreeCwd = vi.fn(
|
||||||
overrides?.isPaseoOwnedWorktreeCwd ??
|
overrides?.isPaseoOwnedWorktreeCwd ??
|
||||||
(async () => ({
|
(async () => ({
|
||||||
@@ -137,37 +130,34 @@ function createHarness(overrides?: {
|
|||||||
) as unknown as ArchiveIfSafeDependencies["isPaseoOwnedWorktreeCwd"];
|
) as unknown as ArchiveIfSafeDependencies["isPaseoOwnedWorktreeCwd"];
|
||||||
const deps: ArchiveIfSafeDependencies = {
|
const deps: ArchiveIfSafeDependencies = {
|
||||||
archiveByScope,
|
archiveByScope,
|
||||||
resolveWorkspaceIdAtPath,
|
|
||||||
isPaseoOwnedWorktreeCwd,
|
isPaseoOwnedWorktreeCwd,
|
||||||
killTerminalsForWorkspace: vi.fn(),
|
killTerminalsForWorkspace: vi.fn(),
|
||||||
};
|
};
|
||||||
const log = createLogger();
|
const log = createLogger();
|
||||||
const inFlight = new Set<string>();
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
deps,
|
deps,
|
||||||
getConfig,
|
|
||||||
getSnapshot,
|
getSnapshot,
|
||||||
inFlight,
|
|
||||||
log,
|
log,
|
||||||
options,
|
options,
|
||||||
|
snapshot: overrides?.snapshot ?? createSnapshot(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runArchiveIfSafe(
|
async function runArchiveIfSafe(
|
||||||
harness: ReturnType<typeof createHarness>,
|
harness: ReturnType<typeof createHarness>,
|
||||||
overrides?: {
|
overrides?: {
|
||||||
cwd?: string;
|
workspaceId?: string;
|
||||||
|
snapshot?: WorkspaceGitRuntimeSnapshot;
|
||||||
pullRequest?: WorkspaceGitRuntimeSnapshot["forge"]["pullRequest"];
|
pullRequest?: WorkspaceGitRuntimeSnapshot["forge"]["pullRequest"];
|
||||||
},
|
},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await archiveIfSafe({
|
await archiveIfSafe({
|
||||||
cwd: overrides?.cwd ?? CWD,
|
workspaceId: overrides?.workspaceId ?? "ws-auto-archive",
|
||||||
pullRequest:
|
snapshot:
|
||||||
overrides && "pullRequest" in overrides
|
overrides?.snapshot ??
|
||||||
? (overrides.pullRequest ?? null)
|
(overrides && "pullRequest" in overrides
|
||||||
: createPullRequest(),
|
? createSnapshot({ pullRequest: overrides.pullRequest ?? null })
|
||||||
inFlight: harness.inFlight,
|
: harness.snapshot),
|
||||||
options: harness.options,
|
options: harness.options,
|
||||||
log: harness.log,
|
log: harness.log,
|
||||||
deps: harness.deps,
|
deps: harness.deps,
|
||||||
@@ -292,8 +282,9 @@ function createRealOutcomeHarness(input: {
|
|||||||
hasRemote: true,
|
hasRemote: true,
|
||||||
diffStat: { additions: 0, deletions: 0 },
|
diffStat: { additions: 0, deletions: 0 },
|
||||||
},
|
},
|
||||||
github: {
|
forge: {
|
||||||
featuresEnabled: true,
|
featuresEnabled: true,
|
||||||
|
authState: "authenticated",
|
||||||
pullRequest: createPullRequest({ isMerged: true }),
|
pullRequest: createPullRequest({ isMerged: true }),
|
||||||
error: null,
|
error: null,
|
||||||
},
|
},
|
||||||
@@ -336,7 +327,6 @@ function createRealOutcomeHarness(input: {
|
|||||||
return {
|
return {
|
||||||
options,
|
options,
|
||||||
log: logger,
|
log: logger,
|
||||||
inFlight: new Set<string>(),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,61 +346,13 @@ describe("archiveIfSafe", () => {
|
|||||||
|
|
||||||
await runArchiveIfSafe(harness, { pullRequest: createPullRequest({ isMerged: false }) });
|
await runArchiveIfSafe(harness, { pullRequest: createPullRequest({ isMerged: false }) });
|
||||||
|
|
||||||
expect(harness.getConfig).not.toHaveBeenCalled();
|
|
||||||
expect(harness.getSnapshot).not.toHaveBeenCalled();
|
expect(harness.getSnapshot).not.toHaveBeenCalled();
|
||||||
expect(harness.deps.archiveByScope).not.toHaveBeenCalled();
|
expect(harness.deps.archiveByScope).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("does nothing when auto-archive-after-merge is disabled", async () => {
|
|
||||||
const harness = createHarness({ autoArchiveAfterMerge: false });
|
|
||||||
|
|
||||||
await runArchiveIfSafe(harness);
|
|
||||||
|
|
||||||
expect(harness.getConfig).toHaveBeenCalledTimes(1);
|
|
||||||
expect(harness.getSnapshot).not.toHaveBeenCalled();
|
|
||||||
expect(harness.deps.archiveByScope).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("does nothing when the cwd already has an archive in flight", async () => {
|
|
||||||
const harness = createHarness();
|
|
||||||
harness.inFlight.add(CWD);
|
|
||||||
|
|
||||||
await runArchiveIfSafe(harness);
|
|
||||||
|
|
||||||
expect(harness.getSnapshot).not.toHaveBeenCalled();
|
|
||||||
expect(harness.deps.archiveByScope).not.toHaveBeenCalled();
|
|
||||||
expect(harness.inFlight.has(CWD)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("logs and skips when reading the snapshot fails", async () => {
|
|
||||||
const harness = createHarness({
|
|
||||||
getSnapshot: async () => {
|
|
||||||
throw new Error("snapshot failed");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await runArchiveIfSafe(harness);
|
|
||||||
|
|
||||||
expect(harness.log.warn).toHaveBeenCalledWith(
|
|
||||||
{ err: expect.any(Error), cwd: CWD },
|
|
||||||
"Failed to read snapshot for auto-archive; skipping",
|
|
||||||
);
|
|
||||||
expect(harness.deps.archiveByScope).not.toHaveBeenCalled();
|
|
||||||
expect(harness.inFlight.has(CWD)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("does nothing when there is no snapshot", async () => {
|
|
||||||
const harness = createHarness({ getSnapshot: async () => null });
|
|
||||||
|
|
||||||
await runArchiveIfSafe(harness);
|
|
||||||
|
|
||||||
expect(harness.deps.isPaseoOwnedWorktreeCwd).not.toHaveBeenCalled();
|
|
||||||
expect(harness.deps.archiveByScope).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("does nothing when the worktree is dirty", async () => {
|
test("does nothing when the worktree is dirty", async () => {
|
||||||
const harness = createHarness({
|
const harness = createHarness({
|
||||||
getSnapshot: async () => createSnapshot({ git: { isDirty: true } }),
|
snapshot: createSnapshot({ git: { isDirty: true } }),
|
||||||
});
|
});
|
||||||
|
|
||||||
await runArchiveIfSafe(harness);
|
await runArchiveIfSafe(harness);
|
||||||
@@ -421,7 +363,7 @@ describe("archiveIfSafe", () => {
|
|||||||
|
|
||||||
test("does nothing when the worktree is ahead of origin", async () => {
|
test("does nothing when the worktree is ahead of origin", async () => {
|
||||||
const harness = createHarness({
|
const harness = createHarness({
|
||||||
getSnapshot: async () => createSnapshot({ git: { aheadOfOrigin: 1 } }),
|
snapshot: createSnapshot({ git: { aheadOfOrigin: 1 } }),
|
||||||
});
|
});
|
||||||
|
|
||||||
await runArchiveIfSafe(harness);
|
await runArchiveIfSafe(harness);
|
||||||
@@ -432,8 +374,7 @@ describe("archiveIfSafe", () => {
|
|||||||
|
|
||||||
test("archives when the PR is merged and the upstream branch was deleted", async () => {
|
test("archives when the PR is merged and the upstream branch was deleted", async () => {
|
||||||
const harness = createHarness({
|
const harness = createHarness({
|
||||||
getSnapshot: async () =>
|
snapshot: createSnapshot({ git: { aheadOfOrigin: null, behindOfOrigin: null } }),
|
||||||
createSnapshot({ git: { aheadOfOrigin: null, behindOfOrigin: null } }),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await runArchiveIfSafe(harness);
|
await runArchiveIfSafe(harness);
|
||||||
@@ -467,7 +408,6 @@ describe("archiveIfSafe", () => {
|
|||||||
{ err: expect.any(Error), cwd: CWD },
|
{ err: expect.any(Error), cwd: CWD },
|
||||||
"Auto-archive after merge failed",
|
"Auto-archive after merge failed",
|
||||||
);
|
);
|
||||||
expect(harness.inFlight.has(CWD)).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("archives a clean Paseo-owned worktree after merge", async () => {
|
test("archives a clean Paseo-owned worktree after merge", async () => {
|
||||||
@@ -475,14 +415,6 @@ describe("archiveIfSafe", () => {
|
|||||||
|
|
||||||
await runArchiveIfSafe(harness);
|
await runArchiveIfSafe(harness);
|
||||||
|
|
||||||
expect(harness.deps.resolveWorkspaceIdAtPath).toHaveBeenCalledTimes(1);
|
|
||||||
expect(harness.deps.resolveWorkspaceIdAtPath).toHaveBeenCalledWith(
|
|
||||||
{
|
|
||||||
findWorkspaceIdForCwd: harness.options.findWorkspaceIdForCwd,
|
|
||||||
listActiveWorkspaces: harness.options.listActiveWorkspaces,
|
|
||||||
},
|
|
||||||
CWD,
|
|
||||||
);
|
|
||||||
expect(harness.deps.archiveByScope).toHaveBeenCalledTimes(1);
|
expect(harness.deps.archiveByScope).toHaveBeenCalledTimes(1);
|
||||||
expect(harness.deps.archiveByScope).toHaveBeenCalledWith(
|
expect(harness.deps.archiveByScope).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@@ -495,10 +427,14 @@ describe("archiveIfSafe", () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
expect(harness.log.info).toHaveBeenCalledWith(
|
expect(harness.log.info).toHaveBeenCalledWith(
|
||||||
{ cwd: CWD },
|
{
|
||||||
|
workspaceId: "ws-auto-archive",
|
||||||
|
cwd: CWD,
|
||||||
|
branch: "feature",
|
||||||
|
pullRequestUrl: "https://github.com/acme/repo/pull/123",
|
||||||
|
},
|
||||||
"Auto-archived worktree after PR merge",
|
"Auto-archived worktree after PR merge",
|
||||||
);
|
);
|
||||||
expect(harness.inFlight.has(CWD)).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("does not archive a merge event already consumed by this workspace", async () => {
|
test("does not archive a merge event already consumed by this workspace", async () => {
|
||||||
@@ -540,18 +476,15 @@ describe("archiveIfSafe", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("resolves the merged cwd to a single workspace and does not iterate siblings", async () => {
|
test("archives only the supplied workspace id and does not iterate siblings", async () => {
|
||||||
const harness = createHarness({
|
const harness = createHarness();
|
||||||
resolveWorkspaceIdAtPath: async () => "ws-merged-worktree",
|
|
||||||
});
|
|
||||||
harness.options.listActiveWorkspaces = vi.fn(async () => [
|
harness.options.listActiveWorkspaces = vi.fn(async () => [
|
||||||
{ workspaceId: "ws-merged-worktree", cwd: CWD, kind: "worktree" as const },
|
{ workspaceId: "ws-merged-worktree", cwd: CWD, kind: "worktree" as const },
|
||||||
{ workspaceId: "ws-sibling", cwd: CWD, kind: "local_checkout" as const },
|
{ workspaceId: "ws-sibling", cwd: CWD, kind: "local_checkout" as const },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await runArchiveIfSafe(harness);
|
await runArchiveIfSafe(harness, { workspaceId: "ws-merged-worktree" });
|
||||||
|
|
||||||
expect(harness.deps.resolveWorkspaceIdAtPath).toHaveBeenCalledTimes(1);
|
|
||||||
expect(harness.deps.archiveByScope).toHaveBeenCalledTimes(1);
|
expect(harness.deps.archiveByScope).toHaveBeenCalledTimes(1);
|
||||||
expect(harness.deps.archiveByScope).toHaveBeenCalledWith(
|
expect(harness.deps.archiveByScope).toHaveBeenCalledWith(
|
||||||
expect.any(Object),
|
expect.any(Object),
|
||||||
@@ -581,9 +514,8 @@ describe("archiveIfSafe", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await archiveIfSafe({
|
await archiveIfSafe({
|
||||||
cwd: worktree.worktreePath,
|
workspaceId: workspaceA,
|
||||||
pullRequest: createPullRequest({ isMerged: true }),
|
snapshot: { ...createSnapshot(), cwd: worktree.worktreePath },
|
||||||
inFlight: harness.inFlight,
|
|
||||||
options: harness.options,
|
options: harness.options,
|
||||||
log: harness.log,
|
log: harness.log,
|
||||||
});
|
});
|
||||||
@@ -609,9 +541,8 @@ describe("archiveIfSafe", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await archiveIfSafe({
|
await archiveIfSafe({
|
||||||
cwd: worktree.worktreePath,
|
workspaceId: workspaceA,
|
||||||
pullRequest: createPullRequest({ isMerged: true }),
|
snapshot: { ...createSnapshot(), cwd: worktree.worktreePath },
|
||||||
inFlight: harness.inFlight,
|
|
||||||
options: harness.options,
|
options: harness.options,
|
||||||
log: harness.log,
|
log: harness.log,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
archiveByScope,
|
archiveByScope,
|
||||||
type ActiveWorkspaceRef,
|
type ActiveWorkspaceRef,
|
||||||
killTerminalsForWorkspace,
|
killTerminalsForWorkspace,
|
||||||
resolveWorkspaceIdAtPath,
|
|
||||||
} from "../workspace-archive-service.js";
|
} from "../workspace-archive-service.js";
|
||||||
import type {
|
import type {
|
||||||
WorkspaceGitRuntimeSnapshot,
|
WorkspaceGitRuntimeSnapshot,
|
||||||
@@ -38,124 +37,89 @@ export interface AutoArchiveArchiveOptions {
|
|||||||
|
|
||||||
export interface ArchiveIfSafeDependencies {
|
export interface ArchiveIfSafeDependencies {
|
||||||
archiveByScope: typeof archiveByScope;
|
archiveByScope: typeof archiveByScope;
|
||||||
resolveWorkspaceIdAtPath: typeof resolveWorkspaceIdAtPath;
|
|
||||||
isPaseoOwnedWorktreeCwd: typeof isPaseoOwnedWorktreeCwd;
|
isPaseoOwnedWorktreeCwd: typeof isPaseoOwnedWorktreeCwd;
|
||||||
killTerminalsForWorkspace: typeof killTerminalsForWorkspace;
|
killTerminalsForWorkspace: typeof killTerminalsForWorkspace;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultDependencies: ArchiveIfSafeDependencies = {
|
const defaultDependencies: ArchiveIfSafeDependencies = {
|
||||||
archiveByScope,
|
archiveByScope,
|
||||||
resolveWorkspaceIdAtPath,
|
|
||||||
isPaseoOwnedWorktreeCwd,
|
isPaseoOwnedWorktreeCwd,
|
||||||
killTerminalsForWorkspace,
|
killTerminalsForWorkspace,
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function archiveIfSafe(input: {
|
export async function archiveIfSafe(input: {
|
||||||
cwd: string;
|
workspaceId: string;
|
||||||
pullRequest: WorkspaceGitRuntimeSnapshot["forge"]["pullRequest"];
|
snapshot: WorkspaceGitRuntimeSnapshot;
|
||||||
inFlight: Set<string>;
|
|
||||||
options: AutoArchiveArchiveOptions;
|
options: AutoArchiveArchiveOptions;
|
||||||
log: Logger;
|
log: Logger;
|
||||||
deps?: ArchiveIfSafeDependencies;
|
deps?: ArchiveIfSafeDependencies;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const { cwd, pullRequest, inFlight, options, log } = input;
|
const { workspaceId, snapshot, options, log } = input;
|
||||||
const deps = input.deps ?? defaultDependencies;
|
const deps = input.deps ?? defaultDependencies;
|
||||||
|
const cwd = snapshot.cwd;
|
||||||
|
const pullRequest = snapshot.forge.pullRequest;
|
||||||
|
|
||||||
if (!pullRequest?.isMerged) {
|
if (!pullRequest?.isMerged) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (options.daemonConfigStore.get().autoArchiveAfterMerge !== true) {
|
if (snapshot.git.isDirty === true) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (inFlight.has(cwd)) {
|
if (typeof snapshot.git.aheadOfOrigin === "number" && snapshot.git.aheadOfOrigin > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ownership = await deps.isPaseoOwnedWorktreeCwd(cwd, {
|
||||||
|
paseoHome: options.paseoHome,
|
||||||
|
worktreesRoot: options.paseoWorktreesBaseRoot,
|
||||||
|
});
|
||||||
|
if (!ownership.allowed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
inFlight.add(cwd);
|
|
||||||
try {
|
try {
|
||||||
let snapshot: Awaited<ReturnType<typeof options.workspaceGitService.getSnapshot>> | null;
|
const autoArchivedChangeRequestUrl = await options.getAutoArchivedChangeRequestUrl(workspaceId);
|
||||||
try {
|
if (autoArchivedChangeRequestUrl === pullRequest.url) {
|
||||||
snapshot = await options.workspaceGitService.getSnapshot(cwd, {
|
|
||||||
reason: "auto-archive-on-merge",
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
log.warn({ err: error, cwd }, "Failed to read snapshot for auto-archive; skipping");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!snapshot) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (snapshot.git.isDirty === true) {
|
await deps.archiveByScope(
|
||||||
return;
|
{
|
||||||
}
|
paseoHome: options.paseoHome,
|
||||||
if (typeof snapshot.git.aheadOfOrigin === "number" && snapshot.git.aheadOfOrigin > 0) {
|
paseoWorktreesBaseRoot: options.paseoWorktreesBaseRoot,
|
||||||
return;
|
github: options.github,
|
||||||
}
|
workspaceGitService: options.workspaceGitService,
|
||||||
|
agentManager: options.agentManager,
|
||||||
const ownership = await deps.isPaseoOwnedWorktreeCwd(cwd, {
|
agentStorage: options.agentStorage,
|
||||||
paseoHome: options.paseoHome,
|
findWorkspaceIdForCwd: options.findWorkspaceIdForCwd,
|
||||||
worktreesRoot: options.paseoWorktreesBaseRoot,
|
listActiveWorkspaces: options.listActiveWorkspaces,
|
||||||
});
|
archiveWorkspaceRecord: (workspaceIdToArchive) =>
|
||||||
if (!ownership.allowed) {
|
options.archiveWorkspaceRecord(workspaceIdToArchive, {
|
||||||
return;
|
autoArchivedChangeRequestUrl: pullRequest.url,
|
||||||
}
|
}),
|
||||||
|
emitWorkspaceUpdatesForWorkspaceIds: options.emitWorkspaceUpdatesForWorkspaceIds,
|
||||||
try {
|
markWorkspaceArchiving: options.markWorkspaceArchiving,
|
||||||
const workspaceId = await deps.resolveWorkspaceIdAtPath(
|
clearWorkspaceArchiving: options.clearWorkspaceArchiving,
|
||||||
{
|
killTerminalsForWorkspace: (workspaceIdToKill) =>
|
||||||
findWorkspaceIdForCwd: options.findWorkspaceIdForCwd,
|
deps.killTerminalsForWorkspace(
|
||||||
listActiveWorkspaces: options.listActiveWorkspaces,
|
{
|
||||||
},
|
terminalManager: options.terminalManager,
|
||||||
cwd,
|
sessionLogger: log,
|
||||||
);
|
},
|
||||||
if (!workspaceId) {
|
workspaceIdToKill,
|
||||||
log.warn({ cwd }, "Auto-archive could not resolve a workspace for cwd; skipping");
|
),
|
||||||
return;
|
sessionLogger: log,
|
||||||
}
|
},
|
||||||
const autoArchivedChangeRequestUrl =
|
{
|
||||||
await options.getAutoArchivedChangeRequestUrl(workspaceId);
|
scope: { kind: "workspace", workspaceId },
|
||||||
if (autoArchivedChangeRequestUrl === pullRequest.url) {
|
requestId: "auto-archive-on-merge",
|
||||||
return;
|
},
|
||||||
}
|
);
|
||||||
|
log.info(
|
||||||
await deps.archiveByScope(
|
{ workspaceId, cwd, branch: pullRequest.headRefName, pullRequestUrl: pullRequest.url },
|
||||||
{
|
"Auto-archived worktree after PR merge",
|
||||||
paseoHome: options.paseoHome,
|
);
|
||||||
paseoWorktreesBaseRoot: options.paseoWorktreesBaseRoot,
|
} catch (error) {
|
||||||
github: options.github,
|
log.warn({ err: error, cwd }, "Auto-archive after merge failed");
|
||||||
workspaceGitService: options.workspaceGitService,
|
|
||||||
agentManager: options.agentManager,
|
|
||||||
agentStorage: options.agentStorage,
|
|
||||||
findWorkspaceIdForCwd: options.findWorkspaceIdForCwd,
|
|
||||||
listActiveWorkspaces: options.listActiveWorkspaces,
|
|
||||||
archiveWorkspaceRecord: (workspaceIdToArchive) =>
|
|
||||||
options.archiveWorkspaceRecord(workspaceIdToArchive, {
|
|
||||||
autoArchivedChangeRequestUrl: pullRequest.url,
|
|
||||||
}),
|
|
||||||
emitWorkspaceUpdatesForWorkspaceIds: options.emitWorkspaceUpdatesForWorkspaceIds,
|
|
||||||
markWorkspaceArchiving: options.markWorkspaceArchiving,
|
|
||||||
clearWorkspaceArchiving: options.clearWorkspaceArchiving,
|
|
||||||
killTerminalsForWorkspace: (workspaceIdToKill) =>
|
|
||||||
deps.killTerminalsForWorkspace(
|
|
||||||
{
|
|
||||||
terminalManager: options.terminalManager,
|
|
||||||
sessionLogger: log,
|
|
||||||
},
|
|
||||||
workspaceIdToKill,
|
|
||||||
),
|
|
||||||
sessionLogger: log,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
scope: { kind: "workspace", workspaceId },
|
|
||||||
requestId: "auto-archive-on-merge",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
log.info({ cwd }, "Auto-archived worktree after PR merge");
|
|
||||||
} catch (error) {
|
|
||||||
log.warn({ err: error, cwd }, "Auto-archive after merge failed");
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
inFlight.delete(cwd);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
import { resolve } from "node:path";
|
||||||
|
import type { Logger } from "pino";
|
||||||
|
import { expect, test, vi } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
setupAutoArchiveOnMerge,
|
||||||
|
type AutoArchiveOnMergeDependencies,
|
||||||
|
type AutoArchiveOnMergeOptions,
|
||||||
|
} from "./index.js";
|
||||||
|
import type { WorkspaceGitRuntimeSnapshot } from "../workspace-git-service.js";
|
||||||
|
|
||||||
|
function createSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot {
|
||||||
|
return {
|
||||||
|
cwd,
|
||||||
|
git: {
|
||||||
|
isGit: true,
|
||||||
|
repoRoot: cwd,
|
||||||
|
mainRepoRoot: "/repo",
|
||||||
|
currentBranch: "feature",
|
||||||
|
remoteUrl: "https://github.com/acme/repo.git",
|
||||||
|
isPaseoOwnedWorktree: true,
|
||||||
|
isDirty: false,
|
||||||
|
baseRef: "main",
|
||||||
|
aheadBehind: { ahead: 0, behind: 0 },
|
||||||
|
aheadOfOrigin: 0,
|
||||||
|
behindOfOrigin: 0,
|
||||||
|
hasRemote: true,
|
||||||
|
diffStat: { additions: 0, deletions: 0 },
|
||||||
|
},
|
||||||
|
forge: {
|
||||||
|
featuresEnabled: true,
|
||||||
|
authState: "authenticated",
|
||||||
|
pullRequest: {
|
||||||
|
url: "https://github.com/acme/repo/pull/12",
|
||||||
|
title: "Merged",
|
||||||
|
state: "merged",
|
||||||
|
baseRefName: "main",
|
||||||
|
headRefName: "feature",
|
||||||
|
isMerged: true,
|
||||||
|
},
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("fans one fresh observation out to every workspace attached to its exact cwd", async () => {
|
||||||
|
let onSnapshotUpdated: ((snapshot: WorkspaceGitRuntimeSnapshot) => void) | null = null;
|
||||||
|
const eventSnapshot = createSnapshot("/repo/worktree/.");
|
||||||
|
const freshSnapshot = createSnapshot("/repo/worktree");
|
||||||
|
if (freshSnapshot.forge.pullRequest) {
|
||||||
|
freshSnapshot.forge.pullRequest.url = "https://github.com/acme/repo/pull/13";
|
||||||
|
}
|
||||||
|
const getSnapshot = vi.fn(async () => freshSnapshot);
|
||||||
|
const options = {
|
||||||
|
logger: { child: () => ({ warn: vi.fn() }) } as unknown as Logger,
|
||||||
|
daemonConfigStore: { get: () => ({ autoArchiveAfterMerge: true }) },
|
||||||
|
workspaceGitService: {
|
||||||
|
onSnapshotUpdated: (listener: (next: WorkspaceGitRuntimeSnapshot) => void) => {
|
||||||
|
onSnapshotUpdated = listener;
|
||||||
|
return { unsubscribe: vi.fn() };
|
||||||
|
},
|
||||||
|
getSnapshot,
|
||||||
|
},
|
||||||
|
listActiveWorkspaces: async () => [
|
||||||
|
{ workspaceId: "workspace-a", cwd: "/repo/worktree" },
|
||||||
|
{ workspaceId: "workspace-b", cwd: "/repo/worktree/child/.." },
|
||||||
|
{ workspaceId: "workspace-other", cwd: "/repo/other" },
|
||||||
|
],
|
||||||
|
} as unknown as AutoArchiveOnMergeOptions;
|
||||||
|
const calls: Array<{ workspaceId: string; pullRequest: unknown }> = [];
|
||||||
|
let resolveFinished: (() => void) | null = null;
|
||||||
|
const finished = new Promise<void>((resolvePromise) => {
|
||||||
|
resolveFinished = resolvePromise;
|
||||||
|
});
|
||||||
|
const deps: AutoArchiveOnMergeDependencies = {
|
||||||
|
archiveIfSafe: async (input) => {
|
||||||
|
calls.push({
|
||||||
|
workspaceId: input.workspaceId,
|
||||||
|
pullRequest: input.snapshot.forge.pullRequest,
|
||||||
|
});
|
||||||
|
if (calls.length === 2) resolveFinished?.();
|
||||||
|
},
|
||||||
|
resolvePath: resolve,
|
||||||
|
};
|
||||||
|
|
||||||
|
setupAutoArchiveOnMerge(options, deps);
|
||||||
|
if (!onSnapshotUpdated) throw new Error("Snapshot listener was not registered");
|
||||||
|
onSnapshotUpdated(eventSnapshot);
|
||||||
|
await finished;
|
||||||
|
|
||||||
|
expect(getSnapshot).toHaveBeenCalledTimes(1);
|
||||||
|
expect(calls).toEqual([
|
||||||
|
{ workspaceId: "workspace-a", pullRequest: freshSnapshot.forge.pullRequest },
|
||||||
|
{ workspaceId: "workspace-b", pullRequest: freshSnapshot.forge.pullRequest },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("serializes the complete fan-out for duplicate merge events on one cwd", async () => {
|
||||||
|
let onSnapshotUpdated: ((snapshot: WorkspaceGitRuntimeSnapshot) => void) | null = null;
|
||||||
|
const snapshot = createSnapshot("/repo/worktree/.");
|
||||||
|
const options = {
|
||||||
|
logger: { child: () => ({ warn: vi.fn() }) } as unknown as Logger,
|
||||||
|
daemonConfigStore: { get: () => ({ autoArchiveAfterMerge: true }) },
|
||||||
|
workspaceGitService: {
|
||||||
|
onSnapshotUpdated: (listener: (next: WorkspaceGitRuntimeSnapshot) => void) => {
|
||||||
|
onSnapshotUpdated = listener;
|
||||||
|
return { unsubscribe: vi.fn() };
|
||||||
|
},
|
||||||
|
getSnapshot: async () => snapshot,
|
||||||
|
},
|
||||||
|
listActiveWorkspaces: async () => [
|
||||||
|
{ workspaceId: "workspace-a", cwd: "/repo/worktree" },
|
||||||
|
{ workspaceId: "workspace-b", cwd: "/repo/worktree/child/.." },
|
||||||
|
],
|
||||||
|
} as unknown as AutoArchiveOnMergeOptions;
|
||||||
|
const archivedWorkspaceIds: string[] = [];
|
||||||
|
let releaseFirstArchive: (() => void) | null = null;
|
||||||
|
const firstArchivePaused = new Promise<void>((resolvePromise) => {
|
||||||
|
releaseFirstArchive = resolvePromise;
|
||||||
|
});
|
||||||
|
let resolveFinished: (() => void) | null = null;
|
||||||
|
const finished = new Promise<void>((resolvePromise) => {
|
||||||
|
resolveFinished = resolvePromise;
|
||||||
|
});
|
||||||
|
const deps: AutoArchiveOnMergeDependencies = {
|
||||||
|
archiveIfSafe: async (input) => {
|
||||||
|
archivedWorkspaceIds.push(input.workspaceId);
|
||||||
|
if (input.workspaceId === "workspace-a") {
|
||||||
|
await firstArchivePaused;
|
||||||
|
}
|
||||||
|
if (archivedWorkspaceIds.length === 2) resolveFinished?.();
|
||||||
|
},
|
||||||
|
resolvePath: resolve,
|
||||||
|
};
|
||||||
|
|
||||||
|
setupAutoArchiveOnMerge(options, deps);
|
||||||
|
if (!onSnapshotUpdated) throw new Error("Snapshot listener was not registered");
|
||||||
|
onSnapshotUpdated(snapshot);
|
||||||
|
await vi.waitFor(() => expect(archivedWorkspaceIds).toEqual(["workspace-a"]));
|
||||||
|
|
||||||
|
onSnapshotUpdated(createSnapshot("/repo/worktree/child/.."));
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(archivedWorkspaceIds).toEqual(["workspace-a"]);
|
||||||
|
|
||||||
|
releaseFirstArchive?.();
|
||||||
|
await finished;
|
||||||
|
expect(archivedWorkspaceIds).toEqual(["workspace-a", "workspace-b"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not fan out a stale merged event when the fresh observation has no PR", async () => {
|
||||||
|
let onSnapshotUpdated: ((snapshot: WorkspaceGitRuntimeSnapshot) => void) | null = null;
|
||||||
|
const eventSnapshot = createSnapshot("/repo/worktree");
|
||||||
|
const freshSnapshot = createSnapshot("/repo/worktree");
|
||||||
|
freshSnapshot.forge.pullRequest = null;
|
||||||
|
const archiveIfSafe = vi.fn();
|
||||||
|
const getSnapshot = vi.fn(async () => freshSnapshot);
|
||||||
|
const options = {
|
||||||
|
logger: { child: () => ({ warn: vi.fn() }) } as unknown as Logger,
|
||||||
|
daemonConfigStore: { get: () => ({ autoArchiveAfterMerge: true }) },
|
||||||
|
workspaceGitService: {
|
||||||
|
onSnapshotUpdated: (listener: (next: WorkspaceGitRuntimeSnapshot) => void) => {
|
||||||
|
onSnapshotUpdated = listener;
|
||||||
|
return { unsubscribe: vi.fn() };
|
||||||
|
},
|
||||||
|
getSnapshot,
|
||||||
|
},
|
||||||
|
listActiveWorkspaces: vi.fn(async () => [
|
||||||
|
{ workspaceId: "workspace-a", cwd: "/repo/worktree" },
|
||||||
|
]),
|
||||||
|
} as unknown as AutoArchiveOnMergeOptions;
|
||||||
|
|
||||||
|
setupAutoArchiveOnMerge(options, { archiveIfSafe, resolvePath: resolve });
|
||||||
|
if (!onSnapshotUpdated) throw new Error("Snapshot listener was not registered");
|
||||||
|
onSnapshotUpdated(eventSnapshot);
|
||||||
|
await vi.waitFor(() => expect(getSnapshot).toHaveBeenCalledTimes(1));
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(archiveIfSafe).not.toHaveBeenCalled();
|
||||||
|
expect(options.listActiveWorkspaces).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("logs and skips when the fresh observation cannot be read", async () => {
|
||||||
|
let onSnapshotUpdated: ((snapshot: WorkspaceGitRuntimeSnapshot) => void) | null = null;
|
||||||
|
const warn = vi.fn();
|
||||||
|
const archiveIfSafe = vi.fn();
|
||||||
|
const options = {
|
||||||
|
logger: { child: () => ({ warn }) } as unknown as Logger,
|
||||||
|
daemonConfigStore: { get: () => ({ autoArchiveAfterMerge: true }) },
|
||||||
|
workspaceGitService: {
|
||||||
|
onSnapshotUpdated: (listener: (next: WorkspaceGitRuntimeSnapshot) => void) => {
|
||||||
|
onSnapshotUpdated = listener;
|
||||||
|
return { unsubscribe: vi.fn() };
|
||||||
|
},
|
||||||
|
getSnapshot: async () => {
|
||||||
|
throw new Error("snapshot failed");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
listActiveWorkspaces: vi.fn(),
|
||||||
|
} as unknown as AutoArchiveOnMergeOptions;
|
||||||
|
|
||||||
|
setupAutoArchiveOnMerge(options, { archiveIfSafe, resolvePath: resolve });
|
||||||
|
if (!onSnapshotUpdated) throw new Error("Snapshot listener was not registered");
|
||||||
|
onSnapshotUpdated(createSnapshot("/repo/worktree"));
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(warn).toHaveBeenCalledWith(
|
||||||
|
{ err: expect.any(Error), cwd: "/repo/worktree" },
|
||||||
|
"Failed to read snapshot for auto-archive; skipping",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(archiveIfSafe).not.toHaveBeenCalled();
|
||||||
|
expect(options.listActiveWorkspaces).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not read an observation when auto-archive is disabled", async () => {
|
||||||
|
let onSnapshotUpdated: ((snapshot: WorkspaceGitRuntimeSnapshot) => void) | null = null;
|
||||||
|
const getSnapshot = vi.fn();
|
||||||
|
const archiveIfSafe = vi.fn();
|
||||||
|
const options = {
|
||||||
|
logger: { child: () => ({ warn: vi.fn() }) } as unknown as Logger,
|
||||||
|
daemonConfigStore: { get: () => ({ autoArchiveAfterMerge: false }) },
|
||||||
|
workspaceGitService: {
|
||||||
|
onSnapshotUpdated: (listener: (next: WorkspaceGitRuntimeSnapshot) => void) => {
|
||||||
|
onSnapshotUpdated = listener;
|
||||||
|
return { unsubscribe: vi.fn() };
|
||||||
|
},
|
||||||
|
getSnapshot,
|
||||||
|
},
|
||||||
|
listActiveWorkspaces: vi.fn(),
|
||||||
|
} as unknown as AutoArchiveOnMergeOptions;
|
||||||
|
|
||||||
|
setupAutoArchiveOnMerge(options, { archiveIfSafe, resolvePath: resolve });
|
||||||
|
if (!onSnapshotUpdated) throw new Error("Snapshot listener was not registered");
|
||||||
|
onSnapshotUpdated(createSnapshot("/repo/worktree"));
|
||||||
|
expect(getSnapshot).not.toHaveBeenCalled();
|
||||||
|
expect(archiveIfSafe).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
@@ -1,25 +1,81 @@
|
|||||||
|
import { resolve } from "node:path";
|
||||||
import type { Logger } from "pino";
|
import type { Logger } from "pino";
|
||||||
|
|
||||||
import { archiveIfSafe, type AutoArchiveArchiveOptions } from "./archive-if-safe.js";
|
import { archiveIfSafe, type AutoArchiveArchiveOptions } from "./archive-if-safe.js";
|
||||||
import type { WorkspaceGitSubscription } from "../workspace-git-service.js";
|
import type {
|
||||||
|
WorkspaceGitRuntimeSnapshot,
|
||||||
|
WorkspaceGitSubscription,
|
||||||
|
} from "../workspace-git-service.js";
|
||||||
|
|
||||||
export interface AutoArchiveOnMergeOptions extends AutoArchiveArchiveOptions {
|
export interface AutoArchiveOnMergeOptions extends AutoArchiveArchiveOptions {
|
||||||
logger: Logger;
|
logger: Logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AutoArchiveOnMergeDependencies {
|
||||||
|
archiveIfSafe: typeof archiveIfSafe;
|
||||||
|
resolvePath: typeof resolve;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultDependencies: AutoArchiveOnMergeDependencies = {
|
||||||
|
archiveIfSafe,
|
||||||
|
resolvePath: resolve,
|
||||||
|
};
|
||||||
|
|
||||||
export function setupAutoArchiveOnMerge(
|
export function setupAutoArchiveOnMerge(
|
||||||
options: AutoArchiveOnMergeOptions,
|
options: AutoArchiveOnMergeOptions,
|
||||||
|
deps: AutoArchiveOnMergeDependencies = defaultDependencies,
|
||||||
): WorkspaceGitSubscription {
|
): WorkspaceGitSubscription {
|
||||||
const log = options.logger.child({ module: "auto-archive-on-merge" });
|
const log = options.logger.child({ module: "auto-archive-on-merge" });
|
||||||
const inFlight = new Set<string>();
|
const inFlightCwds = new Set<string>();
|
||||||
|
|
||||||
return options.workspaceGitService.onSnapshotUpdated((snapshot) => {
|
return options.workspaceGitService.onSnapshotUpdated((snapshot) => {
|
||||||
void archiveIfSafe({
|
if (!snapshot.forge.pullRequest?.isMerged) {
|
||||||
cwd: snapshot.cwd,
|
return;
|
||||||
pullRequest: snapshot.forge.pullRequest,
|
}
|
||||||
inFlight,
|
if (options.daemonConfigStore.get().autoArchiveAfterMerge !== true) {
|
||||||
options,
|
return;
|
||||||
log,
|
}
|
||||||
});
|
|
||||||
|
const snapshotCwd = deps.resolvePath(snapshot.cwd);
|
||||||
|
if (inFlightCwds.has(snapshotCwd)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
inFlightCwds.add(snapshotCwd);
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
let freshSnapshot: WorkspaceGitRuntimeSnapshot | null;
|
||||||
|
try {
|
||||||
|
freshSnapshot = await options.workspaceGitService.getSnapshot(snapshot.cwd, {
|
||||||
|
reason: "auto-archive-on-merge",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
log.warn(
|
||||||
|
{ err: error, cwd: snapshot.cwd },
|
||||||
|
"Failed to read snapshot for auto-archive; skipping",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!freshSnapshot?.forge.pullRequest?.isMerged) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachedWorkspaces = (await options.listActiveWorkspaces()).filter(
|
||||||
|
(workspace) => deps.resolvePath(workspace.cwd) === snapshotCwd,
|
||||||
|
);
|
||||||
|
for (const workspace of attachedWorkspaces) {
|
||||||
|
await deps.archiveIfSafe({
|
||||||
|
workspaceId: workspace.workspaceId,
|
||||||
|
snapshot: freshSnapshot,
|
||||||
|
options,
|
||||||
|
log,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
.catch((error) => {
|
||||||
|
log.warn({ err: error, cwd: snapshot.cwd }, "Failed to auto-archive attached workspaces");
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
inFlightCwds.delete(snapshotCwd);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+189
@@ -0,0 +1,189 @@
|
|||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import { createWorktree } from "../../utils/worktree.js";
|
||||||
|
import {
|
||||||
|
createDaemonTestContext,
|
||||||
|
createTempGithubRepoName,
|
||||||
|
type DaemonTestContext,
|
||||||
|
} from "../test-utils/index.js";
|
||||||
|
|
||||||
|
const cleanupContexts = new Set<DaemonTestContext>();
|
||||||
|
const cleanupPaths = new Set<string>();
|
||||||
|
const cleanupRepos = new Set<string>();
|
||||||
|
|
||||||
|
function run(cwd: string, command: string, args: string[]): string {
|
||||||
|
return execFileSync(command, args, { cwd, stdio: "pipe" }).toString().trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasGitHubTestAccess(): boolean {
|
||||||
|
try {
|
||||||
|
const auth = run(process.cwd(), "gh", ["auth", "status", "-h", "github.com"]);
|
||||||
|
return auth.length >= 0;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const githubTest = hasGitHubTestAccess() ? test : test.skip;
|
||||||
|
|
||||||
|
async function activeWorkspaceIds(context: DaemonTestContext): Promise<string[]> {
|
||||||
|
return (await context.client.fetchWorkspaces()).entries.map((workspace) => workspace.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForWorkspaceToArchive(
|
||||||
|
context: DaemonTestContext,
|
||||||
|
workspaceId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const deadline = Date.now() + 15_000;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if (!(await activeWorkspaceIds(context)).includes(workspaceId)) return;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||||
|
}
|
||||||
|
throw new Error(`Workspace ${workspaceId} was not archived`);
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(
|
||||||
|
Array.from(cleanupContexts, (context) => context.cleanup().catch(() => undefined)),
|
||||||
|
);
|
||||||
|
cleanupContexts.clear();
|
||||||
|
for (const repo of cleanupRepos) {
|
||||||
|
try {
|
||||||
|
run(process.cwd(), "gh", ["repo", "delete", repo, "--yes"]);
|
||||||
|
} catch {
|
||||||
|
// Preserve the original test failure; the temp-repo prefix supports bulk cleanup.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cleanupRepos.clear();
|
||||||
|
for (const cleanupPath of cleanupPaths) {
|
||||||
|
rmSync(cleanupPath, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
cleanupPaths.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
githubTest(
|
||||||
|
"does not retarget a never-pushed workspace when its checkout moves onto a merged PR branch",
|
||||||
|
async () => {
|
||||||
|
const repository = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-github-merge-")));
|
||||||
|
cleanupPaths.add(repository);
|
||||||
|
run(repository, "git", ["init", "-b", "main"]);
|
||||||
|
run(repository, "git", ["config", "user.email", "paseo-test@example.com"]);
|
||||||
|
run(repository, "git", ["config", "user.name", "Paseo Test"]);
|
||||||
|
writeFileSync(path.join(repository, "README.md"), "producer repro\n");
|
||||||
|
run(repository, "git", ["add", "README.md"]);
|
||||||
|
run(repository, "git", ["-c", "commit.gpgsign=false", "commit", "-m", "initial"]);
|
||||||
|
|
||||||
|
const owner = run(repository, "gh", ["api", "user", "--jq", ".login"]);
|
||||||
|
const repoName = createTempGithubRepoName("unrelated-merge");
|
||||||
|
const repoFullName = `${owner}/${repoName}`;
|
||||||
|
run(repository, "gh", [
|
||||||
|
"api",
|
||||||
|
"-X",
|
||||||
|
"POST",
|
||||||
|
"user/repos",
|
||||||
|
"-f",
|
||||||
|
`name=${repoName}`,
|
||||||
|
"-f",
|
||||||
|
"private=true",
|
||||||
|
]);
|
||||||
|
cleanupRepos.add(repoFullName);
|
||||||
|
const token = encodeURIComponent(run(repository, "gh", ["auth", "token"]));
|
||||||
|
run(repository, "git", [
|
||||||
|
"remote",
|
||||||
|
"add",
|
||||||
|
"origin",
|
||||||
|
`https://x-access-token:${token}@github.com/${repoFullName}.git`,
|
||||||
|
]);
|
||||||
|
run(repository, "git", ["push", "-u", "origin", "main"]);
|
||||||
|
|
||||||
|
const context = await createDaemonTestContext({ autoArchiveAfterMerge: true });
|
||||||
|
cleanupContexts.add(context);
|
||||||
|
const merged = await createWorktree({
|
||||||
|
cwd: repository,
|
||||||
|
worktreeSlug: "merged-feature",
|
||||||
|
source: { kind: "branch-off", baseBranch: "main", branchName: "merged-feature" },
|
||||||
|
runSetup: false,
|
||||||
|
paseoHome: context.daemon.paseoHome,
|
||||||
|
});
|
||||||
|
const unrelated = await createWorktree({
|
||||||
|
cwd: repository,
|
||||||
|
worktreeSlug: "never-pushed-unrelated",
|
||||||
|
source: { kind: "branch-off", baseBranch: "main", branchName: "never-pushed-unrelated" },
|
||||||
|
runSetup: false,
|
||||||
|
paseoHome: context.daemon.paseoHome,
|
||||||
|
});
|
||||||
|
const mergedWorkspace = await context.client.createWorkspace({
|
||||||
|
source: { kind: "directory", path: merged.worktreePath },
|
||||||
|
});
|
||||||
|
const unrelatedWorkspace = await context.client.createWorkspace({
|
||||||
|
source: { kind: "directory", path: unrelated.worktreePath },
|
||||||
|
});
|
||||||
|
if (!mergedWorkspace.workspace || !unrelatedWorkspace.workspace) {
|
||||||
|
throw new Error("Failed to register both worktrees");
|
||||||
|
}
|
||||||
|
await context.client.fetchWorkspaces({
|
||||||
|
subscribe: { subscriptionId: "real-unrelated-merge" },
|
||||||
|
});
|
||||||
|
|
||||||
|
writeFileSync(path.join(merged.worktreePath, "feature.txt"), "merged\n");
|
||||||
|
run(merged.worktreePath, "git", ["add", "feature.txt"]);
|
||||||
|
run(merged.worktreePath, "git", [
|
||||||
|
"-c",
|
||||||
|
"commit.gpgsign=false",
|
||||||
|
"commit",
|
||||||
|
"-m",
|
||||||
|
"merged feature",
|
||||||
|
]);
|
||||||
|
run(merged.worktreePath, "git", ["push", "-u", "origin", "merged-feature"]);
|
||||||
|
const pullRequestUrl = run(merged.worktreePath, "gh", [
|
||||||
|
"pr",
|
||||||
|
"create",
|
||||||
|
"--repo",
|
||||||
|
repoFullName,
|
||||||
|
"--base",
|
||||||
|
"main",
|
||||||
|
"--head",
|
||||||
|
"merged-feature",
|
||||||
|
"--title",
|
||||||
|
"Merged feature",
|
||||||
|
"--body",
|
||||||
|
"Producer-level auto-archive reproduction.",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const beforeMerge = await activeWorkspaceIds(context);
|
||||||
|
expect(beforeMerge).toEqual(
|
||||||
|
expect.arrayContaining([mergedWorkspace.workspace.id, unrelatedWorkspace.workspace.id]),
|
||||||
|
);
|
||||||
|
run(merged.worktreePath, "gh", [
|
||||||
|
"pr",
|
||||||
|
"merge",
|
||||||
|
pullRequestUrl,
|
||||||
|
"--repo",
|
||||||
|
repoFullName,
|
||||||
|
"--squash",
|
||||||
|
"--delete-branch",
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect((await context.client.checkoutRefresh(merged.worktreePath)).success).toBe(true);
|
||||||
|
await waitForWorkspaceToArchive(context, mergedWorkspace.workspace.id);
|
||||||
|
|
||||||
|
expect((await context.client.checkoutRefresh(unrelated.worktreePath)).success).toBe(true);
|
||||||
|
const unrelatedStatus = await context.client.checkoutPrStatus(unrelated.worktreePath);
|
||||||
|
expect(unrelatedStatus.status).toBeNull();
|
||||||
|
expect(await activeWorkspaceIds(context)).toContain(unrelatedWorkspace.workspace.id);
|
||||||
|
|
||||||
|
// The workspace remains identified by the branch it was created for. Moving the
|
||||||
|
// checkout does not silently retarget its PR association or archive identity.
|
||||||
|
run(merged.worktreePath, "git", ["checkout", "--detach"]);
|
||||||
|
run(unrelated.worktreePath, "git", ["checkout", "merged-feature"]);
|
||||||
|
expect((await context.client.checkoutRefresh(unrelated.worktreePath)).success).toBe(true);
|
||||||
|
const switchedStatus = await context.client.checkoutPrStatus(unrelated.worktreePath);
|
||||||
|
expect(switchedStatus.status).toBeNull();
|
||||||
|
expect(await activeWorkspaceIds(context)).toContain(unrelatedWorkspace.workspace.id);
|
||||||
|
},
|
||||||
|
180_000,
|
||||||
|
);
|
||||||
@@ -47,6 +47,7 @@ interface TestPaseoDaemonOptions {
|
|||||||
webUi?: PaseoDaemonConfig["webUi"];
|
webUi?: PaseoDaemonConfig["webUi"];
|
||||||
trustedProxies?: PaseoDaemonConfig["trustedProxies"];
|
trustedProxies?: PaseoDaemonConfig["trustedProxies"];
|
||||||
agentProfiles?: AgentProfile[];
|
agentProfiles?: AgentProfile[];
|
||||||
|
autoArchiveAfterMerge?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TestPaseoDaemon {
|
export interface TestPaseoDaemon {
|
||||||
@@ -197,6 +198,7 @@ async function prepareTestDaemonConfig(
|
|||||||
dictationFinalTimeoutMs: options.dictationFinalTimeoutMs,
|
dictationFinalTimeoutMs: options.dictationFinalTimeoutMs,
|
||||||
downloadTokenTtlMs: options.downloadTokenTtlMs,
|
downloadTokenTtlMs: options.downloadTokenTtlMs,
|
||||||
agentProfiles: options.agentProfiles,
|
agentProfiles: options.agentProfiles,
|
||||||
|
autoArchiveAfterMerge: options.autoArchiveAfterMerge,
|
||||||
};
|
};
|
||||||
return { config, paseoHomeRoot, paseoHome, staticDir };
|
return { config, paseoHomeRoot, paseoHome, staticDir };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2569,6 +2569,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
target.latestFacts?.isGit && target.latestFacts.currentBranch === git.currentBranch
|
target.latestFacts?.isGit && target.latestFacts.currentBranch === git.currentBranch
|
||||||
? target.latestFacts.pullRequestLookupTarget
|
? target.latestFacts.pullRequestLookupTarget
|
||||||
: null;
|
: null;
|
||||||
|
if (target.latestFacts?.isGit && target.latestFacts.paseoWorktree.isPaseoOwnedWorktree) {
|
||||||
|
return lookupTarget;
|
||||||
|
}
|
||||||
if (lookupTarget) {
|
if (lookupTarget) {
|
||||||
return lookupTarget;
|
return lookupTarget;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
type ProjectRegistry,
|
type ProjectRegistry,
|
||||||
type WorkspaceRegistry,
|
type WorkspaceRegistry,
|
||||||
} from "./workspace-registry.js";
|
} from "./workspace-registry.js";
|
||||||
|
import { pinPaseoWorktreeBranchIdentityIfMissing } from "../utils/worktree-metadata.js";
|
||||||
|
|
||||||
function minIsoDate(left: string | null, right: string | null): string | null {
|
function minIsoDate(left: string | null, right: string | null): string | null {
|
||||||
if (!left) {
|
if (!left) {
|
||||||
@@ -60,6 +61,28 @@ export async function bootstrapWorkspaceRegistries(options: {
|
|||||||
|
|
||||||
await Promise.all([options.projectRegistry.initialize(), options.workspaceRegistry.initialize()]);
|
await Promise.all([options.projectRegistry.initialize(), options.workspaceRegistry.initialize()]);
|
||||||
|
|
||||||
|
// COMPAT(worktree-branch-identity): added in v0.4.0 on 2026-08-15; remove after
|
||||||
|
// 2027-02-15. Older worktrees did not pin branch-off/check-out branch identity.
|
||||||
|
// Seed it from the registry value clients already display, never from live Git.
|
||||||
|
for (const workspace of await options.workspaceRegistry.list()) {
|
||||||
|
if (
|
||||||
|
workspace.archivedAt ||
|
||||||
|
!workspace.isPaseoOwnedWorktree ||
|
||||||
|
!workspace.worktreeRoot ||
|
||||||
|
!workspace.branch
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
pinPaseoWorktreeBranchIdentityIfMissing(workspace.worktreeRoot, workspace.branch);
|
||||||
|
} catch (error) {
|
||||||
|
options.logger.warn(
|
||||||
|
{ err: error, workspaceId: workspace.workspaceId },
|
||||||
|
"Failed to pin legacy worktree branch identity; PR association remains disabled",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (projectsExists && workspacesExists) {
|
if (projectsExists && workspacesExists) {
|
||||||
await backfillWorkspaceIdForLegacyAgents(options);
|
await backfillWorkspaceIdForLegacyAgents(options);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1087,7 +1087,7 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
|
|||||||
expect(dedupedRemoteBranch.status).toBe(1);
|
expect(dedupedRemoteBranch.status).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("derives a deduped PR lookup target from git config when metadata has no target", async () => {
|
test("does not derive a deduped PR lookup target when managed metadata has no target", async () => {
|
||||||
const { tempDir, repoDir, remoteDir, paseoHome } = createSameRepoGitHubPrRemoteRepo();
|
const { tempDir, repoDir, remoteDir, paseoHome } = createSameRepoGitHubPrRemoteRepo();
|
||||||
cleanupPaths.push(tempDir);
|
cleanupPaths.push(tempDir);
|
||||||
execFileSync("git", ["remote", "set-url", "origin", `file://${remoteDir}`], {
|
execFileSync("git", ["remote", "set-url", "origin", `file://${remoteDir}`], {
|
||||||
@@ -1145,7 +1145,7 @@ describe.skipIf(isPlatform("win32"))("worktree-core POSIX-only", () => {
|
|||||||
expect(facts).toMatchObject({
|
expect(facts).toMatchObject({
|
||||||
isGit: true,
|
isGit: true,
|
||||||
currentBranch: "daemon-shutdown-diagnostics-1",
|
currentBranch: "daemon-shutdown-diagnostics-1",
|
||||||
pullRequestLookupTarget: { headRef: "daemon-shutdown-diagnostics" },
|
pullRequestLookupTarget: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2619,7 +2619,7 @@ const x = 1;
|
|||||||
expect(lookupTarget?.headSha).toMatch(/^[0-9a-f]{40}$/);
|
expect(lookupTarget?.headSha).toMatch(/^[0-9a-f]{40}$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reconciles a PR worktree lookup from current branch tracking", async () => {
|
it("does not retarget a PR worktree lookup from current branch tracking", async () => {
|
||||||
execFileSync("git", ["remote", "add", "origin", "https://github.com/acme/repo.git"], {
|
execFileSync("git", ["remote", "add", "origin", "https://github.com/acme/repo.git"], {
|
||||||
cwd: repoDir,
|
cwd: repoDir,
|
||||||
});
|
});
|
||||||
@@ -2657,8 +2657,7 @@ const x = 1;
|
|||||||
const switchedTarget = await readPullRequestLookupTargetFromFacts(workspaceDir, paseoHome);
|
const switchedTarget = await readPullRequestLookupTargetFromFacts(workspaceDir, paseoHome);
|
||||||
const commands = stopGitCommandMetrics().commands.map((command) => command.args[0]);
|
const commands = stopGitCommandMetrics().commands.map((command) => command.args[0]);
|
||||||
|
|
||||||
expect(switchedTarget).toMatchObject({ headRef: "new-change" });
|
expect(switchedTarget).toBeNull();
|
||||||
expect(switchedTarget).not.toHaveProperty("headRepositoryOwner");
|
|
||||||
expect(commands).not.toContain("fetch");
|
expect(commands).not.toContain("fetch");
|
||||||
|
|
||||||
writePaseoWorktreeMetadata(workspaceDir, {
|
writePaseoWorktreeMetadata(workspaceDir, {
|
||||||
@@ -2669,7 +2668,8 @@ const x = 1;
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(await readPullRequestLookupTargetFromFacts(workspaceDir, paseoHome)).toMatchObject({
|
expect(await readPullRequestLookupTargetFromFacts(workspaceDir, paseoHome)).toMatchObject({
|
||||||
headRef: "new-change",
|
headRef: "old-change",
|
||||||
|
headRepositoryOwner: "contributor",
|
||||||
});
|
});
|
||||||
|
|
||||||
execFileSync(
|
execFileSync(
|
||||||
@@ -2696,10 +2696,52 @@ const x = 1;
|
|||||||
cwd: repoDir,
|
cwd: repoDir,
|
||||||
});
|
});
|
||||||
expect(await readPullRequestLookupTargetFromFacts(workspaceDir, paseoHome)).toMatchObject({
|
expect(await readPullRequestLookupTargetFromFacts(workspaceDir, paseoHome)).toMatchObject({
|
||||||
headRef: "local-upstream",
|
headRef: "old-change",
|
||||||
|
headRepositoryOwner: "contributor",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not replace a managed branch pin from shared push config", async () => {
|
||||||
|
execFileSync("git", ["branch", "feature/pinned"], { cwd: repoDir });
|
||||||
|
execFileSync("git", ["remote", "add", "origin", "https://github.com/acme/repo.git"], {
|
||||||
|
cwd: repoDir,
|
||||||
|
});
|
||||||
|
execFileSync("git", ["remote", "add", "fork", "https://github.com/other/repo.git"], {
|
||||||
|
cwd: repoDir,
|
||||||
|
});
|
||||||
|
const workspaceDir = join(paseoHome, "worktrees", "repo", "pinned-worktree");
|
||||||
|
mkdirSync(join(paseoHome, "worktrees", "repo"), { recursive: true });
|
||||||
|
execFileSync("git", ["worktree", "add", workspaceDir, "feature/pinned"], { cwd: repoDir });
|
||||||
|
writePaseoWorktreeMetadata(workspaceDir, {
|
||||||
|
baseRefName: "main",
|
||||||
|
changeRequestLookupTarget: {
|
||||||
|
headRef: "feature/pinned",
|
||||||
|
localBranchName: "feature/pinned",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
execFileSync("git", ["config", "branch.feature/pinned.pushRemote", "fork"], {
|
||||||
|
cwd: repoDir,
|
||||||
|
});
|
||||||
|
execFileSync("git", ["config", "remote.fork.push", "HEAD:refs/heads/unrelated"], {
|
||||||
|
cwd: repoDir,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await readPullRequestLookupTargetFromFacts(workspaceDir, paseoHome)).toMatchObject({
|
||||||
|
headRef: "feature/pinned",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails closed when a managed worktree has no metadata", async () => {
|
||||||
|
execFileSync("git", ["branch", "feature/unpinned"], { cwd: repoDir });
|
||||||
|
const workspaceDir = join(paseoHome, "worktrees", "repo", "unpinned-worktree");
|
||||||
|
mkdirSync(join(paseoHome, "worktrees", "repo"), { recursive: true });
|
||||||
|
execFileSync("git", ["worktree", "add", workspaceDir, "feature/unpinned"], {
|
||||||
|
cwd: repoDir,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await readPullRequestLookupTargetFromFacts(workspaceDir, paseoHome)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("does not apply ambiguous legacy PR metadata to a suffixed branch", async () => {
|
it("does not apply ambiguous legacy PR metadata to a suffixed branch", async () => {
|
||||||
execFileSync("git", ["branch", "contributor/old-change-1"], { cwd: repoDir });
|
execFileSync("git", ["branch", "contributor/old-change-1"], { cwd: repoDir });
|
||||||
const workspaceDir = join(paseoHome, "worktrees", "repo", "legacy-pr-worktree");
|
const workspaceDir = join(paseoHome, "worktrees", "repo", "legacy-pr-worktree");
|
||||||
@@ -2718,8 +2760,7 @@ const x = 1;
|
|||||||
|
|
||||||
const lookupTarget = await readPullRequestLookupTargetFromFacts(workspaceDir, paseoHome);
|
const lookupTarget = await readPullRequestLookupTargetFromFacts(workspaceDir, paseoHome);
|
||||||
|
|
||||||
expect(lookupTarget).toMatchObject({ headRef: "contributor/old-change-1" });
|
expect(lookupTarget).toBeNull();
|
||||||
expect(lookupTarget).not.toHaveProperty("headRepositoryOwner");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not apply a legacy fork hint to an ownerless branch with the same head", async () => {
|
it("does not apply a legacy fork hint to an ownerless branch with the same head", async () => {
|
||||||
@@ -2762,8 +2803,7 @@ const x = 1;
|
|||||||
{ paseoHome, facts: ownerlessFacts },
|
{ paseoHome, facts: ownerlessFacts },
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(requestedTargets.at(-1)).toMatchObject({ headRef: "old-change" });
|
expect(requestedTargets).toHaveLength(1);
|
||||||
expect(requestedTargets.at(-1)).not.toHaveProperty("headRepositoryOwner");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("recognizes a normalized GitHub owner branch from legacy Enterprise metadata", async () => {
|
it("recognizes a normalized GitHub owner branch from legacy Enterprise metadata", async () => {
|
||||||
@@ -2830,7 +2870,7 @@ const x = 1;
|
|||||||
|
|
||||||
expect(requestedTargets.at(-1)).toMatchObject({
|
expect(requestedTargets.at(-1)).toMatchObject({
|
||||||
headRef: "old-change",
|
headRef: "old-change",
|
||||||
headRepositoryOwner: "OtherOwner",
|
headRepositoryOwner: "MixedOwner",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2878,7 +2918,34 @@ const x = 1;
|
|||||||
{ paseoHome, facts: switchedFacts },
|
{ paseoHome, facts: switchedFacts },
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(requestedTargets.at(-1)).toMatchObject({ headRef: "other-branch" });
|
expect(requestedTargets).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves a managed branch identity pin when its branch is renamed", async () => {
|
||||||
|
execFileSync("git", ["branch", "feature/placeholder"], { cwd: repoDir });
|
||||||
|
const workspaceDir = join(paseoHome, "worktrees", "repo", "renamed-worktree");
|
||||||
|
mkdirSync(join(paseoHome, "worktrees", "repo"), { recursive: true });
|
||||||
|
execFileSync("git", ["worktree", "add", workspaceDir, "feature/placeholder"], {
|
||||||
|
cwd: repoDir,
|
||||||
|
});
|
||||||
|
writePaseoWorktreeMetadata(workspaceDir, {
|
||||||
|
baseRefName: "main",
|
||||||
|
changeRequestLookupTarget: {
|
||||||
|
headRef: "feature/placeholder",
|
||||||
|
localBranchName: "feature/placeholder",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await renameCurrentBranch(workspaceDir, "feature/generated");
|
||||||
|
|
||||||
|
expect(readPaseoWorktreeMetadata(workspaceDir)?.changeRequestLookupTarget).toEqual({
|
||||||
|
headRef: "feature/generated",
|
||||||
|
localBranchName: "feature/generated",
|
||||||
|
});
|
||||||
|
const facts = await getCheckoutSnapshotFacts(workspaceDir, { paseoHome });
|
||||||
|
expect(facts.isGit && facts.pullRequestLookupTarget).toMatchObject({
|
||||||
|
headRef: "feature/generated",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps fork identity when the local and tracked branch names match", async () => {
|
it("keeps fork identity when the local and tracked branch names match", async () => {
|
||||||
|
|||||||
@@ -1824,6 +1824,12 @@ function buildInitialPullRequestLookupTarget(input: {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Paseo worktree metadata owns PR identity. A checkout drift must not fall
|
||||||
|
// through to branch config and silently retarget the workspace.
|
||||||
|
if (input.metadata) {
|
||||||
|
return buildPullRequestLookupTargetFromMetadata(input.metadata, input.currentBranch);
|
||||||
|
}
|
||||||
|
|
||||||
const hasConfiguredBranchTarget = Boolean(
|
const hasConfiguredBranchTarget = Boolean(
|
||||||
input.branchRemoteName && parseBranchMergeHeadRef(input.branchMergeRef),
|
input.branchRemoteName && parseBranchMergeHeadRef(input.branchMergeRef),
|
||||||
);
|
);
|
||||||
@@ -1883,6 +1889,47 @@ async function resolvePullRequestLookupTargetFromPushConfig(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function resolveFactsPullRequestLookupTarget(input: {
|
||||||
|
cwd: string;
|
||||||
|
inspected: CheckoutInspectionContext;
|
||||||
|
metadata: PaseoWorktreeMetadata | null;
|
||||||
|
branchRemoteName: string | null;
|
||||||
|
branchMergeRef: string | null;
|
||||||
|
branchRemoteUrl: string | null;
|
||||||
|
resolvedBaseRef: string | null;
|
||||||
|
context?: CheckoutContext;
|
||||||
|
}): Promise<PullRequestStatusLookupTarget | null> {
|
||||||
|
const { cwd, inspected, metadata, context } = input;
|
||||||
|
if (inspected.paseoWorktree.isPaseoOwnedWorktree) {
|
||||||
|
return buildPullRequestLookupTargetFromMetadata(metadata, inspected.currentBranch ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
let target = buildInitialPullRequestLookupTarget({
|
||||||
|
currentBranch: inspected.currentBranch,
|
||||||
|
metadata,
|
||||||
|
branchRemoteName: input.branchRemoteName,
|
||||||
|
branchMergeRef: input.branchMergeRef,
|
||||||
|
branchRemoteUrl: input.branchRemoteUrl,
|
||||||
|
originRemoteUrl: inspected.remoteUrl,
|
||||||
|
resolvedBaseRef: input.resolvedBaseRef,
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
inspected.currentBranch &&
|
||||||
|
target?.headRef === inspected.currentBranch &&
|
||||||
|
!target.headRepositoryOwner
|
||||||
|
) {
|
||||||
|
target =
|
||||||
|
(await resolvePullRequestLookupTargetFromPushConfig(
|
||||||
|
cwd,
|
||||||
|
inspected.currentBranch,
|
||||||
|
inspected.remoteUrl,
|
||||||
|
input.resolvedBaseRef,
|
||||||
|
context,
|
||||||
|
)) ?? target;
|
||||||
|
}
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getCheckoutSnapshotFacts(
|
export async function getCheckoutSnapshotFacts(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
context?: CheckoutContext,
|
context?: CheckoutContext,
|
||||||
@@ -1938,29 +1985,16 @@ export async function getCheckoutSnapshotFacts(
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let pullRequestLookupTarget = buildInitialPullRequestLookupTarget({
|
let pullRequestLookupTarget = await resolveFactsPullRequestLookupTarget({
|
||||||
currentBranch: inspected.currentBranch,
|
cwd,
|
||||||
|
inspected,
|
||||||
metadata: paseoWorktreeMetadata,
|
metadata: paseoWorktreeMetadata,
|
||||||
branchRemoteName,
|
branchRemoteName,
|
||||||
branchMergeRef,
|
branchMergeRef,
|
||||||
branchRemoteUrl,
|
branchRemoteUrl,
|
||||||
originRemoteUrl: inspected.remoteUrl,
|
|
||||||
resolvedBaseRef,
|
resolvedBaseRef,
|
||||||
|
context,
|
||||||
});
|
});
|
||||||
if (
|
|
||||||
inspected.currentBranch &&
|
|
||||||
pullRequestLookupTarget?.headRef === inspected.currentBranch &&
|
|
||||||
!pullRequestLookupTarget.headRepositoryOwner
|
|
||||||
) {
|
|
||||||
pullRequestLookupTarget =
|
|
||||||
(await resolvePullRequestLookupTargetFromPushConfig(
|
|
||||||
cwd,
|
|
||||||
inspected.currentBranch,
|
|
||||||
inspected.remoteUrl,
|
|
||||||
resolvedBaseRef,
|
|
||||||
context,
|
|
||||||
)) ?? pullRequestLookupTarget;
|
|
||||||
}
|
|
||||||
pullRequestLookupTarget = await addHeadShaToPullRequestLookupTarget(
|
pullRequestLookupTarget = await addHeadShaToPullRequestLookupTarget(
|
||||||
cwd,
|
cwd,
|
||||||
pullRequestLookupTarget,
|
pullRequestLookupTarget,
|
||||||
@@ -3952,9 +3986,8 @@ async function getPullRequestStatusUncached(
|
|||||||
context?: CheckoutContext,
|
context?: CheckoutContext,
|
||||||
headSha?: string | null,
|
headSha?: string | null,
|
||||||
): Promise<PullRequestStatusResult> {
|
): Promise<PullRequestStatusResult> {
|
||||||
if (context?.facts?.isGit === false) {
|
const unavailable = getUnavailablePullRequestStatus(context?.facts);
|
||||||
return buildPullRequestStatusResult(null, "no_remote");
|
if (unavailable) return unavailable;
|
||||||
}
|
|
||||||
if (!context?.facts?.isGit) {
|
if (!context?.facts?.isGit) {
|
||||||
await requireGitRepo(cwd);
|
await requireGitRepo(cwd);
|
||||||
}
|
}
|
||||||
@@ -3995,3 +4028,19 @@ async function getPullRequestStatusUncached(
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getUnavailablePullRequestStatus(
|
||||||
|
facts: CheckoutSnapshotFacts | null | undefined,
|
||||||
|
): PullRequestStatusResult | null {
|
||||||
|
if (facts?.isGit === false) {
|
||||||
|
return buildPullRequestStatusResult(null, "no_remote");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
facts?.isGit === true &&
|
||||||
|
facts.paseoWorktree.isPaseoOwnedWorktree &&
|
||||||
|
facts.pullRequestLookupTarget === null
|
||||||
|
) {
|
||||||
|
return buildPullRequestStatusResult(null, "authenticated");
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -105,12 +105,36 @@ export function rebindPaseoWorktreeChangeRequestHint(
|
|||||||
...metadata,
|
...metadata,
|
||||||
changeRequestLookupTarget: {
|
changeRequestLookupTarget: {
|
||||||
...target,
|
...target,
|
||||||
|
...(target.headRef === previousBranch &&
|
||||||
|
!target.headRepositoryOwner &&
|
||||||
|
target.changeRequestNumber === undefined
|
||||||
|
? { headRef: currentBranch }
|
||||||
|
: {}),
|
||||||
localBranchName: currentBranch,
|
localBranchName: currentBranch,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function pinPaseoWorktreeBranchIdentityIfMissing(
|
||||||
|
worktreeRoot: string,
|
||||||
|
branch: string,
|
||||||
|
): boolean {
|
||||||
|
const metadata = readPaseoWorktreeMetadata(worktreeRoot);
|
||||||
|
if (!metadata || metadata.changeRequestLookupTarget) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const target = createPaseoWorktreeChangeRequestHint({
|
||||||
|
headRef: branch,
|
||||||
|
localBranchName: branch,
|
||||||
|
});
|
||||||
|
writePaseoWorktreeMetadataFile(worktreeRoot, {
|
||||||
|
...metadata,
|
||||||
|
changeRequestLookupTarget: target,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function getGitDirForWorktreeRoot(worktreeRoot: string): string {
|
function getGitDirForWorktreeRoot(worktreeRoot: string): string {
|
||||||
const gitPath = join(worktreeRoot, ".git");
|
const gitPath = join(worktreeRoot, ".git");
|
||||||
if (!existsSync(gitPath)) {
|
if (!existsSync(gitPath)) {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
type WorktreeConfig,
|
type WorktreeConfig,
|
||||||
} from "./worktree";
|
} from "./worktree";
|
||||||
import type { PaseoConfig } from "@getpaseo/protocol/paseo-config-schema";
|
import type { PaseoConfig } from "@getpaseo/protocol/paseo-config-schema";
|
||||||
import { getPaseoWorktreeMetadataPath } from "./worktree-metadata.js";
|
import { getPaseoWorktreeMetadataPath, readPaseoWorktreeMetadata } from "./worktree-metadata.js";
|
||||||
import {
|
import {
|
||||||
getCheckoutDiff,
|
getCheckoutDiff,
|
||||||
getCheckoutStatus,
|
getCheckoutStatus,
|
||||||
@@ -128,7 +128,11 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
|||||||
const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath);
|
const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath);
|
||||||
expect(existsSync(metadataPath)).toBe(true);
|
expect(existsSync(metadataPath)).toBe(true);
|
||||||
const metadata = JSON.parse(readFileSync(metadataPath, "utf8"));
|
const metadata = JSON.parse(readFileSync(metadataPath, "utf8"));
|
||||||
expect(metadata).toMatchObject({ version: 1, baseRefName: "main" });
|
expect(metadata).toMatchObject({
|
||||||
|
version: 1,
|
||||||
|
baseRefName: "main",
|
||||||
|
changeRequestLookupTarget: { headRef: "hello-world", localBranchName: "hello-world" },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("creates and owns worktrees under a configured root", async () => {
|
it("creates and owns worktrees under a configured root", async () => {
|
||||||
@@ -254,7 +258,11 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
|||||||
|
|
||||||
const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath);
|
const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath);
|
||||||
const metadata = JSON.parse(readFileSync(metadataPath, "utf8"));
|
const metadata = JSON.parse(readFileSync(metadataPath, "utf8"));
|
||||||
expect(metadata).toMatchObject({ version: 1, baseRefName: "main" });
|
expect(metadata).toMatchObject({
|
||||||
|
version: 1,
|
||||||
|
baseRefName: "main",
|
||||||
|
changeRequestLookupTarget: { headRef: "feature/x", localBranchName: "feature/x" },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("checks out an existing local branch that is not checked out elsewhere", async () => {
|
it("checks out an existing local branch that is not checked out elsewhere", async () => {
|
||||||
@@ -278,7 +286,11 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
|||||||
|
|
||||||
const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath);
|
const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath);
|
||||||
const metadata = JSON.parse(readFileSync(metadataPath, "utf8"));
|
const metadata = JSON.parse(readFileSync(metadataPath, "utf8"));
|
||||||
expect(metadata).toMatchObject({ version: 1, baseRefName: "dev" });
|
expect(metadata).toMatchObject({
|
||||||
|
version: 1,
|
||||||
|
baseRefName: "dev",
|
||||||
|
changeRequestLookupTarget: { headRef: "dev", localBranchName: "dev" },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("checks out an existing local branch whose name contains uppercase letters and dots", async () => {
|
it("checks out an existing local branch whose name contains uppercase letters and dots", async () => {
|
||||||
@@ -312,6 +324,10 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
|||||||
|
|
||||||
expect(result.branchName).toBe("main-1");
|
expect(result.branchName).toBe("main-1");
|
||||||
expect(existsSync(result.worktreePath)).toBe(true);
|
expect(existsSync(result.worktreePath)).toBe(true);
|
||||||
|
expect(readPaseoWorktreeMetadata(result.worktreePath)?.changeRequestLookupTarget).toEqual({
|
||||||
|
headRef: "main-1",
|
||||||
|
localBranchName: "main-1",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fetches a GitHub PR branch, checks it out, writes metadata, and runs setup", async () => {
|
it("fetches a GitHub PR branch, checks it out, writes metadata, and runs setup", async () => {
|
||||||
|
|||||||
@@ -1322,6 +1322,10 @@ async function resolveWorktreeSourcePlan({
|
|||||||
branchName: newBranchName,
|
branchName: newBranchName,
|
||||||
metadataBaseRefName: normalizedBaseBranch,
|
metadataBaseRefName: normalizedBaseBranch,
|
||||||
metadataBaseRef: resolvedBaseBranch,
|
metadataBaseRef: resolvedBaseBranch,
|
||||||
|
changeRequestLookupTarget: createPaseoWorktreeChangeRequestHint({
|
||||||
|
headRef: newBranchName,
|
||||||
|
localBranchName: newBranchName,
|
||||||
|
}),
|
||||||
addArguments: ["-b", newBranchName, "--no-track", base],
|
addArguments: ["-b", newBranchName, "--no-track", base],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1342,6 +1346,10 @@ async function resolveWorktreeSourcePlan({
|
|||||||
return {
|
return {
|
||||||
branchName,
|
branchName,
|
||||||
metadataBaseRefName: source.branchName,
|
metadataBaseRefName: source.branchName,
|
||||||
|
changeRequestLookupTarget: createPaseoWorktreeChangeRequestHint({
|
||||||
|
headRef: branchName,
|
||||||
|
localBranchName: branchName,
|
||||||
|
}),
|
||||||
addArguments: ["-b", branchName, "--no-track", source.branchName],
|
addArguments: ["-b", branchName, "--no-track", source.branchName],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1349,6 +1357,10 @@ async function resolveWorktreeSourcePlan({
|
|||||||
return {
|
return {
|
||||||
branchName: source.branchName,
|
branchName: source.branchName,
|
||||||
metadataBaseRefName: source.branchName,
|
metadataBaseRefName: source.branchName,
|
||||||
|
changeRequestLookupTarget: createPaseoWorktreeChangeRequestHint({
|
||||||
|
headRef: source.branchName,
|
||||||
|
localBranchName: source.branchName,
|
||||||
|
}),
|
||||||
addArguments: [source.branchName],
|
addArguments: [source.branchName],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user