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
54 changes: 54 additions & 0 deletions apps/server/src/git/GitWorkflowService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { assert, describe, it, vi } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as NodeServices from "@effect/platform-node/NodeServices";
import * as NodeFS from "node:fs";
import * as NodeOS from "node:os";
import * as NodePath from "node:path";

Expand Down Expand Up @@ -345,3 +346,56 @@ describe("GitWorkflowService", () => {
}).pipe(Effect.provide(testLayer));
});
});

describe("resolveRepositoryRootRelation", () => {
it("reads normalization-only differences as same", () => {
assert.equal(GitWorkflowService.resolveRepositoryRootRelation("/repo/wt", "/repo/wt"), "same");
assert.equal(GitWorkflowService.resolveRepositoryRootRelation("/repo/wt", "/repo/wt/"), "same");
assert.equal(
GitWorkflowService.resolveRepositoryRootRelation(
"C:\\Users\\Will\\repo",
"c:/users/will/repo",
),
"same",
);
});

it("reads a genuine subdirectory as ancestor", () => {
assert.equal(
GitWorkflowService.resolveRepositoryRootRelation("/repo", "/repo/apps/web"),
"ancestor",
);
});

it("resolves symlinks before comparing", () => {
const base = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "tl-root-relation-"));
try {
const repo = NodePath.join(base, "repo");
const sub = NodePath.join(repo, "packages", "app");
NodeFS.mkdirSync(sub, { recursive: true });
const rootLink = NodePath.join(base, "root-link");
const subLink = NodePath.join(base, "sub-link");
NodeFS.symlinkSync(repo, rootLink);
NodeFS.symlinkSync(sub, subLink);

// A symlinked spelling of the repository root is the same checkout —
// this was the false "ancestor" that locked healthy panels.
assert.equal(GitWorkflowService.resolveRepositoryRootRelation(repo, rootLink), "same");
// A symlink INTO the repository is a genuine parent situation and must
// keep the safety gate up.
assert.equal(GitWorkflowService.resolveRepositoryRootRelation(repo, subLink), "ancestor");
} finally {
NodeFS.rmSync(base, { recursive: true, force: true });
}
});

it("keeps the gate up when paths still diverge after resolution", () => {
assert.equal(
GitWorkflowService.resolveRepositoryRootRelation(
"/definitely-not-here-a/x",
"/definitely-not-here-b/x",
),
"ancestor",
);
});
});
38 changes: 35 additions & 3 deletions apps/server/src/git/GitWorkflowService.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as nodePath from "node:path";
import * as NodeFS from "node:fs";
import { areFilesystemPathsEqual } from "@threadlines/shared/path";

import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
Expand Down Expand Up @@ -178,11 +179,42 @@ function withRepositoryContext<T extends VcsStatusLocalResult>(
return {
...status,
repositoryRoot,
repositoryRootRelation:
nodePath.resolve(repositoryRoot) === nodePath.resolve(cwd) ? "same" : "ancestor",
repositoryRootRelation: resolveRepositoryRootRelation(repositoryRoot, cwd),
};
}

/** Symlink-resolved form for comparison; the raw value when resolution fails. */
function toComparableRealPath(value: string): string {
try {
return NodeFS.realpathSync.native(value);
} catch {
return value;
}
}

/**
* "ancestor" gates the whole source-control panel behind a confirmation.
* Plain string comparison used to raise that gate for healthy checkouts
* whenever git's resolved root differed from the configured cwd only by
* symlinks, casing, or separators (git realpaths its answers; the configured
* cwd may be the symlinked spelling). Compare after resolving symlinks and
* normalizing separators and case instead. Anything still unequal keeps the
* gate up: a false gate is a visible, dismissible banner, while a false
* "same" would silently expose repository-wide actions — e.g. a cwd
* symlinked into a subdirectory of a larger repository.
*/
export function resolveRepositoryRootRelation(
repositoryRoot: string,
cwd: string,
): "same" | "ancestor" {
if (areFilesystemPathsEqual(repositoryRoot, cwd)) {
return "same";
}
return areFilesystemPathsEqual(toComparableRealPath(repositoryRoot), toComparableRealPath(cwd))
? "same"
: "ancestor";
}

