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
1 change: 1 addition & 0 deletions apps/desktop/src/updates/DesktopUpdates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ function makeHarness(options: UpdatesHarnessOptions = {}) {
setDockIcon: () => Effect.void,
getAppMetrics: Effect.succeed([]),
appendCommandLineSwitch: () => Effect.void,
removeCommandLineSwitch: () => Effect.void,
onBeforeQuitForUpdate: () => Effect.void,
on: () => Effect.void as any,
} satisfies ElectronApp.ElectronApp["Service"]);
Expand Down
21 changes: 9 additions & 12 deletions apps/mobile/src/features/threads/ThreadComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -673,18 +673,15 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
() => providerOptionsConfigurationLabel(providerOptionDescriptors),
[providerOptionDescriptors],
);
const modelMenuActions = useMemo(
() => {
const actions = buildModelMenuActions(providerGroups, currentModelSelection);
if (!currentUsageNote) return actions;
return actions.map((action) =>
action.subtitle === undefined
? action
: { ...action, subtitle: `${action.subtitle} · ${currentUsageNote}` },
);
},
[providerGroups, currentModelSelection, currentUsageNote],
);
const modelMenuActions = useMemo(() => {
const actions = buildModelMenuActions(providerGroups, currentModelSelection);
if (!currentUsageNote) return actions;
return actions.map((action) =>
action.subtitle === undefined
? action
: { ...action, subtitle: `${action.subtitle} · ${currentUsageNote}` },
);
}, [providerGroups, currentModelSelection, currentUsageNote]);

// ── Options menu ─────────────────────────────────────────
const optionsMenuActions = useMemo(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ function makeAlwaysTimingOutRegistry(
kind: "git" as const,
rootPath: CWD,
metadataPath: `${CWD}/.git`,
bare: false,
freshness: { source: "cache" as const, checkedAt: 0 },
},
driver: { checkpoints } as unknown as VcsDriver.VcsDriver["Service"],
Expand Down
144 changes: 143 additions & 1 deletion apps/server/src/git/GitWorkflowService.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import { assert, describe, expect, it, vi } from "@effect/vitest";
import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";

import { VcsRepositoryDetectionError } from "@t3tools/contracts";

import * as GitManager from "./GitManager.ts";
import * as GitWorkflowService from "./GitWorkflowService.ts";
import * as ProjectLifecycleScriptRunner from "../project/ProjectLifecycleScriptRunner.ts";
import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
import * as VcsDriver from "../vcs/VcsDriver.ts";
import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";

const lifecycleScriptRunnerMock = Layer.mock(
Expand All @@ -19,19 +22,46 @@ const lifecycleScriptRunnerMock = Layer.mock(

function makeLayer(input: {
readonly detect: VcsDriverRegistry.VcsDriverRegistry["Service"]["detect"];
readonly resolve?: VcsDriverRegistry.VcsDriverRegistry["Service"]["resolve"];
readonly driver?: Record<string, unknown>;
}) {
return GitWorkflowService.layer.pipe(
Layer.provide(
Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({
detect: input.detect,
...(input.resolve ? { resolve: input.resolve } : {}),
}),
),
Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})),
Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)(input.driver ?? {})),
Layer.provide(Layer.mock(GitManager.GitManager)({})),
Layer.provide(lifecycleScriptRunnerMock),
);
}

const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z");

/**
* A repository with no checkout of its own — the shape a bare worktree source
* repo (or a repo whose `core.bare` says so) detects as.
*/
function bareHandle(cwd: string): VcsDriverRegistry.VcsDriverHandle {
return {
kind: "git",
repository: {
kind: "git",
rootPath: `${cwd}/.git`,
metadataPath: `${cwd}/.git`,
bare: true,
freshness: {
source: "live-local",
observedAt: TEST_EPOCH,
expiresAt: Option.none(),
},
},
driver: {} as unknown as VcsDriver.VcsDriver["Service"],
};
}

