Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 45 additions & 2 deletions apps/server/src/git/GitManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ import {
} from "../project/Services/ProjectSetupScriptRunner.ts";

interface FakeGhScenario {
prListSequence?: string[];
prListSequence?: Array<string | GitHubCliError>;
prListByHeadSelector?: Record<string, string>;
prListSequenceByHeadSelector?: Record<string, string[]>;
createdPrUrl?: string;
Expand Down Expand Up @@ -423,7 +423,11 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
typeof headSelector === "string"
? scenario.prListByHeadSelector?.[headSelector]
: undefined;
const stdout = (mappedQueue ?? mappedStdout ?? prListQueue.shift() ?? "[]") + "\n";
const queued = mappedQueue ?? mappedStdout ?? prListQueue.shift() ?? "[]";
if (queued instanceof GitHubCliError) {
return Effect.fail(queued);
}
const stdout = queued + "\n";
return Effect.succeed(fakeGhOutput(stdout));
}

Expand Down Expand Up @@ -1423,6 +1427,45 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
}),
);

it.effect("keeps the last-known PR through a lookup failure and clears it on success", () =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("threadlines-git-manager-");
yield* initRepo(repoDir);
yield* runGit(repoDir, ["checkout", "-b", "feature/status-stable-pr"]);
const remoteDir = yield* createBareRemote();
yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-stable-pr"]);

const existingPr = {
number: 47,
title: "Stable PR icon",
url: "https://github.com/pingdotgg/codething-mvp/pull/47",
baseRefName: "main",
headRefName: "feature/status-stable-pr",
};
const { manager } = yield* makeManager({
ghScenario: {
prListSequence: [
JSON.stringify([existingPr]),
new GitHubCliError({
operation: "execute",
detail: "Temporary GitHub lookup failure.",
}),
"[]",
],
},
});

const initial = yield* manager.status({ cwd: repoDir });
const retained = yield* manager.remoteStatus({ cwd: repoDir }, { forceRefresh: true });
const cleared = yield* manager.remoteStatus({ cwd: repoDir }, { forceRefresh: true });

expect(initial.pr?.number).toBe(47);
expect(retained?.pr?.number).toBe(47);
expect(cleared?.pr).toBeNull();
}),
);

it.effect("creates a commit when working tree is dirty", () =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("threadlines-git-manager-");
Expand Down
48 changes: 42 additions & 6 deletions apps/server/src/git/GitManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -899,6 +899,15 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
normalizeStatusCacheKey(cwd).pipe(
Effect.flatMap((cacheKey) => Cache.invalidate(localStatusResultCache, cacheKey)),
);
const lastKnownPrByCwdRef = yield* Ref.make(
new Map<
string,
{
readonly branch: string;
readonly pr: VcsStatusRemoteResult["pr"];
}
>(),
);
const readRemoteStatus = Effect.fn("readRemoteStatus")(function* (
cwd: string,
options?: GitRemoteStatusOptions,
Expand All @@ -910,20 +919,47 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
return null;
}

const branch = details.branch;
const pr =
details.branch !== null
? yield* findLatestPr(cwd, {
branch: details.branch,
upstreamRef: details.upstreamRef,
}).pipe(
branch !== null
? yield* sourceControlProvider(cwd).pipe(
// An unknown host has no PR integration. That is a successful
// absence, unlike a configured provider that temporarily fails.
Effect.flatMap((provider) =>
provider.kind === "unknown"
? Effect.succeed(null)
: findLatestPr(cwd, {
branch,
upstreamRef: details.upstreamRef,
}),
),
Effect.map((latest) => {
if (!latest) return null;
// On the default branch, only surface open PRs.
// Merged/closed matches are usually reverse-merge history, not the thread's PR context.
if (details.isDefaultBranch && latest.state !== "open") return null;
return toStatusPr(latest);
}),
Effect.catch(() => Effect.succeed(null)),
Effect.tap((nextPr) =>
Ref.update(lastKnownPrByCwdRef, (lastKnownByCwd) => {
const next = new Map(lastKnownByCwd);
next.delete(cwd);
next.set(cwd, { branch, pr: nextPr });
if (next.size > STATUS_RESULT_CACHE_CAPACITY) {
const oldestCwd = next.keys().next().value;
if (oldestCwd !== undefined) next.delete(oldestCwd);
}
return next;
}),
),
Effect.catch(() =>
Ref.get(lastKnownPrByCwdRef).pipe(
Effect.map((lastKnownByCwd) => {
const lastKnown = lastKnownByCwd.get(cwd);
return lastKnown?.branch === branch ? lastKnown.pr : null;
}),
),
),
)
: null;

Expand Down
58 changes: 58 additions & 0 deletions apps/server/src/terminal/Layers/Manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -712,6 +712,64 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", (
}),
);

it.effect("ignores a PowerShell completion marker queued before command submission", () =>
Effect.gen(function* () {
const { manager, getEvents, ptyAdapter } = yield* createManager(5, {
platform: "win32",
shellResolver: () => "powershell.exe",
subprocessChecker: () => Effect.succeed(false),
subprocessPollIntervalMs: 20,
});

yield* manager.open(openInput());
const process = ptyAdapter.processes[0];
assert.isDefined(process);

const drainBlocked = yield* Deferred.make<void>();
const releaseDrain = yield* Deferred.make<void>();
const unsubscribe = yield* manager.subscribe((event) =>
event.type === "output" && event.data === "prompt output"
? Deferred.succeed(drainBlocked, undefined).pipe(
Effect.andThen(Deferred.await(releaseDrain)),
)
: Effect.void,
);
yield* Effect.addFinalizer(() => Effect.sync(unsubscribe));

process.emitData("prompt output");
yield* Deferred.await(drainBlocked);
process.emitData("\u001b]633;D\u0007PS C:\\repo> ");

yield* manager.write({
threadId: "thread-1",
terminalId: DEFAULT_TERMINAL_ID,
data: "Start-Sleep -Seconds 30\r",
});
yield* waitFor(
Effect.map(getEvents, (events) =>
events.some((event) => event.type === "activity" && event.hasRunningSubprocess),
),
);

yield* Deferred.succeed(releaseDrain, undefined);
yield* Effect.sleep("60 millis");
expect((yield* getEvents).filter((event) => event.type === "activity")).toEqual([
expect.objectContaining({
type: "activity",
hasRunningSubprocess: true,
command: "Start-Sleep -Seconds 30",
}),
]);

process.emitData("\u001b]633;D\u0007PS C:\\repo> ");
yield* waitFor(
Effect.map(getEvents, (events) =>
events.some((event) => event.type === "activity" && !event.hasRunningSubprocess),
),
);
}),
);

it.effect("ignores an in-flight subprocess result after PowerShell reports completion", () =>
Effect.gen(function* () {
const checkStarted = yield* Deferred.make<void>();
Expand Down
Loading
Loading