const unsupportedGitWorkflow = (operation: string, cwd: string, detail: string) =>
new GitManagerError({
operation,
Expand Down
215 changes: 215 additions & 0 deletions apps/server/src/orchestration/decider.checkoutSwitch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
import {
CommandId,
DEFAULT_PROVIDER_INTERACTION_MODE,
ProjectId,
ProviderInstanceId,
ThreadId,
type OrchestrationCommand,
type OrchestrationReadModel,
type OrchestrationSession,
} from "@threadlines/contracts";
import * as Effect from "effect/Effect";
import { describe, expect, it } from "vite-plus/test";

import { decideOrchestrationCommand } from "./decider.ts";

const now = "2026-01-01T00:00:00.000Z";
const threadId = ThreadId.make("thread-checkout-switch");
const projectId = ProjectId.make("project-checkout-switch");
const workspaceRoot = "/repos/project";
const worktreeA = "/repos/project/.worktrees/feature-a";
const worktreeB = "/repos/project/.worktrees/feature-b";

function makeSession(input: {
status: OrchestrationSession["status"];
checkoutCwd?: string | null;
}): OrchestrationSession {
return {
threadId,
status: input.status,
providerName: "codex",
providerSessionId: "session-1",
providerThreadId: "provider-thread-1",
runtimeMode: "full-access",
activeTurnId: null,
lastError: null,
...(input.checkoutCwd !== undefined ? { checkoutCwd: input.checkoutCwd } : {}),
updatedAt: now,
};
}

function makeReadModel(input: {
session: OrchestrationSession | null;
effectiveCwd: string | null;
worktreePath?: string | null;
}): OrchestrationReadModel {
return {
snapshotSequence: 1,
updatedAt: now,
projects: [
{
id: projectId,
kind: "workspace",
title: "Checkout Switch Project",
workspaceRoot,
defaultModelSelection: null,
scripts: [],
createdAt: now,
updatedAt: now,
deletedAt: null,
},
],
threads: [
{
id: threadId,
projectId,
title: "Checkout Switch Thread",
modelSelection: {
instanceId: ProviderInstanceId.make("codex"),
model: "gpt-5-codex",
},
runtimeMode: "full-access",
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
branch: "feature-a",
worktreePath: input.worktreePath !== undefined ? input.worktreePath : worktreeA,
effectiveCwd: input.effectiveCwd,
...(input.effectiveCwd !== null ? { effectiveCwdSource: "session" as const } : {}),
goal: null,
latestTurn: null,
createdAt: now,
updatedAt: now,
archivedAt: null,
pinnedAt: null,
doneOverride: null,
lastSeenAt: null,
deletedAt: null,
messages: [],
proposedPlans: [],
activities: [],
checkpoints: [],
diffStatBaselineTurnCount: 0,
session: input.session,
},
],
};
}

function metaUpdateCommand(input: {
worktreePath?: string | null;
title?: string;
}): Extract<OrchestrationCommand, { type: "thread.meta.update" }> {
return {
type: "thread.meta.update",
commandId: CommandId.make("cmd-meta-update"),
threadId,
...(input.title !== undefined ? { title: input.title } : {}),
...(input.worktreePath !== undefined
? { branch: "main", worktreePath: input.worktreePath }
: {}),
};
}

function sessionSetCommand(
session: OrchestrationSession,
): Extract<OrchestrationCommand, { type: "thread.session.set" }> {
return {
type: "thread.session.set",
commandId: CommandId.make("cmd-session-set"),
threadId,
session,
createdAt: "2026-01-01T00:00:10.000Z",
};
}

async function decide(command: OrchestrationCommand, readModel: OrchestrationReadModel) {
const decided = await Effect.runPromise(decideOrchestrationCommand({ command, readModel }));
return Array.isArray(decided) ? decided : [decided];
}

describe("decider checkout switch effectiveCwd", () => {
it("clears the stale effectiveCwd when a stopped thread's worktree changes", async () => {
const events = await decide(
metaUpdateCommand({ worktreePath: worktreeB }),
makeReadModel({
session: makeSession({ status: "stopped", checkoutCwd: worktreeA }),
effectiveCwd: worktreeA,
}),
);

expect(events).toHaveLength(2);
expect(events[0]).toMatchObject({ type: "thread.meta-updated" });
expect(events[1]).toMatchObject({
type: "thread.effective-cwd-set",
payload: { threadId, effectiveCwd: null },
});
expect(events[1]?.causationEventId).toBe(events[0]?.eventId);
});

it("keeps the effectiveCwd while a live session still runs in the old checkout", async () => {
const events = await decide(
metaUpdateCommand({ worktreePath: worktreeB }),
makeReadModel({
session: makeSession({ status: "running", checkoutCwd: worktreeA }),
effectiveCwd: worktreeA,
}),
);

expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({ type: "thread.meta-updated" });
});

it("keeps effectiveCwd when a branch-only update carries the unchanged worktree path", async () => {
const events = await decide(
metaUpdateCommand({ worktreePath: worktreeA }),
makeReadModel({
session: makeSession({ status: "stopped", checkoutCwd: worktreeA }),
effectiveCwd: `${worktreeA}/packages/deep`,
}),
);

expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({ type: "thread.meta-updated" });
});

it("leaves effectiveCwd alone for meta updates that do not move the checkout", async () => {
const events = await decide(
metaUpdateCommand({ title: "Renamed" }),
makeReadModel({ session: null, effectiveCwd: worktreeA }),
);

expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({ type: "thread.meta-updated" });
});

it("applies a queued switch when the session stops away from the thread checkout", async () => {
const events = await decide(
sessionSetCommand(makeSession({ status: "stopped", checkoutCwd: worktreeA })),
makeReadModel({
session: makeSession({ status: "running", checkoutCwd: worktreeA }),
effectiveCwd: worktreeA,
worktreePath: worktreeB,
}),
);

expect(events).toHaveLength(2);
expect(events[0]).toMatchObject({ type: "thread.session-set" });
expect(events[1]).toMatchObject({
type: "thread.effective-cwd-set",
payload: { threadId, effectiveCwd: null },
});
});

it("keeps a cwd-follow effectiveCwd when the session stops in its own checkout", async () => {
const events = await decide(
sessionSetCommand(makeSession({ status: "stopped", checkoutCwd: worktreeA })),
makeReadModel({
session: makeSession({ status: "running", checkoutCwd: worktreeA }),
effectiveCwd: `${worktreeA}/packages/deep`,
worktreePath: worktreeA,
}),
);

expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({ type: "thread.session-set" });
});
});
Loading
Loading