describe("GitWorkflowService", () => {
it.effect("returns an empty local status when no VCS repository is detected", () =>
Effect.gen(function* () {
Expand Down Expand Up @@ -199,4 +229,116 @@ describe("GitWorkflowService", () => {
),
);
});

describe("bare repositories", () => {
it.effect("creates a worktree from a bare repository", () => {
// The service builds driver effects eagerly, so execution has to be
// recorded from inside the effect rather than from a call count.
let ran = false;
const createWorktree = () =>
Effect.sync(() => {
ran = true;
return { worktree: { path: "/worktrees/feature", refName: "feature" } };
});

return Effect.gen(function* () {
const workflow = yield* GitWorkflowService.GitWorkflowService;
const result = yield* workflow.createWorktree({
cwd: "/bare-repo",
refName: "main",
newRefName: "feature",
path: null,
});

assert.deepStrictEqual(result.worktree, {
path: "/worktrees/feature",
refName: "feature",
});
assert.isTrue(ran);
}).pipe(
Effect.provide(
makeLayer({
detect: () => Effect.succeed(bareHandle("/bare-repo")),
resolve: () => Effect.succeed(bareHandle("/bare-repo")),
driver: { createWorktree },
}),
),
);
});

it.effect("fetches into a bare repository", () => {
let ran = false;
const fetchRemote = () =>
Effect.sync(() => {
ran = true;
});

return Effect.gen(function* () {
const workflow = yield* GitWorkflowService.GitWorkflowService;
yield* workflow.fetchRemote({ cwd: "/bare-repo", remoteName: "origin" });

assert.isTrue(ran);
}).pipe(
Effect.provide(
makeLayer({
detect: () => Effect.succeed(bareHandle("/bare-repo")),
resolve: () => Effect.succeed(bareHandle("/bare-repo")),
driver: { fetchRemote },
}),
),
);
});

it.effect("rejects a checkout-dependent command with an actionable reason", () => {
let ran = false;
const switchRef = () =>
Effect.sync(() => {
ran = true;
return { refName: "main" };
});

return Effect.gen(function* () {
const workflow = yield* GitWorkflowService.GitWorkflowService;
const error = yield* workflow
.switchRef({ cwd: "/bare-repo", refName: "main" })
.pipe(Effect.flip);

expect(error).toMatchObject({
_tag: "GitCommandError",
operation: "GitWorkflowService.switchRef",
command: "vcs-route",
cwd: "/bare-repo",
});
expect(error.detail).toContain("needs a working tree");
expect(error.detail).toContain("bare Git repository");
// The gate must short-circuit before the driver command runs.
assert.isFalse(ran);
}).pipe(
Effect.provide(
makeLayer({
detect: () => Effect.succeed(bareHandle("/bare-repo")),
resolve: () => Effect.succeed(bareHandle("/bare-repo")),
driver: { switchRef },
}),
),
);
});

it.effect("reports a bare repository as having no working tree status", () =>
Effect.gen(function* () {
const workflow = yield* GitWorkflowService.GitWorkflowService;
const status = yield* workflow.localStatus({ cwd: "/bare-repo" });

assert.equal(status.isRepo, false);
assert.equal(status.hasWorkingTreeChanges, false);
}).pipe(
Effect.provide(
makeLayer({
detect: () => Effect.succeed(bareHandle("/bare-repo")),
resolve: () => Effect.succeed(bareHandle("/bare-repo")),
}),
),
),
);
});
});
58 changes: 48 additions & 10 deletions apps/server/src/git/GitWorkflowService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,9 +173,20 @@ export const make = Effect.gen(function* () {
const gitManager = yield* GitManager.GitManager;
const lifecycleScriptRunner = yield* ProjectLifecycleScriptRunner.ProjectLifecycleScriptRunner;

/**
* Bare repositories have no checkout, but stay valid sources for ref, fetch,
* and `worktree add` plumbing — which is exactly what starting a thread needs,
* since the thread then runs in the worktree it just created. Such routes opt
* in with `allowBare: true`; everything that touches a checkout keeps the
* default and reports this reason instead of a blanket routing failure.
*/
const bareRepositoryDetail = (operation: string, cwd: string) =>
`The ${operation} operation needs a working tree, but ${cwd} is a bare Git repository (no checkout of its own). Run it inside a worktree, or give the project a checkout.`;

const ensureGit = Effect.fn("GitWorkflowService.ensureGit")(function* (
operation: string,
cwd: string,
options?: { readonly allowBare?: boolean },
) {
const handle = yield* registry.resolve({ cwd }).pipe(
Effect.mapError(
Expand All @@ -195,11 +206,19 @@ export const make = Effect.gen(function* () {
detail: `The ${operation} workflow currently supports Git repositories only; detected ${handle.kind}. (${cwd})`,
});
}
if (handle.repository.bare && options?.allowBare !== true) {
return yield* new GitManagerError({
operation,
cwd,
detail: bareRepositoryDetail(operation, cwd),
});
}
});

const ensureGitCommand = Effect.fn("GitWorkflowService.ensureGitCommand")(function* (
operation: string,
cwd: string,
options?: { readonly allowBare?: boolean },
) {
const handle = yield* registry.resolve({ cwd }).pipe(
Effect.mapError(
Expand All @@ -223,6 +242,15 @@ export const make = Effect.gen(function* () {
detail: `The ${operation} command currently supports Git repositories only; detected ${handle.kind}.`,
});
}
if (handle.repository.bare && options?.allowBare !== true) {
return yield* new GitCommandError({
operation,
command: "vcs-route",
cwd,
failureKind: "unknown",
detail: bareRepositoryDetail(operation, cwd),
});
}
});

const detectGitRepositoryForStatus = Effect.fn("GitWorkflowService.detectGitRepositoryForStatus")(
Expand All @@ -248,6 +276,12 @@ export const make = Effect.gen(function* () {
detail: `The ${operation} workflow currently supports Git repositories only; detected ${handle.kind}. (${cwd})`,
});
}
// Status describes a working tree, and a bare repository has none. These
// paths are polled continuously, so report "no workspace here" instead of
// failing every poll with an error nobody can act on.
if (handle.repository.bare) {
return false;
}
return true;
},
);
Expand Down Expand Up @@ -341,20 +375,22 @@ export const make = Effect.gen(function* () {
isGitRepository ? git.listRefs(input) : Effect.succeed(nonRepositoryListRefs()),
),
),
// `git worktree add` is the whole point of a bare source repository: the
// thread gets its own checkout, so the source never needs one.
createWorktree: (input) =>
ensureGitCommand("GitWorkflowService.createWorktree", input.cwd).pipe(
ensureGitCommand("GitWorkflowService.createWorktree", input.cwd, { allowBare: true }).pipe(
Effect.andThen(git.createWorktree(input)),
),
fetchRemote: (input) =>
ensureGitCommand("GitWorkflowService.fetchRemote", input.cwd).pipe(
ensureGitCommand("GitWorkflowService.fetchRemote", input.cwd, { allowBare: true }).pipe(
Effect.andThen(git.fetchRemote(input)),
),
resolveRemoteTrackingCommit: (input) =>
ensureGitCommand("GitWorkflowService.resolveRemoteTrackingCommit", input.cwd).pipe(
Effect.andThen(git.resolveRemoteTrackingCommit(input)),
),
ensureGitCommand("GitWorkflowService.resolveRemoteTrackingCommit", input.cwd, {
allowBare: true,
}).pipe(Effect.andThen(git.resolveRemoteTrackingCommit(input))),
removeWorktree: (input) =>
ensureGitCommand("GitWorkflowService.removeWorktree", input.cwd).pipe(
ensureGitCommand("GitWorkflowService.removeWorktree", input.cwd, { allowBare: true }).pipe(
Effect.andThen(
Effect.gen(function* () {
// Prefer the PR associated with the worktree branch (status cwd = worktree path).
Expand Down Expand Up @@ -395,15 +431,17 @@ export const make = Effect.gen(function* () {
),
),
createRef: (input) =>
ensureGitCommand("GitWorkflowService.createRef", input.cwd).pipe(
Effect.andThen(git.createRef(input)),
),
ensureGitCommand("GitWorkflowService.createRef", input.cwd, {
// Creating the branch is pure ref plumbing; only checking it out after
// creation needs a working tree.
allowBare: input.switchRef !== true,
}).pipe(Effect.andThen(git.createRef(input))),
switchRef: (input) =>
ensureGitCommand("GitWorkflowService.switchRef", input.cwd).pipe(
Effect.andThen(Effect.scoped(git.switchRef(input))),
),
renameBranch: (input) =>
ensureGit("GitWorkflowService.renameBranch", input.cwd).pipe(
ensureGit("GitWorkflowService.renameBranch", input.cwd, { allowBare: true }).pipe(
Effect.andThen(git.renameBranch(input)),
),
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1939,7 +1939,7 @@ const make = Effect.gen(function* () {
}
});

yield* forkParked(Stream.runForEach(orchestrationEngine.streamDomainEvents, processEvent));
yield* forkParked(Stream.runForEach(orchestrationEngine.streamDomainEvents, processEvent));

// The domain event stream is hot, so work pending before this reactor
// starts cannot be resumed. Correlated completions only clear the request
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,7 @@ const buildAppUnderTest = (options?: {
kind: "git" as const,
rootPath: input.cwd,
metadataPath: null,
bare: false,
freshness: {
source: "live-local" as const,
observedAt: TEST_EPOCH,
Expand Down Expand Up @@ -505,6 +506,7 @@ const buildAppUnderTest = (options?: {
input.requestedKind === "auto" || !input.requestedKind ? "git" : input.requestedKind,
rootPath: input.cwd,
metadataPath: null,
bare: false,
freshness: {
source: "live-local",
observedAt: TEST_EPOCH,
Expand Down
Loading
Loading