authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-05-02 16:08:17-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-05-02 16:08:17-07:00
log6d7fe2eeb7d2731da3f7f69988056ff7a5d3b921
treef050950a236409464878134c05aa760ee05ca44d
parent460d9c3eb977bc0341876527af7fde591133925f
signature Signed by PGP key B5690EEEBB952194

Introduce pluggable VCS driver foundation (#2435)

Co-authored-by: Julius Marminge <julius@macmini.local> Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: codex <codex@users.noreply.github.com> Co-authored-by: cursor[bot] <206951365+cursor[bot]@users.noreply.github.com>

148 files changed, 19497 insertions(+), 16089 deletions(-)

.plans/19-version-control-phase-1-vcs-driver-foundation.md created+216
......@@ -0,0 +1,216 @@
1# Version Control Phase 1: VCS Driver Foundation
2
3## Goal
4
5Introduce a provider-neutral VCS layer and rewrite the local Git implementation as an Effect-native driver. This phase should preserve user-visible behavior while replacing the Git-first service boundary with an abstraction that can support Git, Jujutsu, and later Sapling or another viable VCS.
6
7The existing `GitCore` implementation is a behavior reference and source of regression tests, not the target architecture. New code should follow the newer package style used by `effect-acp` and `effect-codex-app-server`: typed service tags, schema-backed tagged errors, scoped process usage, explicit decode boundaries, and no Promise-based process helper as the core execution primitive.
8
9## Scope
10
11- Add VCS-domain contracts in `packages/contracts/src/vcs.ts`.
12- Add shared runtime parsing helpers in `packages/shared/src/vcs/*` only when they are useful to both server and web.
13- Add server services under `apps/server/src/vcs`:
14 - `Services/VcsDriver.ts`
15 - `Services/VcsRepositoryResolver.ts`
16 - `Services/VcsProcess.ts`
17 - `Layers/GitVcsDriver.ts`
18 - `errors.ts`
19- Migrate server callers from Git-specific terms where the operation is actually VCS-generic.
20- Update active consumers to the new VCS APIs in the same phase; do not add backwards-compatible export shims.
21- Leave source-control hosting providers out of this phase except for remote metadata needed to describe repository status.
22
23## Non-Goals
24
25- No GitLab, Azure DevOps, or GitHub provider rewrite yet.
26- No Jujutsu driver yet, but every interface must be designed so a Jujutsu driver does not have to pretend to be Git.
27- No T3 Review implementation yet.
28- No broad UI redesign.
29
30## Driver Model
31
32Use provider-neutral nouns in new APIs:
33
34- `VcsDriver`: local repository mechanics.
35- `RepositoryIdentity`: detected VCS kind, root path, common metadata path when available, remotes.
36- `WorkingCopyStatus`: dirty state, changed files, aggregate insertions/deletions, current branch/bookmark/change name.
37- `ChangeSet`: a committed or pending unit of change, not necessarily a Git commit.
38- `RefName`: branch, bookmark, tag, or provider-specific ref.
39
40The initial driver capabilities should be explicit:
41
42```ts
43export interface VcsDriverCapabilities {
44 readonly kind: "git" | "jj" | "sapling" | "unknown";
45 readonly supportsWorktrees: boolean;
46 readonly supportsBookmarks: boolean;
47 readonly supportsAtomicSnapshot: boolean;
48 readonly supportsPushDefaultRemote: boolean;
49}
50```
51
52Do not model Jujutsu as `GitCoreShape extends ...`. The Git driver can expose Git-specific implementation details internally, but the public VCS layer should describe operations by intent:
53
54- `detectRepository(cwd)`
55- `status(cwd, options)`
56- `listRefs(cwd, query/pagination)`
57- `checkoutRef(cwd, ref)`
58- `createRef(cwd, ref, from?)`
59- `createWorkspace(cwd, ref, path?)`
60- `removeWorkspace(path)`
61- `prepareChangeContext(cwd, filePaths?)`
62- `createChange(cwd, message, options)`
63- `push(cwd, target?)`
64- `rangeContext(cwd, base, head)`
65- `listWorkspaceFiles(cwd, options)`
66
67## Effect Process Layer
68
69Create a small reusable `VcsProcess` service instead of using `runProcess`.
70
71Requirements:
72
73- Implement with `ChildProcess` and `ChildProcessSpawner` from `effect/unstable/process`.
74- Support scoped acquisition/release for long-running commands and interruption.
75- Support bounded stdout/stderr collection with truncation markers.
76 - DO not eagerly consume full stdout/stderr, return stream apis and expose helpers for consumers so we don't consume streams to memory unnecessarily...
77- Support stdin.
78- Support timeout through Effect scheduling/interruption, not ad-hoc timers.
79- Stream output lines to progress callbacks as Effects.
80- Return a typed `ProcessOutput` value for successful execution.
81- Fail with typed errors, not generic thrown exceptions.
82
83Errors should be schema-backed tagged classes, for example:
84
85- `VcsProcessSpawnError`
86- `VcsProcessExitError`
87- `VcsProcessTimeoutError`
88- `VcsOutputDecodeError`
89- `VcsRepositoryDetectionError`
90- `VcsUnsupportedOperationError`
91
92Every error should carry operation name, command display string, cwd when applicable, exit code when applicable, stderr/stdout tails when useful, and original cause where available. Override `message` for user readable messages that provides meaning and hints where appropriate. Errors are schema backed so the full error details will be persisted and serialized properly when stored to DB/Logfiles.
93
94## Git Driver Rewrite
95
96Rewrite Git support against `VcsProcess`.
97
98Carry forward current behavior from:
99
100- `apps/server/src/git/Layers/GitCore.ts`
101- `apps/server/src/git/Layers/GitCore.test.ts`
102- current Git status/branch/worktree contracts
103
104But split the implementation into smaller modules:
105
106- command execution and hardening config
107- repository detection
108- status parsing
109- branch/ref parsing
110- worktree operations
111- commit/range context generation
112- push/pull operations
113
114Keep parsing deterministic. Prefer Git porcelain formats, null-separated output, and schema decoding for JSON-like command output. Avoid regex parsing where Git gives a structured format.
115
116## Freshness and Local Caching
117
118Define freshness rules in the VCS layer before adding more providers. Local VCS status is cheap enough to refresh often; network-backed status is not.
119
120Treat these as live/local:
121
122- repository detection for the active cwd
123- working copy dirty state
124- staged/unstaged/untracked file summaries
125- current branch/bookmark/change name
126- local branch/bookmark lists
127- local worktree/workspace lists
128
129These may run on user-visible polling, but should still be debounced and coalesced per repository root. Prefer filesystem-triggered invalidation where available, with a short fallback poll interval. Concurrent requests for the same repository/status shape should share one in-flight Effect.
130
131Treat these as cached or explicit-refresh only:
132
133- remote tracking branch refreshes
134- ahead/behind counts that require network fetches
135- default branch discovery from a remote provider
136- remote branch lists beyond locally known refs
137
138The VCS driver should expose freshness metadata with status results:
139
140```ts
141export interface VcsFreshness {
142 readonly source: "live-local" | "cached-local" | "cached-remote" | "explicit-remote";
143 readonly observedAt: string;
144 readonly expiresAt?: string;
145}
146```
147
148Remote refreshes should be opt-in per operation, for example `refresh: "local-only" | "allow-cached-remote" | "force-remote"`. The default for background status should be `local-only`.
149
150Use Effect `Cache` for repository identity and expensive local metadata:
151
152- key by resolved repository root plus VCS kind
153- invalidate on cwd/root changes and workspace mutation operations
154- use short TTLs for local status caches when filesystem events are unavailable
155- never hide command failures behind stale values unless the caller explicitly accepts stale data
156
157## Cutover Policy
158
159Prefer direct migration and deletion over compatibility wrappers.
160
161Rules:
162
163- Update consumers to call `VcsDriver`/`VcsRepositoryResolver` directly as soon as the new API exists.
164- Delete migrated `GitCore` service methods and tests in the same PR that moves their consumers.
165- Do not keep backwards-compatible export shims, barrel aliases, or old service names for convenience.
166- Transitional modules are allowed only when a caller group is too complex or risky to migrate in the same PR.
167- Every transitional module must have a narrow owner, a removal checklist, and a test proving it delegates to the new implementation.
168- No new feature work may depend on transitional modules.
169
170Expected transitional candidates:
171
172- The highest-level `GitManager` orchestration can be migrated in slices if doing the full Commit + PR flow in one PR is too risky.
173- WebSocket payload compatibility can remain only where changing it would require a coordinated UI/server protocol migration. Internal server code should still use the new VCS contracts.
174
175## Tests
176
177Add integration-style tests with real temporary Git repositories for the new Git driver:
178
179- non-repository detection
180- status for clean/dirty/untracked/staged states
181- branch/ref list with pagination
182- checkout/create branch
183- worktree create/remove
184- commit context generation with file filters
185- commit creation with hook progress events
186- push behavior against a local bare remote
187- status polling does not perform remote network refresh by default
188- concurrent duplicate status requests are coalesced
189- bounded output/truncation
190- timeout/interruption
191- typed error shape for command failure and missing executable
192
193Move or duplicate only the tests needed to prove behavior, then delete the old service tests in the same migration slice.
194
195## Migration Steps
196
1971. Add `vcs` contracts and tagged errors.
1982. Add `VcsProcess` and unit tests around process execution semantics.
1993. Add `VcsDriver` and `VcsRepositoryResolver` service contracts.
2004. Implement `GitVcsDriver` with real Git command integration tests.
2015. Move `GitStatusBroadcaster` and branch/worktree flows to the VCS service directly.
2026. Move commit/range/push callers to the VCS service directly.
2037. Delete migrated `GitCore` internals and tests as each caller group moves.
2048. Add a transitional adapter only for any remaining `GitManager` path that is explicitly too complex to cut over safely in one PR.
2059. Remove every transitional adapter before starting Phase 2 unless the adapter is documented as blocking on the provider cutover.
206
207## Acceptance Criteria
208
209- Current Git branch/status/worktree/commit behavior remains intact.
210- New Git implementation does not depend on `processRunner.ts`.
211- New errors are typed and inspectable by tests.
212- VCS interfaces contain no GitHub/GitLab/Azure concepts.
213- Active consumers use the new VCS APIs directly; any remaining transitional module has a written removal checklist and no compatibility export shim.
214- Background status refresh is local-only by default and cannot hit provider rate limits.
215- Jujutsu can be added by implementing a real driver instead of conforming to Git command semantics.
216- `bun fmt`, `bun lint`, and `bun typecheck` pass.
.plans/20-version-control-phase-2-source-control-provider-foundation.md created+268
......@@ -0,0 +1,268 @@
1# Version Control Phase 2: Source Control Provider Foundation
2
3## Goal
4
5Introduce a pluggable source-control provider layer and rewrite GitHub support as an Effect-native provider. This phase should preserve the existing GitHub Commit + PR flow while making GitLab and Azure DevOps additive drivers rather than branches inside GitHub-oriented code.
6
7The existing `GitHubCli` service and GitHub-specific `GitManager` paths are behavior references. The new provider layer should use detailed tagged errors, schema decode boundaries, `effect/unstable/process`, capability flags, and provider-neutral change-request types.
8
9## Scope
10
11- Add provider-domain contracts in `packages/contracts/src/sourceControl.ts`.
12- Add provider URL/reference parsing helpers in `packages/shared/src/sourceControl/*`.
13- Add server services under `apps/server/src/sourceControl`:
14 - `Services/SourceControlProvider.ts`
15 - `Services/SourceControlProviderRegistry.ts`
16 - `Services/SourceControlProcess.ts`
17 - `Layers/GitHubSourceControlProvider.ts`
18 - `errors.ts`
19- Migrate PR creation, PR lookup, default-branch lookup, clone URL lookup, and PR checkout through the provider layer.
20- Update active consumers to the provider APIs directly; do not add backwards-compatible `GitHubCli` export shims.
21- Keep GitHub as the only production provider at the end of this phase, but make GitLab and Azure implementation paths obvious and bounded.
22
23## Non-Goals
24
25- No GitLab implementation in this phase, except fixtures/contracts that prove the abstraction can represent merge requests.
26- No Azure DevOps implementation in this phase, except URL/reference parser test cases if cheap.
27- No in-app review UI yet.
28- No hard dependency on one CLI forever. The first GitHub driver may use `gh`, but the interface should support REST/GraphQL implementations later.
29
30## Provider Model
31
32Use provider-neutral names:
33
34- `SourceControlProvider`: hosted repository and change-request mechanics.
35- `ChangeRequest`: GitHub pull request, GitLab merge request, Azure pull request.
36- `ChangeRequestThread`: review or discussion thread.
37- `ChangeRequestComment`: top-level or inline comment.
38- `ProviderRepository`: owner/project/repo identity plus clone URLs.
39
40Core provider operations:
41
42- `detectRemote(remoteUrl)`
43- `checkAuth(cwd)`
44- `getRepository(cwd | remoteUrl)`
45- `getDefaultTargetRef(repository)`
46- `listChangeRequests(repository, filters)`
47- `getChangeRequest(repository, reference)`
48- `createChangeRequest(repository, input)`
49- `checkoutChangeRequest(cwd, changeRequest, options)`
50- `getCloneUrls(repository)`
51
52Review-facing operations should be designed now, even if unimplemented:
53
54- `listReviewThreads(changeRequest)`
55- `createReviewComment(changeRequest, input)`
56- `replyToReviewThread(thread, input)`
57- `resolveReviewThread(thread)`
58- `submitReview(changeRequest, input)`
59
60Each operation should be guarded by capabilities:
61
62```ts
63export interface SourceControlProviderCapabilities {
64 readonly kind: "github" | "gitlab" | "azure-devops" | "unknown";
65 readonly supportsCreateChangeRequest: boolean;
66 readonly supportsCheckoutChangeRequest: boolean;
67 readonly supportsReviewThreads: boolean;
68 readonly supportsInlineComments: boolean;
69 readonly supportsDraftChangeRequests: boolean;
70}
71```
72
73## Provider Registry
74
75Add a registry that resolves a provider from repository remotes and explicit user input.
76
77Rules:
78
79- Detection should be pure where possible and testable without spawning CLIs.
80- Remote URL parsing belongs in `packages/shared`, not server-only provider layers.
81- Unknown providers should return explicit unsupported-operation errors, not silently fall back to GitHub.
82- Provider selection should be stable per operation and logged with enough context to debug bad remote detection.
83
84The registry should support multiple provider implementations at runtime, not a single dispatcher file with inline provider branches.
85
86## Rate Limits and Provider Caching
87
88Design the provider layer around a strict freshness budget. Provider API and CLI calls must not be part of frequent background polling unless the operation is explicitly marked safe and cached.
89
90Default behavior:
91
92- Pure URL/remote parsing is always live because it is local.
93- Provider detection from local remotes is live-local.
94- Authentication checks are cached.
95- Repository metadata is cached.
96- Default branch metadata is cached.
97- Change-request lists are cached and refreshed on explicit user actions or coarse intervals.
98- Full review threads, comments, file diffs, and timeline data are fetched only when the user opens the relevant review surface or explicitly refreshes it.
99- Create/update operations invalidate affected cache keys immediately after success.
100
101The provider API should make freshness explicit:
102
103```ts
104export interface SourceControlFreshness {
105 readonly source: "live-local" | "cached-provider" | "live-provider";
106 readonly observedAt: string;
107 readonly expiresAt?: string;
108 readonly stale?: boolean;
109}
110
111export type ProviderRefreshPolicy =
112 | "cache-first"
113 | "stale-while-revalidate"
114 | "force-refresh"
115 | "local-only";
116```
117
118Every read operation that can touch a provider should accept a refresh policy. Background UI reads should default to `cache-first` or `stale-while-revalidate`; direct user actions like pressing refresh can use `force-refresh`.
119
120Use Effect `Cache` for provider data:
121
122- auth status: key by provider kind, hostname, workspace identity, and account if known; TTL around minutes, not seconds
123- repository metadata/default branch: key by provider repository stable ID or normalized remote URL; TTL around tens of minutes
124- change-request summary lists: key by provider repository, state/filter, source ref, target ref; short TTL with stale-while-revalidate
125- individual change-request summaries: key by provider repository and provider CR ID; short TTL, invalidated after create/update/comment operations
126- review threads/comments/diffs: key by provider CR ID and head SHA/version when available; fetch on demand for T3 Review
127
128Provider drivers should surface rate-limit signals when available:
129
130- remaining quota
131- reset time
132- retry-after duration
133- whether the limit is primary, secondary/abuse, or unknown
134
135Rate-limit errors should be typed, retryable when the provider gives a reset/retry time, and visible enough for the UI to avoid repeatedly retrying a blocked operation.
136
137Avoid rate-limit footguns:
138
139- no provider calls from render loops or fast status polling
140- no listing all PRs/MRs across all repos to infer one branch state
141- no silent GitHub fallback for unknown providers
142- no unbounded cache cardinality for branch names or free-form search queries
143- no per-thread duplicate provider refresh when multiple views observe the same repository
144
145## GitHub Provider Rewrite
146
147Rewrite GitHub support as `GitHubSourceControlProvider`.
148
149Carry forward behavior from:
150
151- `apps/server/src/git/Layers/GitHubCli.ts`
152- `apps/server/src/git/Layers/GitHubCli.test.ts`
153- `apps/server/src/git/githubPullRequests.ts`
154- GitHub-specific `GitManager` PR paths
155
156Implementation requirements:
157
158- Use `SourceControlProcess` built on `effect/unstable/process`, not `runProcess`.
159- Decode `gh api` and `gh pr --json` responses with Effect Schema.
160- Use typed errors for auth failure, missing CLI, command failure, output decode failure, unsupported reference, and provider mismatch.
161- Keep stdout/stderr bounded.
162- Avoid global mutable auth caches unless they are Effect `Cache` values with explicit keys, TTLs, and invalidation behavior.
163- Parse provider rate-limit headers or CLI/API error payloads when available and map them to typed rate-limit errors.
164- Keep GitHub nouns inside the GitHub driver; convert to `ChangeRequest` at the provider boundary.
165
166## GitManager Cutover
167
168Refactor `GitManager` so it coordinates three independent services:
169
170- `VcsDriver` for local repository mechanics.
171- `SourceControlProviderRegistry` for hosted provider selection.
172- `TextGeneration` for message/body generation.
173
174`GitManager` should stop depending directly on GitHub services. User-visible step labels should be provider-neutral unless the selected provider is known and the label is intentionally provider-specific.
175
176The Commit + PR flow should become:
177
1781. Resolve VCS repository and local status.
1792. Resolve source-control provider from remotes.
1803. Generate commit content through the existing text generation service.
1814. Create local change through `VcsDriver`.
1825. Push through `VcsDriver` or a narrow provider push helper only if the VCS requires provider-specific target syntax.
1836. Generate change-request title/body.
1847. Create the change request through `SourceControlProvider`.
185
186## Cutover Policy
187
188This phase should aggressively remove old GitHub-specific internals.
189
190Rules:
191
192- Move each active consumer directly to `SourceControlProviderRegistry` or a concrete provider test layer.
193- Delete migrated `GitHubCli` methods, tests, and GitHub-specific helper exports in the same PR that moves their final consumer.
194- Do not add compatibility export shims from `apps/server/src/git` to `apps/server/src/sourceControl`.
195- Transitional modules are allowed only for a bounded `GitManager` slice that cannot move safely with the rest of the provider cutover.
196- Every transitional module must have an owner comment, a removal checklist, and no public exports consumed by new code.
197- Provider-neutral web parsing should replace GitHub-only parsing directly; do not keep parallel parser stacks unless a route still requires both during a single PR.
198
199## GitLab and Azure Readiness
200
201Use the triaged references as implementation inputs, not merge targets:
202
203- GitLab PR #592 is useful for `glab mr` command mapping and JSON normalization.
204- Azure issue #1138 defines a good first Azure slice: remote/URL detection and change-request thread setup for same-repo URLs.
205
206The abstraction should let Phase 3 add:
207
208- `GitLabSourceControlProvider` using `glab`.
209- `AzureDevOpsSourceControlProvider` using `az repos pr` or REST APIs.
210
211No provider should need to edit GitHub code to join the registry.
212
213## T3 Review Design Constraint
214
215Do not optimize only for creation/checkout. The provider layer must be able to support a future in-app review surface.
216
217That means contracts should include stable IDs and enough metadata for:
218
219- file-level diffs
220- inline review threads
221- resolved/unresolved state
222- top-level discussion comments
223- pending review submission
224- provider URL back-links
225
226Provider-specific fields can live in a metadata bag, but core review behavior should not require the UI to know whether the backing service is GitHub, GitLab, or Azure DevOps.
227
228## Tests
229
230Add tests at three levels:
231
232- Pure parser tests for GitHub, GitLab, and Azure remote URLs and change-request references.
233- Provider unit tests with fake `SourceControlProcess` output and schema decode failures.
234- Integration-style GitHub CLI tests only where they can run hermetically or be skipped without hiding unit coverage.
235
236Required cases:
237
238- GitHub PR URL, number, and branch-ish references.
239- GitLab MR URL/reference parsing.
240- Azure DevOps PR URL parsing for same-repo URLs.
241- unknown provider returns unsupported-operation errors.
242- missing CLI and auth failures produce distinct typed errors.
243- invalid CLI JSON fails at decode boundary with useful context.
244
245## Migration Steps
246
2471. Add `sourceControl` contracts and provider-neutral schemas.
2482. Add shared remote/reference parser helpers and tests.
2493. Add `SourceControlProcess` and provider errors.
2504. Add provider registry with GitHub-only registration.
2515. Implement `GitHubSourceControlProvider` from scratch against the new process layer.
2526. Cut GitHub PR operations in `GitManager` over to the provider registry.
2537. Replace web PR-reference parsing with provider-neutral parser output while keeping current GitHub UX.
2548. Add provider cache metrics and tests for cache hit, stale refresh, invalidation, and rate-limit error mapping.
2559. Delete the migrated `GitHubCli` implementation, tests, and GitHub-specific helper exports unless an explicit transitional checklist remains.
256
257## Acceptance Criteria
258
259- Existing GitHub Commit + PR and PR checkout flows still work.
260- `GitManager` no longer imports or depends on `GitHubCli`.
261- Active consumers use source-control provider APIs directly; any remaining transitional module has a written removal checklist and no compatibility export shim.
262- Source-control contracts can represent GitHub PRs, GitLab MRs, and Azure DevOps PRs.
263- Unknown/unsupported providers fail explicitly and visibly.
264- GitHub command execution does not depend on `processRunner.ts`.
265- Background provider reads are cached/coalesced and do not consume provider API quota on every status refresh.
266- Rate-limit responses become typed errors with retry/reset metadata where available.
267- The provider API includes the review operations needed by future T3 Review work, even if they are capability-gated.
268- `bun fmt`, `bun lint`, and `bun typecheck` pass.
.plans/README.md+2
......@@ -10,3 +10,5 @@
10108. `08-precommit-format-and-lint.md`
11119. `09-event-state-test-expansion.md`
121210. `10-unify-process-session-abstraction.md`
1319. `19-version-control-phase-1-vcs-driver-foundation.md`
1420. `20-version-control-phase-2-source-control-provider-foundation.md`
apps/server/integration/OrchestrationEngineHarness.integration.ts+17-14
......@@ -25,10 +25,7 @@ import {
2525
2626import { CheckpointStoreLive } from "../src/checkpointing/Layers/CheckpointStore.ts";
2727import { CheckpointStore } from "../src/checkpointing/Services/CheckpointStore.ts";
28import { GitCoreLive } from "../src/git/Layers/GitCore.ts";
29import { GitCore, type GitCoreShape } from "../src/git/Services/GitCore.ts";
30import { GitStatusBroadcaster } from "../src/git/Services/GitStatusBroadcaster.ts";
31import { TextGeneration, type TextGenerationShape } from "../src/git/Services/TextGeneration.ts";
28import { TextGeneration, type TextGenerationShape } from "../src/textGeneration/TextGeneration.ts";
3229import { OrchestrationCommandReceiptRepositoryLive } from "../src/persistence/Layers/OrchestrationCommandReceipts.ts";
3330import { OrchestrationEventStoreLive } from "../src/persistence/Layers/OrchestrationEventStore.ts";
3431import { ProjectionCheckpointRepositoryLive } from "../src/persistence/Layers/ProjectionCheckpoints.ts";
......@@ -77,6 +74,11 @@ import {
7774import { deriveServerPaths, ServerConfig } from "../src/config.ts";
7875import { WorkspaceEntriesLive } from "../src/workspace/Layers/WorkspaceEntries.ts";
7976import { WorkspacePathsLive } from "../src/workspace/Layers/WorkspacePaths.ts";
77import * as GitVcsDriver from "../src/vcs/GitVcsDriver.ts";
78import * as VcsDriverRegistry from "../src/vcs/VcsDriverRegistry.ts";
79import { VcsStatusBroadcaster } from "../src/vcs/VcsStatusBroadcaster.ts";
80import { GitWorkflowService } from "../src/git/GitWorkflowService.ts";
81import * as VcsProcess from "../src/vcs/VcsProcess.ts";
8082
8183function runGit(cwd: string, args: ReadonlyArray<string>) {
8284 return execFileSync("git", args, {
......@@ -291,7 +293,7 @@ export const makeOrchestrationIntegrationHarness = (
291293 Layer.provide(providerEventLoggersLayer),
292294 );
293295
294 const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(GitCoreLive));
296 const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(VcsDriverRegistry.layer));
295297 const projectionSnapshotQueryLayer = OrchestrationProjectionSnapshotQueryLive;
296298 const runtimeServicesLayer = Layer.mergeAll(
297299 projectionSnapshotQueryLayer,
......@@ -307,31 +309,31 @@ export const makeOrchestrationIntegrationHarness = (
307309 Layer.provideMerge(runtimeServicesLayer),
308310 Layer.provideMerge(serverSettingsLayer),
309311 );
310 const gitCoreLayer = Layer.succeed(GitCore, {
311 renameBranch: (input: Parameters<GitCoreShape["renameBranch"]>[0]) =>
312 const gitWorkflowLayer = Layer.mock(GitWorkflowService)({
313 renameBranch: (input: Parameters<GitVcsDriver.GitVcsDriverShape["renameBranch"]>[0]) =>
312314 Effect.succeed({ branch: input.newBranch }),
313 } as unknown as GitCoreShape);
315 });
314316 const textGenerationLayer = Layer.succeed(TextGeneration, {
315317 generateBranchName: () => Effect.succeed({ branch: "update" }),
316318 generateThreadTitle: () => Effect.succeed({ title: "New thread" }),
317319 } as unknown as TextGenerationShape);
318320 const providerCommandReactorLayer = ProviderCommandReactorLive.pipe(
319321 Layer.provideMerge(runtimeServicesLayer),
320 Layer.provideMerge(gitCoreLayer),
322 Layer.provideMerge(gitWorkflowLayer),
321323 Layer.provideMerge(textGenerationLayer),
322324 Layer.provideMerge(serverSettingsLayer),
323325 );
324326 const checkpointReactorLayer = CheckpointReactorLive.pipe(
325327 Layer.provideMerge(runtimeServicesLayer),
326328 Layer.provideMerge(
327 Layer.succeed(GitStatusBroadcaster, {
329 Layer.succeed(VcsStatusBroadcaster, {
328330 getStatus: () => Effect.die("getStatus should not be called in this test"),
329331 refreshLocalStatus: () =>
330332 Effect.succeed({
331333 isRepo: true,
332 hasOriginRemote: false,
333 isDefaultBranch: true,
334 branch: "main",
334 hasPrimaryRemote: false,
335 isDefaultRef: true,
336 refName: "main",
335337 hasWorkingTreeChanges: false,
336338 workingTree: { files: [], insertions: 0, deletions: 0 },
337339 }),
......@@ -342,11 +344,12 @@ export const makeOrchestrationIntegrationHarness = (
342344 Layer.provideMerge(
343345 WorkspaceEntriesLive.pipe(
344346 Layer.provide(WorkspacePathsLive),
345 Layer.provideMerge(gitCoreLayer),
347 Layer.provideMerge(VcsDriverRegistry.layer),
346348 Layer.provide(NodeServices.layer),
347349 ),
348350 ),
349351 Layer.provideMerge(WorkspacePathsLive),
352 Layer.provideMerge(VcsProcess.layer),
350353 );
351354 const orchestrationReactorLayer = OrchestrationReactorLive.pipe(
352355 Layer.provideMerge(runtimeIngestionLayer),
apps/server/src/checkpointing/Errors.ts+2-5
......@@ -1,6 +1,6 @@
11import { Schema } from "effect";
22import type { ProjectionRepositoryError } from "../persistence/Errors.ts";
3import { GitCommandError } from "@t3tools/contracts";
3import type { VcsError } from "@t3tools/contracts";
44
55/**
66 * CheckpointUnavailableError - Expected checkpoint does not exist.
......@@ -35,9 +35,6 @@ export class CheckpointInvariantError extends Schema.TaggedErrorClass<Checkpoint
3535 }
3636}
3737
38export type CheckpointStoreError =
39 | GitCommandError
40 | CheckpointInvariantError
41 | CheckpointUnavailableError;
38export type CheckpointStoreError = VcsError | CheckpointInvariantError | CheckpointUnavailableError;
4239
4340export type CheckpointServiceError = CheckpointStoreError | ProjectionRepositoryError;
apps/server/src/checkpointing/Layers/CheckpointStore.test.ts+20-17
......@@ -8,24 +8,27 @@ import { describe, expect } from "vitest";
88import { checkpointRefForThreadTurn } from "../Utils.ts";
99import { CheckpointStoreLive } from "./CheckpointStore.ts";
1010import { CheckpointStore } from "../Services/CheckpointStore.ts";
11import { GitCoreLive } from "../../git/Layers/GitCore.ts";
12import { GitCore } from "../../git/Services/GitCore.ts";
13import { GitCommandError } from "@t3tools/contracts";
11import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts";
12import * as VcsProcess from "../../vcs/VcsProcess.ts";
13import type { VcsError } from "@t3tools/contracts";
1414import { ServerConfig } from "../../config.ts";
1515import { ThreadId } from "@t3tools/contracts";
1616
1717const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), {
1818 prefix: "t3-checkpoint-store-test-",
1919});
20const GitCoreTestLayer = GitCoreLive.pipe(
21 Layer.provide(ServerConfigLayer),
22 Layer.provide(NodeServices.layer),
23);
20const VcsProcessTestLayer = VcsProcess.layer.pipe(Layer.provide(NodeServices.layer));
21const VcsDriverTestLayer = VcsDriverRegistry.layer.pipe(Layer.provide(VcsProcessTestLayer));
2422const CheckpointStoreTestLayer = CheckpointStoreLive.pipe(
25 Layer.provide(GitCoreTestLayer),
26 Layer.provide(NodeServices.layer),
23 Layer.provideMerge(VcsDriverTestLayer),
24 Layer.provideMerge(NodeServices.layer),
25);
26const TestLayer = CheckpointStoreTestLayer.pipe(
27 Layer.provideMerge(VcsProcessTestLayer),
28 Layer.provideMerge(VcsDriverTestLayer),
29 Layer.provideMerge(ServerConfigLayer),
30 Layer.provideMerge(NodeServices.layer),
2731);
28const TestLayer = Layer.mergeAll(NodeServices.layer, GitCoreTestLayer, CheckpointStoreTestLayer);
2932
3033function makeTmpDir(
3134 prefix = "checkpoint-store-test-",
......@@ -49,11 +52,12 @@ function writeTextFile(
4952function git(
5053 cwd: string,
5154 args: ReadonlyArray<string>,
52): Effect.Effect<string, GitCommandError, GitCore> {
55): Effect.Effect<string, VcsError, VcsProcess.VcsProcess> {
5356 return Effect.gen(function* () {
54 const gitCore = yield* GitCore;
55 const result = yield* gitCore.execute({
57 const process = yield* VcsProcess.VcsProcess;
58 const result = yield* process.run({
5659 operation: "CheckpointStore.test.git",
60 command: "git",
5761 cwd,
5862 args,
5963 timeoutMs: 10_000,
......@@ -66,12 +70,11 @@ function initRepoWithCommit(
6670 cwd: string,
6771): Effect.Effect<
6872 void,
69 GitCommandError | PlatformError.PlatformError,
70 GitCore | FileSystem.FileSystem
73 VcsError | PlatformError.PlatformError,
74 VcsProcess.VcsProcess | FileSystem.FileSystem
7175> {
7276 return Effect.gen(function* () {
73 const core = yield* GitCore;
74 yield* core.initRepo({ cwd });
77 yield* git(cwd, ["init"]);
7578 yield* git(cwd, ["config", "user.email", "test@test.com"]);
7679 yield* git(cwd, ["config", "user.name", "Test"]);
7780 yield* writeTextFile(path.join(cwd, "README.md"), "# test\n");
apps/server/src/checkpointing/Layers/CheckpointStore.ts+47-31
......@@ -14,8 +14,8 @@ import { randomUUID } from "node:crypto";
1414import { Effect, Layer, FileSystem, Path } from "effect";
1515
1616import { CheckpointInvariantError } from "../Errors.ts";
17import { GitCommandError } from "@t3tools/contracts";
18import { GitCore } from "../../git/Services/GitCore.ts";
17import { VcsProcessExitError } from "@t3tools/contracts";
18import { VcsDriverRegistry } from "../../vcs/VcsDriverRegistry.ts";
1919import { CheckpointStore, type CheckpointStoreShape } from "../Services/CheckpointStore.ts";
2020import { CheckpointRef } from "@t3tools/contracts";
2121
......@@ -24,10 +24,26 @@ const CHECKPOINT_DIFF_MAX_OUTPUT_BYTES = 10_000_000;
2424const makeCheckpointStore = Effect.gen(function* () {
2525 const fs = yield* FileSystem.FileSystem;
2626 const path = yield* Path.Path;
27 const git = yield* GitCore;
28
29 const resolveHeadCommit = (cwd: string): Effect.Effect<string | null, GitCommandError> =>
30 git
27 const vcsRegistry = yield* VcsDriverRegistry;
28 const vcs = {
29 execute: (input: {
30 readonly operation: string;
31 readonly cwd: string;
32 readonly args: ReadonlyArray<string>;
33 readonly stdin?: string;
34 readonly env?: NodeJS.ProcessEnv;
35 readonly allowNonZeroExit?: boolean;
36 readonly timeoutMs?: number;
37 readonly maxOutputBytes?: number;
38 readonly truncateOutputAtMaxBytes?: boolean;
39 }) =>
40 vcsRegistry
41 .resolve({ cwd: input.cwd, requestedKind: "git" })
42 .pipe(Effect.flatMap((handle) => handle.driver.execute(input))),
43 };
44
45 const resolveHeadCommit = (cwd: string) =>
46 vcs
3147 .execute({
3248 operation: "CheckpointStore.resolveHeadCommit",
3349 cwd,
......@@ -36,7 +52,7 @@ const makeCheckpointStore = Effect.gen(function* () {
3652 })
3753 .pipe(
3854 Effect.map((result) => {
39 if (result.code !== 0) {
55 if (result.exitCode !== 0) {
4056 return null;
4157 }
4258 const commit = result.stdout.trim();
......@@ -44,21 +60,18 @@ const makeCheckpointStore = Effect.gen(function* () {
4460 }),
4561 );
4662
47 const hasHeadCommit = (cwd: string): Effect.Effect<boolean, GitCommandError> =>
48 git
63 const hasHeadCommit = (cwd: string) =>
64 vcs
4965 .execute({
5066 operation: "CheckpointStore.hasHeadCommit",
5167 cwd,
5268 args: ["rev-parse", "--verify", "HEAD"],
5369 allowNonZeroExit: true,
5470 })
55 .pipe(Effect.map((result) => result.code === 0));
71 .pipe(Effect.map((result) => result.exitCode === 0));
5672
57 const resolveCheckpointCommit = (
58 cwd: string,
59 checkpointRef: CheckpointRef,
60 ): Effect.Effect<string | null, GitCommandError> =>
61 git
73 const resolveCheckpointCommit = (cwd: string, checkpointRef: CheckpointRef) =>
74 vcs
6275 .execute({
6376 operation: "CheckpointStore.resolveCheckpointCommit",
6477 cwd,
......@@ -67,7 +80,7 @@ const makeCheckpointStore = Effect.gen(function* () {
6780 })
6881 .pipe(
6982 Effect.map((result) => {
70 if (result.code !== 0) {
83 if (result.exitCode !== 0) {
7184 return null;
7285 }
7386 const commit = result.stdout.trim();
......@@ -76,7 +89,7 @@ const makeCheckpointStore = Effect.gen(function* () {
7689 );
7790
7891 const isGitRepository: CheckpointStoreShape["isGitRepository"] = (cwd) =>
79 git
92 vcs
8093 .execute({
8194 operation: "CheckpointStore.isGitRepository",
8295 cwd,
......@@ -84,7 +97,7 @@ const makeCheckpointStore = Effect.gen(function* () {
8497 allowNonZeroExit: true,
8598 })
8699 .pipe(
87 Effect.map((result) => result.code === 0 && result.stdout.trim() === "true"),
100 Effect.map((result) => result.exitCode === 0 && result.stdout.trim() === "true"),
88101 Effect.catch(() => Effect.succeed(false)),
89102 );
90103
......@@ -108,7 +121,7 @@ const makeCheckpointStore = Effect.gen(function* () {
108121
109122 const headExists = yield* hasHeadCommit(input.cwd);
110123 if (headExists) {
111 yield* git.execute({
124 yield* vcs.execute({
112125 operation,
113126 cwd: input.cwd,
114127 args: ["read-tree", "HEAD"],
......@@ -116,14 +129,14 @@ const makeCheckpointStore = Effect.gen(function* () {
116129 });
117130 }
118131
119 yield* git.execute({
132 yield* vcs.execute({
120133 operation,
121134 cwd: input.cwd,
122135 args: ["add", "-A", "--", "."],
123136 env: commitEnv,
124137 });
125138
126 const writeTreeResult = yield* git.execute({
139 const writeTreeResult = yield* vcs.execute({
127140 operation,
128141 cwd: input.cwd,
129142 args: ["write-tree"],
......@@ -131,16 +144,17 @@ const makeCheckpointStore = Effect.gen(function* () {
131144 });
132145 const treeOid = writeTreeResult.stdout.trim();
133146 if (treeOid.length === 0) {
134 return yield* new GitCommandError({
147 return yield* new VcsProcessExitError({
135148 operation,
136149 command: "git write-tree",
137150 cwd: input.cwd,
151 exitCode: 0,
138152 detail: "git write-tree returned an empty tree oid.",
139153 });
140154 }
141155
142156 const message = `t3 checkpoint ref=${input.checkpointRef}`;
143 const commitTreeResult = yield* git.execute({
157 const commitTreeResult = yield* vcs.execute({
144158 operation,
145159 cwd: input.cwd,
146160 args: ["commit-tree", treeOid, "-m", message],
......@@ -148,15 +162,16 @@ const makeCheckpointStore = Effect.gen(function* () {
148162 });
149163 const commitOid = commitTreeResult.stdout.trim();
150164 if (commitOid.length === 0) {
151 return yield* new GitCommandError({
165 return yield* new VcsProcessExitError({
152166 operation,
153167 command: "git commit-tree",
154168 cwd: input.cwd,
169 exitCode: 0,
155170 detail: "git commit-tree returned an empty commit oid.",
156171 });
157172 }
158173
159 yield* git.execute({
174 yield* vcs.execute({
160175 operation,
161176 cwd: input.cwd,
162177 args: ["update-ref", input.checkpointRef, commitOid],
......@@ -197,12 +212,12 @@ const makeCheckpointStore = Effect.gen(function* () {
197212 return false;
198213 }
199214
200 yield* git.execute({
215 yield* vcs.execute({
201216 operation,
202217 cwd: input.cwd,
203218 args: ["restore", "--source", commitOid, "--worktree", "--staged", "--", "."],
204219 });
205 yield* git.execute({
220 yield* vcs.execute({
206221 operation,
207222 cwd: input.cwd,
208223 args: ["clean", "-fd", "--", "."],
......@@ -210,7 +225,7 @@ const makeCheckpointStore = Effect.gen(function* () {
210225
211226 const headExists = yield* hasHeadCommit(input.cwd);
212227 if (headExists) {
213 yield* git.execute({
228 yield* vcs.execute({
214229 operation,
215230 cwd: input.cwd,
216231 args: ["reset", "--quiet", "--", "."],
......@@ -235,15 +250,16 @@ const makeCheckpointStore = Effect.gen(function* () {
235250 }
236251
237252 if (!fromCommitOid || !toCommitOid) {
238 return yield* new GitCommandError({
253 return yield* new VcsProcessExitError({
239254 operation,
240255 command: "git diff",
241256 cwd: input.cwd,
257 exitCode: 1,
242258 detail: "Checkpoint ref is unavailable for diff operation.",
243259 });
244260 }
245261
246 const result = yield* git.execute({
262 const result = yield* vcs.execute({
247263 operation,
248264 cwd: input.cwd,
249265 args: ["diff", "--patch", "--minimal", "--no-color", fromCommitOid, toCommitOid],
......@@ -262,7 +278,7 @@ const makeCheckpointStore = Effect.gen(function* () {
262278 yield* Effect.forEach(
263279 input.checkpointRefs,
264280 (checkpointRef) =>
265 git.execute({
281 vcs.execute({
266282 operation,
267283 cwd: input.cwd,
268284 args: ["update-ref", "-d", checkpointRef],
apps/server/src/git/GitManager.test.ts created+3179
......@@ -0,0 +1,3179 @@
1import fs from "node:fs";
2import path from "node:path";
3import { spawnSync } from "node:child_process";
4
5import * as NodeServices from "@effect/platform-node/NodeServices";
6import { it } from "@effect/vitest";
7import { Effect, FileSystem, Layer, PlatformError, Scope } from "effect";
8import { ChildProcessSpawner } from "effect/unstable/process";
9import { expect } from "vitest";
10import type {
11 GitActionProgressEvent,
12 GitPreparePullRequestThreadInput,
13 ModelSelection,
14 ThreadId,
15} from "@t3tools/contracts";
16
17import { GitCommandError, GitHubCliError, TextGenerationError } from "@t3tools/contracts";
18import { type GitManagerShape } from "./GitManager.ts";
19import {
20 type GitHubCliShape,
21 type GitHubPullRequestSummary,
22 GitHubCli,
23} from "../sourceControl/GitHubCli.ts";
24import { type TextGenerationShape, TextGeneration } from "../textGeneration/TextGeneration.ts";
25import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
26import * as VcsProcess from "../vcs/VcsProcess.ts";
27import * as GitHubSourceControlProvider from "../sourceControl/GitHubSourceControlProvider.ts";
28import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
29import { makeGitManager } from "./GitManager.ts";
30import { ServerConfig } from "../config.ts";
31import { ServerSettingsService } from "../serverSettings.ts";
32import {
33 ProjectSetupScriptRunner,
34 type ProjectSetupScriptRunnerInput,
35 type ProjectSetupScriptRunnerShape,
36} from "../project/Services/ProjectSetupScriptRunner.ts";
37
38interface FakeGhScenario {
39 prListSequence?: string[];
40 prListByHeadSelector?: Record<string, string>;
41 prListSequenceByHeadSelector?: Record<string, string[]>;
42 createdPrUrl?: string;
43 defaultBranch?: string;
44 pullRequest?: {
45 number: number;
46 title: string;
47 url: string;
48 baseRefName: string;
49 headRefName: string;
50 state?: "open" | "closed" | "merged";
51 isCrossRepository?: boolean;
52 headRepositoryNameWithOwner?: string | null;
53 headRepositoryOwnerLogin?: string | null;
54 };
55 repositoryCloneUrls?: Record<string, { url: string; sshUrl: string }>;
56 failWith?: GitHubCliError;
57}
58
59function fakeGhOutput(stdout: string): VcsProcess.VcsProcessOutput {
60 return {
61 exitCode: ChildProcessSpawner.ExitCode(0),
62 stdout,
63 stderr: "",
64 stdoutTruncated: false,
65 stderrTruncated: false,
66 };
67}
68
69interface FakeGitTextGeneration {
70 generateCommitMessage: (input: {
71 cwd: string;
72 branch: string | null;
73 stagedSummary: string;
74 stagedPatch: string;
75 includeBranch?: boolean;
76 modelSelection: ModelSelection;
77 }) => Effect.Effect<
78 { subject: string; body: string; branch?: string | undefined },
79 TextGenerationError
80 >;
81 generatePrContent: (input: {
82 cwd: string;
83 baseBranch: string;
84 headBranch: string;
85 commitSummary: string;
86 diffSummary: string;
87 diffPatch: string;
88 modelSelection: ModelSelection;
89 }) => Effect.Effect<{ title: string; body: string }, TextGenerationError>;
90 generateBranchName: (input: {
91 cwd: string;
92 message: string;
93 modelSelection: ModelSelection;
94 }) => Effect.Effect<{ branch: string }, TextGenerationError>;
95 generateThreadTitle: (input: {
96 cwd: string;
97 message: string;
98 modelSelection: ModelSelection;
99 }) => Effect.Effect<{ title: string }, TextGenerationError>;
100}
101
102type FakePullRequest = NonNullable<FakeGhScenario["pullRequest"]>;
103
104function normalizeFakePullRequestSummary(raw: unknown): GitHubPullRequestSummary | null {
105 if (!raw || typeof raw !== "object") {
106 return null;
107 }
108
109 const record = raw as Record<string, unknown>;
110 const number = record.number;
111 const title = record.title;
112 const url = record.url;
113 const baseRefName = record.baseRefName;
114 const headRefName = record.headRefName;
115 const headRepository =
116 typeof record.headRepository === "object" && record.headRepository !== null
117 ? (record.headRepository as Record<string, unknown>)
118 : null;
119 const headRepositoryOwner =
120 typeof record.headRepositoryOwner === "object" && record.headRepositoryOwner !== null
121 ? (record.headRepositoryOwner as Record<string, unknown>)
122 : null;
123
124 if (
125 typeof number !== "number" ||
126 typeof title !== "string" ||
127 typeof url !== "string" ||
128 typeof baseRefName !== "string" ||
129 typeof headRefName !== "string"
130 ) {
131 return null;
132 }
133
134 const state =
135 typeof record.state === "string"
136 ? record.state === "OPEN" || record.state === "open"
137 ? "open"
138 : record.state === "CLOSED" || record.state === "closed"
139 ? "closed"
140 : "merged"
141 : undefined;
142 const isCrossRepository =
143 typeof record.isCrossRepository === "boolean" ? record.isCrossRepository : undefined;
144 const headRepositoryNameWithOwner =
145 typeof record.headRepositoryNameWithOwner === "string"
146 ? record.headRepositoryNameWithOwner
147 : typeof headRepository?.nameWithOwner === "string"
148 ? headRepository.nameWithOwner
149 : undefined;
150 const headRepositoryOwnerLogin =
151 typeof record.headRepositoryOwnerLogin === "string"
152 ? record.headRepositoryOwnerLogin
153 : typeof headRepositoryOwner?.login === "string"
154 ? headRepositoryOwner.login
155 : undefined;
156
157 return {
158 number,
159 title,
160 url,
161 baseRefName,
162 headRefName,
163 ...(state ? { state } : {}),
164 ...(isCrossRepository !== undefined ? { isCrossRepository } : {}),
165 ...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}),
166 ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}),
167 };
168}
169
170function runGitSyncForFakeGh(cwd: string, args: readonly string[]): void {
171 const result = spawnSync("git", args, {
172 cwd,
173 encoding: "utf8",
174 });
175 if (result.status === 0) {
176 return;
177 }
178 throw new GitHubCliError({
179 operation: "execute",
180 detail: `Failed to simulate gh checkout with git ${args.join(" ")}: ${result.stderr?.trim() || "unknown error"}`,
181 });
182}
183
184function isGitHubCliError(error: unknown): error is GitHubCliError {
185 return (
186 typeof error === "object" &&
187 error !== null &&
188 "_tag" in error &&
189 (error as { _tag?: unknown })._tag === "GitHubCliError"
190 );
191}
192
193function makeTempDir(
194 prefix: string,
195): Effect.Effect<string, PlatformError.PlatformError, FileSystem.FileSystem | Scope.Scope> {
196 return Effect.gen(function* () {
197 const fileSystem = yield* FileSystem.FileSystem;
198 return yield* fileSystem.makeTempDirectoryScoped({ prefix });
199 });
200}
201
202function removePath(
203 targetPath: string,
204): Effect.Effect<void, PlatformError.PlatformError, FileSystem.FileSystem> {
205 return Effect.gen(function* () {
206 const fileSystem = yield* FileSystem.FileSystem;
207 yield* fileSystem.remove(targetPath, { recursive: true, force: true });
208 });
209}
210
211function makeDirectory(
212 dirPath: string,
213): Effect.Effect<void, PlatformError.PlatformError, FileSystem.FileSystem> {
214 return Effect.gen(function* () {
215 const fileSystem = yield* FileSystem.FileSystem;
216 yield* fileSystem.makeDirectory(dirPath, { recursive: true });
217 });
218}
219
220function runGit(
221 cwd: string,
222 args: readonly string[],
223 allowNonZeroExit = false,
224): Effect.Effect<
225 {
226 readonly exitCode: GitVcsDriver.ExecuteGitResult["exitCode"];
227 readonly stdout: string;
228 readonly stderr: string;
229 },
230 GitCommandError,
231 GitVcsDriver.GitVcsDriver
232> {
233 return Effect.gen(function* () {
234 const git = yield* GitVcsDriver.GitVcsDriver;
235 const result = yield* git.execute({
236 operation: "GitManager.test.runGit",
237 cwd,
238 args,
239 allowNonZeroExit,
240 });
241 return {
242 exitCode: result.exitCode,
243 stdout: result.stdout,
244 stderr: result.stderr,
245 };
246 });
247}
248
249function initRepo(
250 cwd: string,
251): Effect.Effect<
252 void,
253 PlatformError.PlatformError | GitCommandError,
254 FileSystem.FileSystem | Scope.Scope | GitVcsDriver.GitVcsDriver
255> {
256 return Effect.gen(function* () {
257 const fs = yield* FileSystem.FileSystem;
258 yield* runGit(cwd, ["init", "--initial-branch=main"]);
259 yield* runGit(cwd, ["config", "user.email", "test@example.com"]);
260 yield* runGit(cwd, ["config", "user.name", "Test User"]);
261 yield* fs.writeFileString(path.join(cwd, "README.md"), "hello\n");
262 yield* runGit(cwd, ["add", "README.md"]);
263 yield* runGit(cwd, ["commit", "-m", "Initial commit"]);
264 });
265}
266
267function createBareRemote(): Effect.Effect<
268 string,
269 PlatformError.PlatformError | GitCommandError,
270 FileSystem.FileSystem | Scope.Scope | GitVcsDriver.GitVcsDriver
271> {
272 return Effect.gen(function* () {
273 const remoteDir = yield* makeTempDir("t3code-git-remote-");
274 yield* runGit(remoteDir, ["init", "--bare"]);
275 return remoteDir;
276 });
277}
278
279function configureRemote(
280 cwd: string,
281 remoteName: string,
282 remotePath: string,
283 fetchNamespace: string,
284): Effect.Effect<void, GitCommandError, GitVcsDriver.GitVcsDriver> {
285 return Effect.gen(function* () {
286 yield* runGit(cwd, ["config", `remote.${remoteName}.url`, remotePath]);
287 yield* runGit(cwd, [
288 "config",
289 "--replace-all",
290 `remote.${remoteName}.fetch`,
291 `+refs/heads/*:refs/remotes/${fetchNamespace}/*`,
292 ]);
293 });
294}
295
296function createTextGeneration(overrides: Partial<FakeGitTextGeneration> = {}): TextGenerationShape {
297 const implementation: FakeGitTextGeneration = {
298 generateCommitMessage: (input) =>
299 Effect.succeed({
300 subject: "Implement stacked git actions",
301 body: "",
302 ...(input.includeBranch ? { branch: "feature/implement-stacked-git-actions" } : {}),
303 }),
304 generatePrContent: () =>
305 Effect.succeed({
306 title: "Add stacked git actions",
307 body: "## Summary\n- Add stacked git workflow\n\n## Testing\n- Not run",
308 }),
309 generateBranchName: () =>
310 Effect.succeed({
311 branch: "update-workflow",
312 }),
313 generateThreadTitle: () =>
314 Effect.succeed({
315 title: "Update workflow",
316 }),
317 ...overrides,
318 };
319
320 return {
321 generateCommitMessage: (input) =>
322 implementation.generateCommitMessage(input).pipe(
323 Effect.mapError(
324 (cause) =>
325 new TextGenerationError({
326 operation: "generateCommitMessage",
327 detail: "fake text generation failed",
328 ...(cause !== undefined ? { cause } : {}),
329 }),
330 ),
331 ),
332 generatePrContent: (input) =>
333 implementation.generatePrContent(input).pipe(
334 Effect.mapError(
335 (cause) =>
336 new TextGenerationError({
337 operation: "generatePrContent",
338 detail: "fake text generation failed",
339 ...(cause !== undefined ? { cause } : {}),
340 }),
341 ),
342 ),
343 generateBranchName: (input) =>
344 implementation.generateBranchName(input).pipe(
345 Effect.mapError(
346 (cause) =>
347 new TextGenerationError({
348 operation: "generateBranchName",
349 detail: "fake text generation failed",
350 ...(cause !== undefined ? { cause } : {}),
351 }),
352 ),
353 ),
354 generateThreadTitle: (input) =>
355 implementation.generateThreadTitle(input).pipe(
356 Effect.mapError(
357 (cause) =>
358 new TextGenerationError({
359 operation: "generateThreadTitle",
360 detail: "fake text generation failed",
361 ...(cause !== undefined ? { cause } : {}),
362 }),
363 ),
364 ),
365 };
366}
367
368function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
369 service: GitHubCliShape;
370 ghCalls: string[];
371} {
372 const prListQueue = [...(scenario.prListSequence ?? [])];
373 const prListQueueByHeadSelector = new Map(
374 Object.entries(scenario.prListSequenceByHeadSelector ?? {}).map(([headSelector, values]) => [
375 headSelector,
376 [...values],
377 ]),
378 );
379 const ghCalls: string[] = [];
380
381 const execute: GitHubCliShape["execute"] = (input) => {
382 const args = [...input.args];
383 ghCalls.push(args.join(" "));
384
385 if (scenario.failWith) {
386 return Effect.fail(scenario.failWith);
387 }
388
389 if (args[0] === "pr" && args[1] === "list") {
390 const headSelectorIndex = args.findIndex((value) => value === "--head");
391 const headSelector =
392 headSelectorIndex >= 0 && headSelectorIndex < args.length - 1
393 ? args[headSelectorIndex + 1]
394 : undefined;
395 const mappedQueue =
396 typeof headSelector === "string"
397 ? prListQueueByHeadSelector.get(headSelector)?.shift()
398 : undefined;
399 const mappedStdout =
400 typeof headSelector === "string"
401 ? scenario.prListByHeadSelector?.[headSelector]
402 : undefined;
403 const stdout = (mappedQueue ?? mappedStdout ?? prListQueue.shift() ?? "[]") + "\n";
404 return Effect.succeed(fakeGhOutput(stdout));
405 }
406
407 if (args[0] === "pr" && args[1] === "create") {
408 return Effect.succeed(
409 fakeGhOutput(
410 (scenario.createdPrUrl ?? "https://github.com/pingdotgg/codething-mvp/pull/101") + "\n",
411 ),
412 );
413 }
414
415 if (args[0] === "pr" && args[1] === "view") {
416 const pullRequest: FakePullRequest = scenario.pullRequest ?? {
417 number: 101,
418 title: "Pull request",
419 url: "https://github.com/pingdotgg/codething-mvp/pull/101",
420 baseRefName: "main",
421 headRefName: "feature/pull-request",
422 state: "open",
423 };
424 return Effect.succeed(
425 fakeGhOutput(
426 JSON.stringify({
427 ...pullRequest,
428 ...(pullRequest.headRepositoryNameWithOwner
429 ? {
430 headRepository: {
431 nameWithOwner: pullRequest.headRepositoryNameWithOwner,
432 },
433 }
434 : {}),
435 ...(pullRequest.headRepositoryOwnerLogin
436 ? {
437 headRepositoryOwner: {
438 login: pullRequest.headRepositoryOwnerLogin,
439 },
440 }
441 : {}),
442 }) + "\n",
443 ),
444 );
445 }
446
447 if (args[0] === "pr" && args[1] === "checkout") {
448 return Effect.try({
449 try: () => {
450 const headBranch = scenario.pullRequest?.headRefName;
451 if (headBranch) {
452 const existingBranch = spawnSync(
453 "git",
454 ["show-ref", "--verify", "--quiet", `refs/heads/${headBranch}`],
455 {
456 cwd: input.cwd,
457 encoding: "utf8",
458 },
459 );
460 if (existingBranch.status === 0) {
461 runGitSyncForFakeGh(input.cwd, ["checkout", headBranch]);
462 } else {
463 runGitSyncForFakeGh(input.cwd, ["checkout", "-b", headBranch]);
464 }
465 }
466 return fakeGhOutput("");
467 },
468 catch: (error) =>
469 isGitHubCliError(error)
470 ? error
471 : new GitHubCliError({
472 operation: "execute",
473 detail:
474 error instanceof Error
475 ? `Failed to simulate gh checkout: ${error.message}`
476 : "Failed to simulate gh checkout.",
477 }),
478 });
479 }
480
481 if (args[0] === "repo" && args[1] === "view") {
482 const repository = args[2];
483 if (typeof repository === "string" && args.includes("nameWithOwner,url,sshUrl")) {
484 const cloneUrls = scenario.repositoryCloneUrls?.[repository];
485 if (!cloneUrls) {
486 return Effect.fail(
487 new GitHubCliError({
488 operation: "execute",
489 detail: `Unexpected repository lookup: ${repository}`,
490 }),
491 );
492 }
493 return Effect.succeed(
494 fakeGhOutput(
495 JSON.stringify({
496 nameWithOwner: repository,
497 url: cloneUrls.url,
498 sshUrl: cloneUrls.sshUrl,
499 }) + "\n",
500 ),
501 );
502 }
503 return Effect.succeed(fakeGhOutput(`${scenario.defaultBranch ?? "main"}\n`));
504 }
505
506 return Effect.fail(
507 new GitHubCliError({
508 operation: "execute",
509 detail: `Unexpected gh command: ${args.join(" ")}`,
510 }),
511 );
512 };
513
514 return {
515 service: {
516 execute,
517 listOpenPullRequests: (input) =>
518 execute({
519 cwd: input.cwd,
520 args: [
521 "pr",
522 "list",
523 "--head",
524 input.headSelector,
525 "--state",
526 "open",
527 "--limit",
528 String(input.limit ?? 1),
529 "--json",
530 "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner",
531 ],
532 }).pipe(
533 Effect.map((result) => JSON.parse(result.stdout) as unknown[]),
534 Effect.map((raw) =>
535 raw
536 .map((entry) => normalizeFakePullRequestSummary(entry))
537 .filter((entry): entry is GitHubPullRequestSummary => entry !== null),
538 ),
539 ),
540 createPullRequest: (input) =>
541 execute({
542 cwd: input.cwd,
543 args: [
544 "pr",
545 "create",
546 "--base",
547 input.baseBranch,
548 "--head",
549 input.headSelector,
550 "--title",
551 input.title,
552 "--body-file",
553 input.bodyFile,
554 ],
555 }).pipe(Effect.asVoid),
556 getDefaultBranch: (input) =>
557 execute({
558 cwd: input.cwd,
559 args: ["repo", "view", "--json", "defaultBranchRef", "--jq", ".defaultBranchRef.name"],
560 }).pipe(
561 Effect.map((result) => {
562 const value = result.stdout.trim();
563 return value.length > 0 ? value : null;
564 }),
565 ),
566 getPullRequest: (input) =>
567 execute({
568 cwd: input.cwd,
569 args: [
570 "pr",
571 "view",
572 input.reference,
573 "--json",
574 "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner",
575 ],
576 }).pipe(Effect.map((result) => JSON.parse(result.stdout) as GitHubPullRequestSummary)),
577 getRepositoryCloneUrls: (input) =>
578 execute({
579 cwd: input.cwd,
580 args: ["repo", "view", input.repository, "--json", "nameWithOwner,url,sshUrl"],
581 }).pipe(Effect.map((result) => JSON.parse(result.stdout))),
582 checkoutPullRequest: (input) =>
583 execute({
584 cwd: input.cwd,
585 args: ["pr", "checkout", input.reference, ...(input.force ? ["--force"] : [])],
586 }).pipe(Effect.asVoid),
587 },
588 ghCalls,
589 };
590}
591
592function runStackedAction(
593 manager: GitManagerShape,
594 input: {
595 cwd: string;
596 action: "commit" | "push" | "create_pr" | "commit_push" | "commit_push_pr";
597 actionId?: string;
598 commitMessage?: string;
599 featureBranch?: boolean;
600 filePaths?: readonly string[];
601 },
602 options?: Parameters<GitManagerShape["runStackedAction"]>[1],
603) {
604 return manager.runStackedAction(
605 {
606 ...input,
607 actionId: input.actionId ?? "test-action-id",
608 },
609 options,
610 );
611}
612
613function resolvePullRequest(manager: GitManagerShape, input: { cwd: string; reference: string }) {
614 return manager.resolvePullRequest(input);
615}
616
617function preparePullRequestThread(
618 manager: GitManagerShape,
619 input: GitPreparePullRequestThreadInput,
620) {
621 return manager.preparePullRequestThread(input);
622}
623
624function makeManager(input?: {
625 ghScenario?: FakeGhScenario;
626 textGeneration?: Partial<FakeGitTextGeneration>;
627 setupScriptRunner?: ProjectSetupScriptRunnerShape;
628}) {
629 const { service: gitHubCli, ghCalls } = createGitHubCliWithFakeGh(input?.ghScenario);
630 const textGeneration = createTextGeneration(input?.textGeneration);
631 const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), {
632 prefix: "t3-git-manager-test-",
633 });
634
635 const serverSettingsLayer = ServerSettingsService.layerTest();
636
637 const vcsDriverLayer = GitVcsDriver.layer.pipe(
638 Layer.provideMerge(VcsProcess.layer),
639 Layer.provideMerge(NodeServices.layer),
640 Layer.provideMerge(ServerConfigLayer),
641 );
642 const sourceControlRegistryLayer = Layer.effect(
643 SourceControlProviderRegistry.SourceControlProviderRegistry,
644 GitHubSourceControlProvider.make().pipe(
645 Effect.map((provider) =>
646 SourceControlProviderRegistry.SourceControlProviderRegistry.of({
647 get: () => Effect.succeed(provider),
648 resolveHandle: () => Effect.succeed({ provider, context: null }),
649 resolve: () => Effect.succeed(provider),
650 }),
651 ),
652 Effect.provide(Layer.succeed(GitHubCli, gitHubCli)),
653 ),
654 );
655
656 const managerLayer = Layer.mergeAll(
657 Layer.succeed(TextGeneration, textGeneration),
658 Layer.succeed(
659 ProjectSetupScriptRunner,
660 input?.setupScriptRunner ?? {
661 runForThread: () => Effect.succeed({ status: "no-script" as const }),
662 },
663 ),
664 vcsDriverLayer,
665 serverSettingsLayer,
666 ).pipe(Layer.provideMerge(sourceControlRegistryLayer), Layer.provideMerge(NodeServices.layer));
667
668 return makeGitManager().pipe(
669 Effect.provide(managerLayer),
670 Effect.map((manager) => ({ manager, ghCalls })),
671 );
672}
673
674const asThreadId = (threadId: string) => threadId as ThreadId;
675
676const GitManagerTestLayer = GitVcsDriver.layer.pipe(
677 Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-manager-test-" })),
678 Layer.provideMerge(VcsProcess.layer),
679 Layer.provideMerge(NodeServices.layer),
680);
681
682it.layer(GitManagerTestLayer)("GitManager", (it) => {
683 it.effect("status includes PR metadata when branch already has an open PR", () =>
684 Effect.gen(function* () {
685 const repoDir = yield* makeTempDir("t3code-git-manager-");
686 yield* initRepo(repoDir);
687 yield* runGit(repoDir, ["checkout", "-b", "feature/status-open-pr"]);
688 const remoteDir = yield* createBareRemote();
689 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
690 yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-open-pr"]);
691
692 const { manager } = yield* makeManager({
693 ghScenario: {
694 prListSequence: [
695 JSON.stringify([
696 {
697 number: 13,
698 title: "Existing PR",
699 url: "https://github.com/pingdotgg/codething-mvp/pull/13",
700 baseRefName: "main",
701 headRefName: "feature/status-open-pr",
702 },
703 ]),
704 ],
705 },
706 });
707
708 const status = yield* manager.status({ cwd: repoDir });
709 expect(status.isRepo).toBe(true);
710 expect(status.hasPrimaryRemote).toBe(true);
711 expect(status.isDefaultRef).toBe(false);
712 expect(status.refName).toBe("feature/status-open-pr");
713 expect(status.pr).toEqual({
714 number: 13,
715 title: "Existing PR",
716 url: "https://github.com/pingdotgg/codething-mvp/pull/13",
717 baseRef: "main",
718 headRef: "feature/status-open-pr",
719 state: "open",
720 });
721 }),
722 );
723
724 it.effect("status trims PR metadata returned by gh before publishing it", () =>
725 Effect.gen(function* () {
726 const repoDir = yield* makeTempDir("t3code-git-manager-");
727 yield* initRepo(repoDir);
728 yield* runGit(repoDir, ["checkout", "-b", "feature/status-trimmed-pr"]);
729 const remoteDir = yield* createBareRemote();
730 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
731 yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-trimmed-pr"]);
732
733 const { manager } = yield* makeManager({
734 ghScenario: {
735 prListSequence: [
736 JSON.stringify([
737 {
738 number: 14,
739 title: " Existing PR title \n",
740 url: " https://github.com/pingdotgg/codething-mvp/pull/14 ",
741 baseRefName: " main ",
742 headRefName: "\tfeature/status-trimmed-pr\t",
743 },
744 ]),
745 ],
746 },
747 });
748
749 const status = yield* manager.status({ cwd: repoDir });
750
751 expect(status.pr).toEqual({
752 number: 14,
753 title: "Existing PR title",
754 url: "https://github.com/pingdotgg/codething-mvp/pull/14",
755 baseRef: "main",
756 headRef: "feature/status-trimmed-pr",
757 state: "open",
758 });
759 }),
760 );
761
762 it.effect("status ignores invalid gh pr list entries and keeps valid ones", () =>
763 Effect.gen(function* () {
764 const repoDir = yield* makeTempDir("t3code-git-manager-");
765 yield* initRepo(repoDir);
766 yield* runGit(repoDir, ["checkout", "-b", "feature/status-valid-pr-entry"]);
767 const remoteDir = yield* createBareRemote();
768 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
769 yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-valid-pr-entry"]);
770
771 const { manager } = yield* makeManager({
772 ghScenario: {
773 prListSequence: [
774 JSON.stringify([
775 {
776 number: 0,
777 title: "invalid",
778 url: "https://github.com/pingdotgg/codething-mvp/pull/0",
779 baseRefName: "main",
780 headRefName: "feature/invalid",
781 },
782 {
783 number: 15,
784 title: " Valid PR title ",
785 url: " https://github.com/pingdotgg/codething-mvp/pull/15 ",
786 baseRefName: " main ",
787 headRefName: "\tfeature/status-valid-pr-entry\t",
788 headRepository: {
789 nameWithOwner: " ",
790 },
791 headRepositoryOwner: {
792 login: " ",
793 },
794 },
795 ]),
796 ],
797 },
798 });
799
800 const status = yield* manager.status({ cwd: repoDir });
801
802 expect(status.pr).toEqual({
803 number: 15,
804 title: "Valid PR title",
805 url: "https://github.com/pingdotgg/codething-mvp/pull/15",
806 baseRef: "main",
807 headRef: "feature/status-valid-pr-entry",
808 state: "open",
809 });
810 }),
811 );
812
813 it.effect("status preserves lowercase merged and closed PR states from gh json", () =>
814 Effect.gen(function* () {
815 const repoDir = yield* makeTempDir("t3code-git-manager-");
816 yield* initRepo(repoDir);
817 yield* runGit(repoDir, ["checkout", "-b", "feature/status-lowercase-state"]);
818 const remoteDir = yield* createBareRemote();
819 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
820 yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-lowercase-state"]);
821
822 const { manager } = yield* makeManager({
823 ghScenario: {
824 prListSequence: [
825 JSON.stringify([
826 {
827 number: 16,
828 title: "Closed PR",
829 url: "https://github.com/pingdotgg/codething-mvp/pull/16",
830 baseRefName: "main",
831 headRefName: "feature/status-lowercase-state",
832 state: "closed",
833 updatedAt: "2026-01-01T00:00:00.000Z",
834 },
835 {
836 number: 17,
837 title: "Merged PR",
838 url: "https://github.com/pingdotgg/codething-mvp/pull/17",
839 baseRefName: "main",
840 headRefName: "feature/status-lowercase-state",
841 state: "merged",
842 updatedAt: "2026-01-02T00:00:00.000Z",
843 },
844 ]),
845 ],
846 },
847 });
848
849 const status = yield* manager.status({ cwd: repoDir });
850
851 expect(status.pr).toEqual({
852 number: 17,
853 title: "Merged PR",
854 url: "https://github.com/pingdotgg/codething-mvp/pull/17",
855 baseRef: "main",
856 headRef: "feature/status-lowercase-state",
857 state: "merged",
858 });
859 }),
860 );
861
862 it.effect("status returns an explicit non-repo result for non-git directories", () =>
863 Effect.gen(function* () {
864 const cwd = yield* makeTempDir("t3code-git-manager-non-repo-");
865 const { manager } = yield* makeManager();
866
867 const status = yield* manager.status({ cwd });
868
869 expect(status).toEqual({
870 isRepo: false,
871 hasPrimaryRemote: false,
872 isDefaultRef: false,
873 refName: null,
874 hasWorkingTreeChanges: false,
875 workingTree: {
876 files: [],
877 insertions: 0,
878 deletions: 0,
879 },
880 hasUpstream: false,
881 aheadCount: 0,
882 behindCount: 0,
883 pr: null,
884 });
885 }),
886 );
887
888 it.effect("status returns an explicit non-repo result for deleted directories", () =>
889 Effect.gen(function* () {
890 const rootDir = yield* makeTempDir("t3code-git-manager-missing-dir-");
891 const cwd = path.join(rootDir, "deleted-repo");
892 yield* makeDirectory(cwd);
893 yield* removePath(cwd);
894 const { manager } = yield* makeManager();
895
896 const status = yield* manager.status({ cwd });
897
898 expect(status).toEqual({
899 isRepo: false,
900 hasPrimaryRemote: false,
901 isDefaultRef: false,
902 refName: null,
903 hasWorkingTreeChanges: false,
904 workingTree: {
905 files: [],
906 insertions: 0,
907 deletions: 0,
908 },
909 hasUpstream: false,
910 aheadCount: 0,
911 behindCount: 0,
912 pr: null,
913 });
914 }),
915 );
916
917 it.effect("status briefly caches repeated lookups for the same cwd", () =>
918 Effect.gen(function* () {
919 const repoDir = yield* makeTempDir("t3code-git-manager-");
920 yield* initRepo(repoDir);
921 yield* runGit(repoDir, ["checkout", "-b", "feature/status-cache"]);
922 const remoteDir = yield* createBareRemote();
923 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
924 yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-cache"]);
925
926 const existingPr = {
927 number: 113,
928 title: "Cached PR",
929 url: "https://github.com/pingdotgg/codething-mvp/pull/113",
930 baseRefName: "main",
931 headRefName: "feature/status-cache",
932 };
933 const { manager, ghCalls } = yield* makeManager({
934 ghScenario: {
935 prListSequence: [JSON.stringify([existingPr]), JSON.stringify([existingPr])],
936 },
937 });
938
939 const first = yield* manager.status({ cwd: repoDir });
940 const second = yield* manager.status({ cwd: repoDir });
941
942 expect(first.pr?.number).toBe(113);
943 expect(second.pr?.number).toBe(113);
944 expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(1);
945 }),
946 );
947
948 it.effect(
949 "status ignores unrelated fork PRs when the current branch tracks the same repository",
950 () =>
951 Effect.gen(function* () {
952 const repoDir = yield* makeTempDir("t3code-git-manager-");
953 yield* initRepo(repoDir);
954 const remoteDir = yield* createBareRemote();
955 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
956 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
957
958 const { manager } = yield* makeManager({
959 ghScenario: {
960 prListSequence: [
961 JSON.stringify([
962 {
963 number: 1661,
964 title: "Fork PR from main",
965 url: "https://github.com/pingdotgg/t3code/pull/1661",
966 baseRefName: "main",
967 headRefName: "main",
968 state: "OPEN",
969 updatedAt: "2026-04-01T15:00:00Z",
970 isCrossRepository: true,
971 headRepository: {
972 nameWithOwner: "lnieuwenhuis/t3code",
973 },
974 headRepositoryOwner: {
975 login: "lnieuwenhuis",
976 },
977 },
978 ]),
979 ],
980 },
981 });
982
983 const status = yield* manager.status({ cwd: repoDir });
984 expect(status.refName).toBe("main");
985 expect(status.pr).toBeNull();
986 }),
987 );
988
989 it.effect(
990 "status detects cross-repo PRs from the upstream remote URL owner",
991 () =>
992 Effect.gen(function* () {
993 const repoDir = yield* makeTempDir("t3code-git-manager-");
994 yield* initRepo(repoDir);
995 const forkDir = yield* createBareRemote();
996 yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]);
997 yield* runGit(repoDir, ["checkout", "-b", "statemachine"]);
998 fs.writeFileSync(path.join(repoDir, "fork-pr.txt"), "fork pr\n");
999 yield* runGit(repoDir, ["add", "fork-pr.txt"]);
1000 yield* runGit(repoDir, ["commit", "-m", "Fork PR branch"]);
1001 yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]);
1002 yield* runGit(repoDir, ["checkout", "-b", "t3code/pr-488/statemachine"]);
1003 yield* runGit(repoDir, ["branch", "--set-upstream-to", "fork-seed/statemachine"]);
1004 yield* runGit(repoDir, [
1005 "config",
1006 "remote.fork-seed.url",
1007 "git@github.com:jasonLaster/codething-mvp.git",
1008 ]);
1009
1010 const { manager, ghCalls } = yield* makeManager({
1011 ghScenario: {
1012 prListSequence: [
1013 JSON.stringify([]),
1014 JSON.stringify([]),
1015 JSON.stringify([
1016 {
1017 number: 488,
1018 title: "Rebase this PR on latest main",
1019 url: "https://github.com/pingdotgg/codething-mvp/pull/488",
1020 baseRefName: "main",
1021 headRefName: "statemachine",
1022 state: "OPEN",
1023 updatedAt: "2026-03-10T07:00:00Z",
1024 isCrossRepository: true,
1025 headRepository: {
1026 nameWithOwner: "jasonLaster/codething-mvp",
1027 },
1028 headRepositoryOwner: {
1029 login: "jasonLaster",
1030 },
1031 },
1032 ]),
1033 ],
1034 },
1035 });
1036
1037 const status = yield* manager.status({ cwd: repoDir });
1038 expect(status.refName).toBe("t3code/pr-488/statemachine");
1039 expect(status.pr).toEqual({
1040 number: 488,
1041 title: "Rebase this PR on latest main",
1042 url: "https://github.com/pingdotgg/codething-mvp/pull/488",
1043 baseRef: "main",
1044 headRef: "statemachine",
1045 state: "open",
1046 });
1047 expect(ghCalls).toContain(
1048 "pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",
1049 );
1050 }),
1051 20_000,
1052 );
1053
1054 it.effect(
1055 "status ignores synthetic local branch aliases when the upstream remote name contains slashes",
1056 () =>
1057 Effect.gen(function* () {
1058 const repoDir = yield* makeTempDir("t3code-git-manager-");
1059 yield* initRepo(repoDir);
1060 const originDir = yield* createBareRemote();
1061 const upstreamDir = yield* createBareRemote();
1062 yield* configureRemote(repoDir, "origin", originDir, "origin");
1063 yield* configureRemote(repoDir, "my-org/upstream", upstreamDir, "my-org/upstream");
1064
1065 yield* runGit(repoDir, ["checkout", "-b", "effect-atom"]);
1066 yield* runGit(repoDir, ["push", "-u", "origin", "effect-atom"]);
1067 yield* runGit(repoDir, ["push", "-u", "my-org/upstream", "effect-atom"]);
1068 yield* runGit(repoDir, [
1069 "config",
1070 "remote.origin.url",
1071 "git@github.com:pingdotgg/codething-mvp.git",
1072 ]);
1073 yield* runGit(repoDir, ["config", "remote.origin.pushurl", originDir]);
1074 yield* runGit(repoDir, [
1075 "config",
1076 "remote.my-org/upstream.url",
1077 "git@github.com:pingdotgg/codething-mvp.git",
1078 ]);
1079 yield* runGit(repoDir, ["config", "remote.my-org/upstream.pushurl", upstreamDir]);
1080 yield* runGit(repoDir, ["checkout", "main"]);
1081 yield* runGit(repoDir, ["branch", "-D", "effect-atom"]);
1082 yield* runGit(repoDir, ["checkout", "--track", "my-org/upstream/effect-atom"]);
1083
1084 const { manager, ghCalls } = yield* makeManager({
1085 ghScenario: {
1086 prListByHeadSelector: {
1087 "effect-atom": JSON.stringify([
1088 {
1089 number: 1618,
1090 title: "Correct PR",
1091 url: "https://github.com/pingdotgg/t3code/pull/1618",
1092 baseRefName: "main",
1093 headRefName: "effect-atom",
1094 state: "OPEN",
1095 updatedAt: "2026-03-01T10:00:00Z",
1096 },
1097 ]),
1098 "upstream/effect-atom": JSON.stringify([
1099 {
1100 number: 1518,
1101 title: "Wrong PR",
1102 url: "https://github.com/pingdotgg/t3code/pull/1518",
1103 baseRefName: "main",
1104 headRefName: "upstream/effect-atom",
1105 state: "OPEN",
1106 updatedAt: "2026-04-01T10:00:00Z",
1107 },
1108 ]),
1109 "pingdotgg:effect-atom": JSON.stringify([]),
1110 "my-org/upstream:effect-atom": JSON.stringify([]),
1111 "pingdotgg:upstream/effect-atom": JSON.stringify([
1112 {
1113 number: 1518,
1114 title: "Wrong PR",
1115 url: "https://github.com/pingdotgg/t3code/pull/1518",
1116 baseRefName: "main",
1117 headRefName: "upstream/effect-atom",
1118 state: "OPEN",
1119 updatedAt: "2026-04-01T10:00:00Z",
1120 },
1121 ]),
1122 "my-org/upstream:upstream/effect-atom": JSON.stringify([
1123 {
1124 number: 1518,
1125 title: "Wrong PR",
1126 url: "https://github.com/pingdotgg/t3code/pull/1518",
1127 baseRefName: "main",
1128 headRefName: "upstream/effect-atom",
1129 state: "OPEN",
1130 updatedAt: "2026-04-01T10:00:00Z",
1131 },
1132 ]),
1133 },
1134 },
1135 });
1136
1137 const status = yield* manager.status({ cwd: repoDir });
1138 expect(status.refName).toBe("upstream/effect-atom");
1139 expect(status.pr).toEqual({
1140 number: 1618,
1141 title: "Correct PR",
1142 url: "https://github.com/pingdotgg/t3code/pull/1618",
1143 baseRef: "main",
1144 headRef: "effect-atom",
1145 state: "open",
1146 });
1147 expect(ghCalls.some((call) => call.includes("pr list --head upstream/effect-atom "))).toBe(
1148 false,
1149 );
1150 expect(
1151 ghCalls.some((call) => call.includes("pr list --head pingdotgg:upstream/effect-atom ")),
1152 ).toBe(false);
1153 expect(
1154 ghCalls.some((call) =>
1155 call.includes("pr list --head my-org/upstream:upstream/effect-atom "),
1156 ),
1157 ).toBe(false);
1158 }),
1159 20_000,
1160 );
1161
1162 it.effect("status returns merged PR state when latest PR was merged", () =>
1163 Effect.gen(function* () {
1164 const repoDir = yield* makeTempDir("t3code-git-manager-");
1165 yield* initRepo(repoDir);
1166 yield* runGit(repoDir, ["checkout", "-b", "feature/status-merged-pr"]);
1167
1168 const { manager } = yield* makeManager({
1169 ghScenario: {
1170 prListSequence: [
1171 JSON.stringify([
1172 {
1173 number: 22,
1174 title: "Merged PR",
1175 url: "https://github.com/pingdotgg/codething-mvp/pull/22",
1176 baseRefName: "main",
1177 headRefName: "feature/status-merged-pr",
1178 state: "MERGED",
1179 mergedAt: "2026-01-30T10:00:00Z",
1180 updatedAt: "2026-01-30T10:00:00Z",
1181 },
1182 ]),
1183 ],
1184 },
1185 });
1186
1187 const status = yield* manager.status({ cwd: repoDir });
1188 expect(status.refName).toBe("feature/status-merged-pr");
1189 expect(status.pr).toEqual({
1190 number: 22,
1191 title: "Merged PR",
1192 url: "https://github.com/pingdotgg/codething-mvp/pull/22",
1193 baseRef: "main",
1194 headRef: "feature/status-merged-pr",
1195 state: "merged",
1196 });
1197 }),
1198 );
1199
1200 it.effect("status hides merged PRs on the default branch", () =>
1201 Effect.gen(function* () {
1202 const repoDir = yield* makeTempDir("t3code-git-manager-");
1203 yield* initRepo(repoDir);
1204
1205 const { manager } = yield* makeManager({
1206 ghScenario: {
1207 prListSequence: [
1208 JSON.stringify([
1209 {
1210 number: 23,
1211 title: "Merged PR",
1212 url: "https://github.com/pingdotgg/codething-mvp/pull/23",
1213 baseRefName: "feature/status-default-branch-target",
1214 headRefName: "main",
1215 state: "MERGED",
1216 mergedAt: "2026-01-30T10:00:00Z",
1217 updatedAt: "2026-01-30T10:00:00Z",
1218 },
1219 ]),
1220 ],
1221 },
1222 });
1223
1224 const status = yield* manager.status({ cwd: repoDir });
1225 expect(status.refName).toBe("main");
1226 expect(status.pr).toBeNull();
1227 }),
1228 );
1229
1230 it.effect("status prefers open PR when merged PR has newer updatedAt", () =>
1231 Effect.gen(function* () {
1232 const repoDir = yield* makeTempDir("t3code-git-manager-");
1233 yield* initRepo(repoDir);
1234 yield* runGit(repoDir, ["checkout", "-b", "feature/status-open-over-merged"]);
1235
1236 const { manager } = yield* makeManager({
1237 ghScenario: {
1238 prListSequence: [
1239 JSON.stringify([
1240 {
1241 number: 45,
1242 title: "Merged PR",
1243 url: "https://github.com/pingdotgg/codething-mvp/pull/45",
1244 baseRefName: "main",
1245 headRefName: "feature/status-open-over-merged",
1246 state: "MERGED",
1247 mergedAt: "2026-01-31T10:00:00Z",
1248 updatedAt: "2026-02-01T10:00:00Z",
1249 },
1250 {
1251 number: 46,
1252 title: "Open PR",
1253 url: "https://github.com/pingdotgg/codething-mvp/pull/46",
1254 baseRefName: "main",
1255 headRefName: "feature/status-open-over-merged",
1256 state: "OPEN",
1257 updatedAt: "2026-01-30T10:00:00Z",
1258 },
1259 ]),
1260 ],
1261 },
1262 });
1263
1264 const status = yield* manager.status({ cwd: repoDir });
1265 expect(status.refName).toBe("feature/status-open-over-merged");
1266 expect(status.pr).toEqual({
1267 number: 46,
1268 title: "Open PR",
1269 url: "https://github.com/pingdotgg/codething-mvp/pull/46",
1270 baseRef: "main",
1271 headRef: "feature/status-open-over-merged",
1272 state: "open",
1273 });
1274 }),
1275 );
1276
1277 it.effect("status is resilient to gh lookup failures and returns pr null", () =>
1278 Effect.gen(function* () {
1279 const repoDir = yield* makeTempDir("t3code-git-manager-");
1280 yield* initRepo(repoDir);
1281 yield* runGit(repoDir, ["checkout", "-b", "feature/status-no-gh"]);
1282 const remoteDir = yield* createBareRemote();
1283 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
1284 yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-no-gh"]);
1285
1286 const { manager } = yield* makeManager({
1287 ghScenario: {
1288 failWith: new GitHubCliError({
1289 operation: "execute",
1290 detail: "GitHub CLI (`gh`) is required but not available on PATH.",
1291 }),
1292 },
1293 });
1294
1295 const status = yield* manager.status({ cwd: repoDir });
1296 expect(status.refName).toBe("feature/status-no-gh");
1297 expect(status.pr).toBeNull();
1298 }),
1299 );
1300
1301 it.effect("creates a commit when working tree is dirty", () =>
1302 Effect.gen(function* () {
1303 const repoDir = yield* makeTempDir("t3code-git-manager-");
1304 yield* initRepo(repoDir);
1305 fs.writeFileSync(path.join(repoDir, "README.md"), "hello\nworld\n");
1306
1307 const { manager } = yield* makeManager();
1308 const result = yield* runStackedAction(manager, {
1309 cwd: repoDir,
1310 action: "commit",
1311 });
1312
1313 expect(result.branch.status).toBe("skipped_not_requested");
1314 expect(result.commit.status).toBe("created");
1315 expect(result.push.status).toBe("skipped_not_requested");
1316 expect(result.pr.status).toBe("skipped_not_requested");
1317 expect(result.toast).toMatchObject({
1318 description: "Implement stacked git actions",
1319 cta: {
1320 kind: "run_action",
1321 label: "Push",
1322 action: {
1323 kind: "push",
1324 },
1325 },
1326 });
1327 expect(result.toast.title).toMatch(/^Committed [0-9a-f]{7}$/);
1328 expect(
1329 yield* runGit(repoDir, ["log", "-1", "--pretty=%s"]).pipe(
1330 Effect.map((result) => result.stdout.trim()),
1331 ),
1332 ).toBe("Implement stacked git actions");
1333 }),
1334 );
1335
1336 it.effect("uses custom commit message when provided", () =>
1337 Effect.gen(function* () {
1338 const repoDir = yield* makeTempDir("t3code-git-manager-");
1339 yield* initRepo(repoDir);
1340 fs.writeFileSync(path.join(repoDir, "README.md"), "hello\ncustom\n");
1341 let generatedCount = 0;
1342
1343 const { manager } = yield* makeManager({
1344 textGeneration: {
1345 generateCommitMessage: (input) =>
1346 Effect.sync(() => {
1347 generatedCount += 1;
1348 return {
1349 subject: "this should not be used",
1350 body: "",
1351 ...(input.includeBranch ? { branch: "feature/unused" } : {}),
1352 };
1353 }),
1354 },
1355 });
1356 const result = yield* runStackedAction(manager, {
1357 cwd: repoDir,
1358 action: "commit",
1359 commitMessage: "feat: custom summary line\n\n- details from user",
1360 });
1361
1362 expect(result.branch.status).toBe("skipped_not_requested");
1363 expect(result.commit.status).toBe("created");
1364 expect(result.commit.subject).toBe("feat: custom summary line");
1365 expect(generatedCount).toBe(0);
1366 expect(
1367 yield* runGit(repoDir, ["log", "-1", "--pretty=%s"]).pipe(
1368 Effect.map((result) => result.stdout.trim()),
1369 ),
1370 ).toBe("feat: custom summary line");
1371 expect(
1372 yield* runGit(repoDir, ["log", "-1", "--pretty=%b"]).pipe(
1373 Effect.map((result) => result.stdout.trim()),
1374 ),
1375 ).toContain("- details from user");
1376 }),
1377 );
1378
1379 it.effect("commits only selected files when filePaths is provided", () =>
1380 Effect.gen(function* () {
1381 const repoDir = yield* makeTempDir("t3code-git-manager-");
1382 yield* initRepo(repoDir);
1383 fs.writeFileSync(path.join(repoDir, "a.txt"), "file a\n");
1384 fs.writeFileSync(path.join(repoDir, "b.txt"), "file b\n");
1385
1386 const { manager } = yield* makeManager();
1387 const result = yield* runStackedAction(manager, {
1388 cwd: repoDir,
1389 action: "commit",
1390 filePaths: ["a.txt"],
1391 });
1392
1393 expect(result.commit.status).toBe("created");
1394
1395 // b.txt should remain in the working tree
1396 const statusStdout = yield* runGit(repoDir, ["status", "--porcelain"]).pipe(
1397 Effect.map((r) => r.stdout),
1398 );
1399 expect(statusStdout).toContain("b.txt");
1400 expect(statusStdout).not.toContain("a.txt");
1401 }),
1402 );
1403
1404 it.effect("creates feature branch, commits, and pushes with featureBranch option", () =>
1405 Effect.gen(function* () {
1406 const repoDir = yield* makeTempDir("t3code-git-manager-");
1407 yield* initRepo(repoDir);
1408 const remoteDir = yield* createBareRemote();
1409 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
1410 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
1411 fs.writeFileSync(path.join(repoDir, "README.md"), "hello\nfeature-branch\n");
1412 let generatedCount = 0;
1413
1414 const { manager } = yield* makeManager({
1415 textGeneration: {
1416 generateCommitMessage: (input) =>
1417 Effect.sync(() => {
1418 generatedCount += 1;
1419 return {
1420 subject: "Implement stacked git actions",
1421 body: "",
1422 ...(input.includeBranch ? { branch: "feature/implement-stacked-git-actions" } : {}),
1423 };
1424 }),
1425 },
1426 });
1427 const result = yield* runStackedAction(manager, {
1428 cwd: repoDir,
1429 action: "commit_push",
1430 featureBranch: true,
1431 });
1432
1433 expect(result.branch.status).toBe("created");
1434 expect(result.branch.name).toBe("feature/implement-stacked-git-actions");
1435 expect(result.commit.status).toBe("created");
1436 expect(result.push.status).toBe("pushed");
1437 expect(result.toast).toMatchObject({
1438 description: "Implement stacked git actions",
1439 cta: {
1440 kind: "run_action",
1441 label: "Create PR",
1442 action: {
1443 kind: "create_pr",
1444 },
1445 },
1446 });
1447 expect(result.toast.title).toMatch(
1448 /^Pushed [0-9a-f]{7} to origin\/feature\/implement-stacked-git-actions$/,
1449 );
1450 expect(
1451 yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "HEAD"]).pipe(
1452 Effect.map((result) => result.stdout.trim()),
1453 ),
1454 ).toBe("feature/implement-stacked-git-actions");
1455
1456 const mainSha = yield* runGit(repoDir, ["rev-parse", "main"]).pipe(
1457 Effect.map((r) => r.stdout.trim()),
1458 );
1459 const mergeBase = yield* runGit(repoDir, ["merge-base", "main", "HEAD"]).pipe(
1460 Effect.map((r) => r.stdout.trim()),
1461 );
1462 expect(mergeBase).toBe(mainSha);
1463 expect(generatedCount).toBe(1);
1464 }),
1465 );
1466
1467 it.effect("featureBranch uses custom commit message and derives branch name", () =>
1468 Effect.gen(function* () {
1469 const repoDir = yield* makeTempDir("t3code-git-manager-");
1470 yield* initRepo(repoDir);
1471 fs.writeFileSync(path.join(repoDir, "README.md"), "hello\ncustom-feature\n");
1472 let generatedCount = 0;
1473
1474 const { manager } = yield* makeManager({
1475 textGeneration: {
1476 generateCommitMessage: (input) =>
1477 Effect.sync(() => {
1478 generatedCount += 1;
1479 return {
1480 subject: "unused",
1481 body: "",
1482 ...(input.includeBranch ? { branch: "feature/unused" } : {}),
1483 };
1484 }),
1485 },
1486 });
1487 const result = yield* runStackedAction(manager, {
1488 cwd: repoDir,
1489 action: "commit",
1490 featureBranch: true,
1491 commitMessage: "feat: custom summary line\n\n- details from user",
1492 });
1493
1494 expect(result.branch.status).toBe("created");
1495 expect(result.branch.name).toBe("feature/feat-custom-summary-line");
1496 expect(result.commit.status).toBe("created");
1497 expect(result.commit.subject).toBe("feat: custom summary line");
1498 expect(generatedCount).toBe(0);
1499
1500 const mainSha = yield* runGit(repoDir, ["rev-parse", "main"]).pipe(
1501 Effect.map((r) => r.stdout.trim()),
1502 );
1503 const mergeBase = yield* runGit(repoDir, ["merge-base", "main", result.branch.name!]).pipe(
1504 Effect.map((r) => r.stdout.trim()),
1505 );
1506 expect(mergeBase).toBe(mainSha);
1507 }),
1508 );
1509
1510 it.effect("skips commit when there are no uncommitted changes", () =>
1511 Effect.gen(function* () {
1512 const repoDir = yield* makeTempDir("t3code-git-manager-");
1513 yield* initRepo(repoDir);
1514
1515 const { manager } = yield* makeManager();
1516 const result = yield* runStackedAction(manager, {
1517 cwd: repoDir,
1518 action: "commit",
1519 });
1520
1521 expect(result.branch.status).toBe("skipped_not_requested");
1522 expect(result.commit.status).toBe("skipped_no_changes");
1523 expect(result.push.status).toBe("skipped_not_requested");
1524 expect(result.pr.status).toBe("skipped_not_requested");
1525 }),
1526 );
1527
1528 it.effect("featureBranch returns error when worktree is clean", () =>
1529 Effect.gen(function* () {
1530 const repoDir = yield* makeTempDir("t3code-git-manager-");
1531 yield* initRepo(repoDir);
1532
1533 const { manager } = yield* makeManager();
1534 const errorMessage = yield* runStackedAction(manager, {
1535 cwd: repoDir,
1536 action: "commit",
1537 featureBranch: true,
1538 }).pipe(
1539 Effect.flip,
1540 Effect.map((error) => error.message),
1541 );
1542
1543 expect(errorMessage).toContain("no changes to commit");
1544 }),
1545 );
1546
1547 it.effect("commits and pushes with upstream auto-setup when needed", () =>
1548 Effect.gen(function* () {
1549 const repoDir = yield* makeTempDir("t3code-git-manager-");
1550 yield* initRepo(repoDir);
1551 yield* runGit(repoDir, ["checkout", "-b", "feature/stacked-flow"]);
1552 const remoteDir = yield* createBareRemote();
1553 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
1554 fs.writeFileSync(path.join(repoDir, "feature.txt"), "feature\n");
1555
1556 const { manager } = yield* makeManager();
1557 const result = yield* runStackedAction(manager, {
1558 cwd: repoDir,
1559 action: "commit_push",
1560 });
1561
1562 expect(result.branch.status).toBe("skipped_not_requested");
1563 expect(result.commit.status).toBe("created");
1564 expect(result.push.status).toBe("pushed");
1565 expect(result.push.setUpstream).toBe(true);
1566 expect(
1567 yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"]).pipe(
1568 Effect.map((result) => result.stdout.trim()),
1569 ),
1570 ).toBe("origin/feature/stacked-flow");
1571 }),
1572 );
1573
1574 it.effect(
1575 "pushes and creates PR from a no-upstream branch when local commits are ahead of base",
1576 () =>
1577 Effect.gen(function* () {
1578 const repoDir = yield* makeTempDir("t3code-git-manager-");
1579 yield* initRepo(repoDir);
1580 yield* runGit(repoDir, ["checkout", "-b", "feature/no-upstream-pr"]);
1581 const remoteDir = yield* createBareRemote();
1582 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
1583 fs.writeFileSync(path.join(repoDir, "feature.txt"), "feature\n");
1584
1585 const { manager, ghCalls } = yield* makeManager({
1586 ghScenario: {
1587 prListSequence: [
1588 "[]",
1589 JSON.stringify([
1590 {
1591 number: 77,
1592 title: "Add no-upstream PR flow",
1593 url: "https://github.com/pingdotgg/codething-mvp/pull/77",
1594 baseRefName: "main",
1595 headRefName: "feature/no-upstream-pr",
1596 },
1597 ]),
1598 ],
1599 },
1600 });
1601
1602 const result = yield* runStackedAction(manager, {
1603 cwd: repoDir,
1604 action: "commit_push_pr",
1605 });
1606
1607 expect(result.branch.status).toBe("skipped_not_requested");
1608 expect(result.commit.status).toBe("created");
1609 expect(result.push.status).toBe("pushed");
1610 expect(result.push.setUpstream).toBe(true);
1611 expect(result.pr.status).toBe("created");
1612 expect(
1613 yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"]).pipe(
1614 Effect.map((result) => result.stdout.trim()),
1615 ),
1616 ).toBe("origin/feature/no-upstream-pr");
1617 expect(
1618 ghCalls.some((call) =>
1619 call.includes("pr create --base main --head feature/no-upstream-pr"),
1620 ),
1621 ).toBe(true);
1622 }),
1623 );
1624
1625 it.effect("skips push when branch is already up to date", () =>
1626 Effect.gen(function* () {
1627 const repoDir = yield* makeTempDir("t3code-git-manager-");
1628 yield* initRepo(repoDir);
1629 yield* runGit(repoDir, ["checkout", "-b", "feature/up-to-date"]);
1630 const remoteDir = yield* createBareRemote();
1631 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
1632 yield* runGit(repoDir, ["push", "-u", "origin", "feature/up-to-date"]);
1633
1634 const { manager } = yield* makeManager();
1635 const result = yield* runStackedAction(manager, {
1636 cwd: repoDir,
1637 action: "commit_push",
1638 });
1639
1640 expect(result.branch.status).toBe("skipped_not_requested");
1641 expect(result.commit.status).toBe("skipped_no_changes");
1642 expect(result.push.status).toBe("skipped_up_to_date");
1643 }),
1644 );
1645
1646 it.effect("pushes existing clean commits without rerunning commit logic", () =>
1647 Effect.gen(function* () {
1648 const repoDir = yield* makeTempDir("t3code-git-manager-");
1649 yield* initRepo(repoDir);
1650 yield* runGit(repoDir, ["checkout", "-b", "feature/push-only"]);
1651 const remoteDir = yield* createBareRemote();
1652 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
1653 fs.writeFileSync(path.join(repoDir, "push-only.txt"), "push only\n");
1654 yield* runGit(repoDir, ["add", "push-only.txt"]);
1655 yield* runGit(repoDir, ["commit", "-m", "Push only branch"]);
1656
1657 const { manager } = yield* makeManager();
1658 const result = yield* runStackedAction(manager, {
1659 cwd: repoDir,
1660 action: "push",
1661 });
1662
1663 expect(result.commit.status).toBe("skipped_not_requested");
1664 expect(result.push.status).toBe("pushed");
1665 expect(result.pr.status).toBe("skipped_not_requested");
1666 expect(
1667 yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"]).pipe(
1668 Effect.map((output) => output.stdout.trim()),
1669 ),
1670 ).toBe("origin/feature/push-only");
1671 }),
1672 );
1673
1674 it.effect("create_pr pushes a clean branch before creating the PR when needed", () =>
1675 Effect.gen(function* () {
1676 const repoDir = yield* makeTempDir("t3code-git-manager-");
1677 yield* initRepo(repoDir);
1678 yield* runGit(repoDir, ["checkout", "-b", "feature/create-pr-only"]);
1679 const remoteDir = yield* createBareRemote();
1680 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
1681 fs.writeFileSync(path.join(repoDir, "create-pr-only.txt"), "create pr\n");
1682 yield* runGit(repoDir, ["add", "create-pr-only.txt"]);
1683 yield* runGit(repoDir, ["commit", "-m", "Create PR only branch"]);
1684
1685 const { manager, ghCalls } = yield* makeManager({
1686 ghScenario: {
1687 prListSequence: [
1688 "[]",
1689 JSON.stringify([
1690 {
1691 number: 303,
1692 title: "Create PR only branch",
1693 url: "https://github.com/pingdotgg/codething-mvp/pull/303",
1694 baseRefName: "main",
1695 headRefName: "feature/create-pr-only",
1696 },
1697 ]),
1698 ],
1699 },
1700 });
1701
1702 const result = yield* runStackedAction(manager, {
1703 cwd: repoDir,
1704 action: "create_pr",
1705 });
1706
1707 expect(result.commit.status).toBe("skipped_not_requested");
1708 expect(result.push.status).toBe("pushed");
1709 expect(result.push.setUpstream).toBe(true);
1710 expect(result.pr.status).toBe("created");
1711 expect(result.pr.number).toBe(303);
1712 expect(
1713 ghCalls.some((call) =>
1714 call.includes("pr create --base main --head feature/create-pr-only"),
1715 ),
1716 ).toBe(true);
1717 }),
1718 );
1719
1720 it.effect("create_pr falls back to main when source control provider detection fails", () =>
1721 Effect.gen(function* () {
1722 const repoDir = yield* makeTempDir("t3code-git-manager-");
1723 yield* initRepo(repoDir);
1724 yield* runGit(repoDir, ["checkout", "-b", "feature/provider-fallback"]);
1725 fs.writeFileSync(path.join(repoDir, "provider-fallback.txt"), "fallback\n");
1726 yield* runGit(repoDir, ["add", "provider-fallback.txt"]);
1727 yield* runGit(repoDir, ["commit", "-m", "Provider fallback"]);
1728 const remoteDir = yield* createBareRemote();
1729 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
1730
1731 const { manager, ghCalls } = yield* makeManager({
1732 ghScenario: {
1733 prListSequence: [
1734 "[]",
1735 JSON.stringify([
1736 {
1737 number: 404,
1738 title: "Provider fallback",
1739 url: "https://github.com/pingdotgg/codething-mvp/pull/404",
1740 baseRefName: "main",
1741 headRefName: "feature/provider-fallback",
1742 },
1743 ]),
1744 ],
1745 },
1746 });
1747
1748 const result = yield* runStackedAction(manager, {
1749 cwd: repoDir,
1750 action: "create_pr",
1751 });
1752
1753 expect(result.pr.status).toBe("created");
1754 expect(result.pr.number).toBe(404);
1755 expect(
1756 ghCalls.some((call) =>
1757 call.includes("pr create --base main --head feature/provider-fallback"),
1758 ),
1759 ).toBe(true);
1760 }),
1761 );
1762
1763 it.effect("returns existing PR metadata for commit/push/pr action", () =>
1764 Effect.gen(function* () {
1765 const repoDir = yield* makeTempDir("t3code-git-manager-");
1766 yield* initRepo(repoDir);
1767 yield* runGit(repoDir, ["checkout", "-b", "feature/existing-pr"]);
1768 const remoteDir = yield* createBareRemote();
1769 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
1770 yield* runGit(repoDir, ["push", "-u", "origin", "feature/existing-pr"]);
1771
1772 const { manager, ghCalls } = yield* makeManager({
1773 ghScenario: {
1774 prListSequence: [
1775 JSON.stringify([
1776 {
1777 number: 42,
1778 title: "Existing PR",
1779 url: "https://github.com/pingdotgg/codething-mvp/pull/42",
1780 baseRefName: "main",
1781 headRefName: "feature/existing-pr",
1782 },
1783 ]),
1784 ],
1785 },
1786 });
1787 const result = yield* runStackedAction(manager, {
1788 cwd: repoDir,
1789 action: "commit_push_pr",
1790 });
1791
1792 expect(result.branch.status).toBe("skipped_not_requested");
1793 expect(result.pr.status).toBe("opened_existing");
1794 expect(result.pr.number).toBe(42);
1795 expect(result.toast).toEqual({
1796 title: "Opened PR #42",
1797 description: "Existing PR",
1798 cta: {
1799 kind: "open_pr",
1800 label: "View PR",
1801 url: "https://github.com/pingdotgg/codething-mvp/pull/42",
1802 },
1803 });
1804 expect(ghCalls.some((call) => call.startsWith("pr view "))).toBe(false);
1805 }),
1806 );
1807
1808 it.effect(
1809 "returns existing cross-repo PR metadata using the fork owner selector",
1810 () =>
1811 Effect.gen(function* () {
1812 const repoDir = yield* makeTempDir("t3code-git-manager-");
1813 yield* initRepo(repoDir);
1814 yield* runGit(repoDir, ["checkout", "-b", "statemachine"]);
1815 const forkDir = yield* createBareRemote();
1816 yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]);
1817 yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]);
1818 yield* runGit(repoDir, [
1819 "config",
1820 "remote.fork-seed.url",
1821 "git@github.com:octocat/codething-mvp.git",
1822 ]);
1823
1824 const { manager, ghCalls } = yield* makeManager({
1825 ghScenario: {
1826 prListSequence: [
1827 JSON.stringify([]),
1828 JSON.stringify([
1829 {
1830 number: 142,
1831 title: "Existing fork PR",
1832 url: "https://github.com/pingdotgg/codething-mvp/pull/142",
1833 baseRefName: "main",
1834 headRefName: "statemachine",
1835 state: "OPEN",
1836 isCrossRepository: true,
1837 headRepository: {
1838 nameWithOwner: "octocat/codething-mvp",
1839 },
1840 headRepositoryOwner: {
1841 login: "octocat",
1842 },
1843 },
1844 ]),
1845 ],
1846 },
1847 });
1848
1849 const result = yield* runStackedAction(manager, {
1850 cwd: repoDir,
1851 action: "commit_push_pr",
1852 });
1853
1854 expect(result.pr.status).toBe("opened_existing");
1855 expect(result.pr.number).toBe(142);
1856 expect(
1857 ghCalls.some((call) =>
1858 call.includes("pr list --head octocat:statemachine --state open --limit 1"),
1859 ),
1860 ).toBe(true);
1861 expect(ghCalls.some((call) => call.startsWith("pr create "))).toBe(false);
1862 }),
1863 12_000,
1864 );
1865
1866 it.effect(
1867 "returns the correct existing PR when a slash remote checks out to a synthetic local alias",
1868 () =>
1869 Effect.gen(function* () {
1870 const repoDir = yield* makeTempDir("t3code-git-manager-");
1871 yield* initRepo(repoDir);
1872 const originDir = yield* createBareRemote();
1873 const upstreamDir = yield* createBareRemote();
1874 yield* configureRemote(repoDir, "origin", originDir, "origin");
1875 yield* configureRemote(repoDir, "my-org/upstream", upstreamDir, "my-org/upstream");
1876
1877 yield* runGit(repoDir, ["checkout", "-b", "effect-atom"]);
1878 yield* runGit(repoDir, ["push", "-u", "origin", "effect-atom"]);
1879 yield* runGit(repoDir, ["push", "-u", "my-org/upstream", "effect-atom"]);
1880 yield* runGit(repoDir, [
1881 "config",
1882 "remote.origin.url",
1883 "git@github.com:pingdotgg/codething-mvp.git",
1884 ]);
1885 yield* runGit(repoDir, ["config", "remote.origin.pushurl", originDir]);
1886 yield* runGit(repoDir, [
1887 "config",
1888 "remote.my-org/upstream.url",
1889 "git@github.com:pingdotgg/codething-mvp.git",
1890 ]);
1891 yield* runGit(repoDir, ["config", "remote.my-org/upstream.pushurl", upstreamDir]);
1892 yield* runGit(repoDir, ["checkout", "main"]);
1893 yield* runGit(repoDir, ["branch", "-D", "effect-atom"]);
1894 yield* runGit(repoDir, ["checkout", "--track", "my-org/upstream/effect-atom"]);
1895 fs.writeFileSync(path.join(repoDir, "changes.txt"), "change\n");
1896 yield* runGit(repoDir, ["add", "changes.txt"]);
1897 yield* runGit(repoDir, ["commit", "-m", "Feature commit"]);
1898
1899 const { manager, ghCalls } = yield* makeManager({
1900 ghScenario: {
1901 prListByHeadSelector: {
1902 "effect-atom": JSON.stringify([
1903 {
1904 number: 1618,
1905 title: "Correct PR",
1906 url: "https://github.com/pingdotgg/t3code/pull/1618",
1907 baseRefName: "main",
1908 headRefName: "effect-atom",
1909 },
1910 ]),
1911 "upstream/effect-atom": JSON.stringify([
1912 {
1913 number: 1518,
1914 title: "Wrong PR",
1915 url: "https://github.com/pingdotgg/t3code/pull/1518",
1916 baseRefName: "main",
1917 headRefName: "upstream/effect-atom",
1918 },
1919 ]),
1920 "pingdotgg:effect-atom": JSON.stringify([]),
1921 "my-org/upstream:effect-atom": JSON.stringify([]),
1922 "pingdotgg:upstream/effect-atom": JSON.stringify([
1923 {
1924 number: 1518,
1925 title: "Wrong PR",
1926 url: "https://github.com/pingdotgg/t3code/pull/1518",
1927 baseRefName: "main",
1928 headRefName: "upstream/effect-atom",
1929 },
1930 ]),
1931 "my-org/upstream:upstream/effect-atom": JSON.stringify([
1932 {
1933 number: 1518,
1934 title: "Wrong PR",
1935 url: "https://github.com/pingdotgg/t3code/pull/1518",
1936 baseRefName: "main",
1937 headRefName: "upstream/effect-atom",
1938 },
1939 ]),
1940 },
1941 },
1942 });
1943
1944 const result = yield* runStackedAction(manager, {
1945 cwd: repoDir,
1946 action: "commit_push_pr",
1947 });
1948
1949 expect(result.pr.status).toBe("opened_existing");
1950 expect(result.pr.number).toBe(1618);
1951 expect(ghCalls.some((call) => call.includes("pr list --head upstream/effect-atom "))).toBe(
1952 false,
1953 );
1954 }),
1955 20_000,
1956 );
1957
1958 it.effect(
1959 "prefers owner-qualified selectors before bare branch names for cross-repo PRs",
1960 () =>
1961 Effect.gen(function* () {
1962 const repoDir = yield* makeTempDir("t3code-git-manager-");
1963 yield* initRepo(repoDir);
1964 yield* runGit(repoDir, ["checkout", "-b", "statemachine"]);
1965 const forkDir = yield* createBareRemote();
1966 yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]);
1967 yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]);
1968 yield* runGit(repoDir, ["checkout", "-b", "t3code/pr-142/statemachine"]);
1969 yield* runGit(repoDir, ["branch", "--set-upstream-to", "fork-seed/statemachine"]);
1970 yield* runGit(repoDir, [
1971 "config",
1972 "remote.fork-seed.url",
1973 "git@github.com:octocat/codething-mvp.git",
1974 ]);
1975
1976 const { manager, ghCalls } = yield* makeManager({
1977 ghScenario: {
1978 prListByHeadSelector: {
1979 "t3code/pr-142/statemachine": JSON.stringify([]),
1980 statemachine: JSON.stringify([
1981 {
1982 number: 41,
1983 title: "Unrelated same-repo PR",
1984 url: "https://github.com/pingdotgg/codething-mvp/pull/41",
1985 baseRefName: "main",
1986 headRefName: "statemachine",
1987 },
1988 ]),
1989 "octocat:statemachine": JSON.stringify([
1990 {
1991 number: 142,
1992 title: "Existing fork PR",
1993 url: "https://github.com/pingdotgg/codething-mvp/pull/142",
1994 baseRefName: "main",
1995 headRefName: "statemachine",
1996 state: "OPEN",
1997 isCrossRepository: true,
1998 headRepository: {
1999 nameWithOwner: "octocat/codething-mvp",
2000 },
2001 headRepositoryOwner: {
2002 login: "octocat",
2003 },
2004 },
2005 ]),
2006 "fork-seed:statemachine": JSON.stringify([]),
2007 },
2008 },
2009 });
2010
2011 const result = yield* runStackedAction(manager, {
2012 cwd: repoDir,
2013 action: "commit_push_pr",
2014 });
2015
2016 expect(result.pr.status).toBe("opened_existing");
2017 expect(result.pr.number).toBe(142);
2018
2019 const ownerSelectorCallIndex = ghCalls.findIndex((call) =>
2020 call.includes("pr list --head octocat:statemachine --state open --limit 1"),
2021 );
2022 expect(ownerSelectorCallIndex).toBeGreaterThanOrEqual(0);
2023 expect(ghCalls.some((call) => call.startsWith("pr create "))).toBe(false);
2024 }),
2025 12_000,
2026 );
2027
2028 it.effect(
2029 "stops probing head selectors after finding an existing PR",
2030 () =>
2031 Effect.gen(function* () {
2032 const repoDir = yield* makeTempDir("t3code-git-manager-");
2033 yield* initRepo(repoDir);
2034 yield* runGit(repoDir, ["checkout", "-b", "statemachine"]);
2035 const forkDir = yield* createBareRemote();
2036 yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]);
2037 yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]);
2038 yield* runGit(repoDir, ["checkout", "-b", "t3code/pr-142/statemachine"]);
2039 yield* runGit(repoDir, ["branch", "--set-upstream-to", "fork-seed/statemachine"]);
2040 yield* runGit(repoDir, [
2041 "config",
2042 "remote.fork-seed.url",
2043 "git@github.com:octocat/codething-mvp.git",
2044 ]);
2045
2046 const { manager, ghCalls } = yield* makeManager({
2047 ghScenario: {
2048 prListByHeadSelector: {
2049 "octocat:statemachine": JSON.stringify([
2050 {
2051 number: 142,
2052 title: "Existing fork PR",
2053 url: "https://github.com/pingdotgg/codething-mvp/pull/142",
2054 baseRefName: "main",
2055 headRefName: "statemachine",
2056 state: "OPEN",
2057 isCrossRepository: true,
2058 headRepository: {
2059 nameWithOwner: "octocat/codething-mvp",
2060 },
2061 headRepositoryOwner: {
2062 login: "octocat",
2063 },
2064 },
2065 ]),
2066 "fork-seed:statemachine": JSON.stringify([]),
2067 "t3code/pr-142/statemachine": JSON.stringify([]),
2068 statemachine: JSON.stringify([]),
2069 },
2070 },
2071 });
2072
2073 const result = yield* runStackedAction(manager, {
2074 cwd: repoDir,
2075 action: "commit_push_pr",
2076 });
2077
2078 expect(result.pr.status).toBe("opened_existing");
2079 expect(result.pr.number).toBe(142);
2080
2081 const openLookupCalls = ghCalls.filter((call) => call.includes("--state open --limit 1"));
2082 expect(openLookupCalls).toHaveLength(1);
2083 expect(openLookupCalls[0]).toContain(
2084 "pr list --head octocat:statemachine --state open --limit 1",
2085 );
2086 }),
2087 12_000,
2088 );
2089
2090 it.effect("creates PR when one does not already exist", () =>
2091 Effect.gen(function* () {
2092 const repoDir = yield* makeTempDir("t3code-git-manager-");
2093 yield* initRepo(repoDir);
2094 yield* runGit(repoDir, ["checkout", "-b", "feature-create-pr"]);
2095 const remoteDir = yield* createBareRemote();
2096 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
2097 fs.writeFileSync(path.join(repoDir, "changes.txt"), "change\n");
2098 yield* runGit(repoDir, ["add", "changes.txt"]);
2099 yield* runGit(repoDir, ["commit", "-m", "Feature commit"]);
2100 yield* runGit(repoDir, ["push", "-u", "origin", "feature-create-pr"]);
2101 yield* runGit(repoDir, ["config", "branch.feature-create-pr.gh-merge-base", "main"]);
2102
2103 const { manager, ghCalls } = yield* makeManager({
2104 ghScenario: {
2105 prListSequence: [
2106 "[]",
2107 JSON.stringify([
2108 {
2109 number: 88,
2110 title: "Add stacked git actions",
2111 url: "https://github.com/pingdotgg/codething-mvp/pull/88",
2112 baseRefName: "main",
2113 headRefName: "feature-create-pr",
2114 },
2115 ]),
2116 ],
2117 },
2118 });
2119 const result = yield* runStackedAction(manager, {
2120 cwd: repoDir,
2121 action: "commit_push_pr",
2122 });
2123
2124 expect(result.branch.status).toBe("skipped_not_requested");
2125 expect(result.pr.status).toBe("created");
2126 expect(result.pr.number).toBe(88);
2127 expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(2);
2128 expect(
2129 ghCalls.some((call) => call.includes("pr create --base main --head feature-create-pr")),
2130 ).toBe(true);
2131 expect(ghCalls.some((call) => call.startsWith("pr view "))).toBe(false);
2132 }),
2133 );
2134
2135 it.effect(
2136 "creates a new PR instead of reusing an unrelated fork PR with the same head branch",
2137 () =>
2138 Effect.gen(function* () {
2139 const repoDir = yield* makeTempDir("t3code-git-manager-");
2140 yield* initRepo(repoDir);
2141 yield* runGit(repoDir, ["checkout", "-b", "feature/no-fork-match"]);
2142 const remoteDir = yield* createBareRemote();
2143 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
2144 fs.writeFileSync(path.join(repoDir, "changes.txt"), "change\n");
2145 yield* runGit(repoDir, ["add", "changes.txt"]);
2146 yield* runGit(repoDir, ["commit", "-m", "Feature commit"]);
2147 yield* runGit(repoDir, ["push", "-u", "origin", "feature/no-fork-match"]);
2148
2149 const { manager, ghCalls } = yield* makeManager({
2150 ghScenario: {
2151 prListSequence: [
2152 JSON.stringify([
2153 {
2154 number: 1661,
2155 title: "Fork PR with same branch name",
2156 url: "https://github.com/pingdotgg/t3code/pull/1661",
2157 baseRefName: "main",
2158 headRefName: "feature/no-fork-match",
2159 state: "OPEN",
2160 isCrossRepository: true,
2161 headRepository: {
2162 nameWithOwner: "lnieuwenhuis/t3code",
2163 },
2164 headRepositoryOwner: {
2165 login: "lnieuwenhuis",
2166 },
2167 },
2168 ]),
2169 JSON.stringify([
2170 {
2171 number: 188,
2172 title: "Add stacked git actions",
2173 url: "https://github.com/pingdotgg/codething-mvp/pull/188",
2174 baseRefName: "main",
2175 headRefName: "feature/no-fork-match",
2176 state: "OPEN",
2177 isCrossRepository: false,
2178 },
2179 ]),
2180 ],
2181 },
2182 });
2183 const result = yield* runStackedAction(manager, {
2184 cwd: repoDir,
2185 action: "commit_push_pr",
2186 });
2187
2188 expect(result.pr.status).toBe("created");
2189 expect(result.pr.number).toBe(188);
2190 expect(result.toast).toEqual({
2191 title: "Created PR #188",
2192 description: "Add stacked git actions",
2193 cta: {
2194 kind: "open_pr",
2195 label: "View PR",
2196 url: "https://github.com/pingdotgg/codething-mvp/pull/188",
2197 },
2198 });
2199 expect(
2200 ghCalls.some((call) =>
2201 call.includes("pr create --base main --head feature/no-fork-match"),
2202 ),
2203 ).toBe(true);
2204 }),
2205 );
2206
2207 it.effect("creates cross-repo PRs with the fork owner selector and default base branch", () =>
2208 Effect.gen(function* () {
2209 const repoDir = yield* makeTempDir("t3code-git-manager-");
2210 yield* initRepo(repoDir);
2211 const forkDir = yield* createBareRemote();
2212 yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]);
2213 yield* runGit(repoDir, ["checkout", "-b", "statemachine"]);
2214 fs.writeFileSync(path.join(repoDir, "changes.txt"), "change\n");
2215 yield* runGit(repoDir, ["add", "changes.txt"]);
2216 yield* runGit(repoDir, ["commit", "-m", "Feature commit"]);
2217 yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]);
2218 yield* runGit(repoDir, ["checkout", "-b", "t3code/pr-91/statemachine"]);
2219 yield* runGit(repoDir, ["branch", "--set-upstream-to", "fork-seed/statemachine"]);
2220 yield* runGit(repoDir, [
2221 "config",
2222 "remote.fork-seed.url",
2223 "git@github.com:octocat/codething-mvp.git",
2224 ]);
2225
2226 const { manager, ghCalls } = yield* makeManager({
2227 ghScenario: {
2228 prListSequenceByHeadSelector: {
2229 "octocat:statemachine": [
2230 JSON.stringify([]),
2231 JSON.stringify([
2232 {
2233 number: 188,
2234 title: "Add stacked git actions",
2235 url: "https://github.com/pingdotgg/codething-mvp/pull/188",
2236 baseRefName: "main",
2237 headRefName: "statemachine",
2238 state: "OPEN",
2239 isCrossRepository: true,
2240 headRepository: {
2241 nameWithOwner: "octocat/codething-mvp",
2242 },
2243 headRepositoryOwner: {
2244 login: "octocat",
2245 },
2246 },
2247 ]),
2248 ],
2249 "fork-seed:statemachine": [JSON.stringify([])],
2250 statemachine: [JSON.stringify([])],
2251 },
2252 },
2253 });
2254
2255 const result = yield* runStackedAction(manager, {
2256 cwd: repoDir,
2257 action: "commit_push_pr",
2258 });
2259
2260 expect(result.pr.status).toBe("created");
2261 expect(result.pr.number).toBe(188);
2262 expect(
2263 ghCalls.some((call) => call.includes("pr create --base main --head octocat:statemachine")),
2264 ).toBe(true);
2265 expect(
2266 ghCalls.some((call) =>
2267 call.includes("pr create --base statemachine --head octocat:statemachine"),
2268 ),
2269 ).toBe(false);
2270 }),
2271 );
2272
2273 it.effect("rejects push/pr actions from detached HEAD", () =>
2274 Effect.gen(function* () {
2275 const repoDir = yield* makeTempDir("t3code-git-manager-");
2276 yield* initRepo(repoDir);
2277 yield* runGit(repoDir, ["checkout", "--detach", "HEAD"]);
2278
2279 const { manager } = yield* makeManager();
2280 const errorMessage = yield* runStackedAction(manager, {
2281 cwd: repoDir,
2282 action: "commit_push",
2283 }).pipe(
2284 Effect.flip,
2285 Effect.map((error) => error.message),
2286 );
2287 expect(errorMessage).toContain("detached HEAD");
2288 }),
2289 );
2290
2291 it.effect("surfaces missing gh binary errors", () =>
2292 Effect.gen(function* () {
2293 const repoDir = yield* makeTempDir("t3code-git-manager-");
2294 yield* initRepo(repoDir);
2295 yield* runGit(repoDir, ["checkout", "-b", "feature/gh-missing"]);
2296 const remoteDir = yield* createBareRemote();
2297 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
2298 yield* runGit(repoDir, ["push", "-u", "origin", "feature/gh-missing"]);
2299
2300 const { manager } = yield* makeManager({
2301 ghScenario: {
2302 failWith: new GitHubCliError({
2303 operation: "execute",
2304 detail: "GitHub CLI (`gh`) is required but not available on PATH.",
2305 }),
2306 },
2307 });
2308
2309 const errorMessage = yield* runStackedAction(manager, {
2310 cwd: repoDir,
2311 action: "commit_push_pr",
2312 }).pipe(
2313 Effect.flip,
2314 Effect.map((error) => error.message),
2315 );
2316 expect(errorMessage).toContain("GitHub CLI (`gh`) is required");
2317 }),
2318 );
2319
2320 it.effect("surfaces gh auth errors with guidance", () =>
2321 Effect.gen(function* () {
2322 const repoDir = yield* makeTempDir("t3code-git-manager-");
2323 yield* initRepo(repoDir);
2324 yield* runGit(repoDir, ["checkout", "-b", "feature/gh-auth"]);
2325 const remoteDir = yield* createBareRemote();
2326 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
2327 yield* runGit(repoDir, ["push", "-u", "origin", "feature/gh-auth"]);
2328
2329 const { manager } = yield* makeManager({
2330 ghScenario: {
2331 failWith: new GitHubCliError({
2332 operation: "execute",
2333 detail: "GitHub CLI is not authenticated. Run `gh auth login` and retry.",
2334 }),
2335 },
2336 });
2337
2338 const errorMessage = yield* runStackedAction(manager, {
2339 cwd: repoDir,
2340 action: "commit_push_pr",
2341 }).pipe(
2342 Effect.flip,
2343 Effect.map((error) => error.message),
2344 );
2345 expect(errorMessage).toContain("gh auth login");
2346 }),
2347 );
2348
2349 it.effect("resolves pull requests from #number references", () =>
2350 Effect.gen(function* () {
2351 const repoDir = yield* makeTempDir("t3code-git-manager-");
2352 yield* initRepo(repoDir);
2353
2354 const { manager, ghCalls } = yield* makeManager({
2355 ghScenario: {
2356 pullRequest: {
2357 number: 42,
2358 title: "Resolve PR",
2359 url: "https://github.com/pingdotgg/codething-mvp/pull/42",
2360 baseRefName: "main",
2361 headRefName: "feature/resolve-pr",
2362 state: "open",
2363 },
2364 },
2365 });
2366
2367 const result = yield* resolvePullRequest(manager, {
2368 cwd: repoDir,
2369 reference: "#42",
2370 });
2371
2372 expect(result.pullRequest).toEqual({
2373 number: 42,
2374 title: "Resolve PR",
2375 url: "https://github.com/pingdotgg/codething-mvp/pull/42",
2376 baseBranch: "main",
2377 headBranch: "feature/resolve-pr",
2378 state: "open",
2379 });
2380 expect(ghCalls.some((call) => call.startsWith("pr view 42 "))).toBe(true);
2381 }),
2382 );
2383
2384 it.effect("prepares pull request threads in local mode by checking out the PR branch", () =>
2385 Effect.gen(function* () {
2386 const repoDir = yield* makeTempDir("t3code-git-manager-");
2387 yield* initRepo(repoDir);
2388 yield* runGit(repoDir, ["checkout", "-b", "feature/pr-local"]);
2389 fs.writeFileSync(path.join(repoDir, "local.txt"), "local\n");
2390 yield* runGit(repoDir, ["add", "local.txt"]);
2391 yield* runGit(repoDir, ["commit", "-m", "Local PR branch"]);
2392
2393 const { manager, ghCalls } = yield* makeManager({
2394 ghScenario: {
2395 pullRequest: {
2396 number: 64,
2397 title: "Local PR",
2398 url: "https://github.com/pingdotgg/codething-mvp/pull/64",
2399 baseRefName: "main",
2400 headRefName: "feature/pr-local",
2401 state: "open",
2402 },
2403 },
2404 });
2405
2406 const result = yield* preparePullRequestThread(manager, {
2407 cwd: repoDir,
2408 reference: "#64",
2409 mode: "local",
2410 });
2411
2412 expect(result.branch).toBe("feature/pr-local");
2413 expect(result.worktreePath).toBeNull();
2414 const branch = (yield* runGit(repoDir, ["branch", "--show-current"])).stdout.trim();
2415 expect(branch).toBe("feature/pr-local");
2416 expect(ghCalls).toContain("pr checkout 64 --force");
2417 }),
2418 );
2419
2420 it.effect("prepares pull request threads in worktree mode on the PR head branch", () =>
2421 Effect.gen(function* () {
2422 const repoDir = yield* makeTempDir("t3code-git-manager-");
2423 yield* initRepo(repoDir);
2424 const remoteDir = yield* createBareRemote();
2425 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
2426 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
2427 yield* runGit(repoDir, ["checkout", "-b", "feature/pr-worktree"]);
2428 fs.writeFileSync(path.join(repoDir, "worktree.txt"), "worktree\n");
2429 yield* runGit(repoDir, ["add", "worktree.txt"]);
2430 yield* runGit(repoDir, ["commit", "-m", "PR worktree branch"]);
2431 yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-worktree"]);
2432 yield* runGit(repoDir, ["push", "origin", "HEAD:refs/pull/77/head"]);
2433 yield* runGit(repoDir, ["checkout", "main"]);
2434
2435 const { manager } = yield* makeManager({
2436 ghScenario: {
2437 pullRequest: {
2438 number: 77,
2439 title: "Worktree PR",
2440 url: "https://github.com/pingdotgg/codething-mvp/pull/77",
2441 baseRefName: "main",
2442 headRefName: "feature/pr-worktree",
2443 state: "open",
2444 },
2445 },
2446 });
2447
2448 const result = yield* preparePullRequestThread(manager, {
2449 cwd: repoDir,
2450 reference: "77",
2451 mode: "worktree",
2452 });
2453
2454 expect(result.branch).toBe("feature/pr-worktree");
2455 expect(result.worktreePath).not.toBeNull();
2456 expect(fs.existsSync(result.worktreePath as string)).toBe(true);
2457 const worktreeBranch = (yield* runGit(result.worktreePath as string, [
2458 "branch",
2459 "--show-current",
2460 ])).stdout.trim();
2461 expect(worktreeBranch).toBe("feature/pr-worktree");
2462 }),
2463 );
2464
2465 it.effect("launches setup only when creating a new PR worktree", () =>
2466 Effect.gen(function* () {
2467 const repoDir = yield* makeTempDir("t3code-git-manager-");
2468 yield* initRepo(repoDir);
2469 const remoteDir = yield* createBareRemote();
2470 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
2471 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
2472 yield* runGit(repoDir, ["checkout", "-b", "feature/pr-worktree-setup"]);
2473 fs.writeFileSync(path.join(repoDir, "setup.txt"), "setup\n");
2474 yield* runGit(repoDir, ["add", "setup.txt"]);
2475 yield* runGit(repoDir, ["commit", "-m", "PR worktree setup branch"]);
2476 yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-worktree-setup"]);
2477 yield* runGit(repoDir, ["push", "origin", "HEAD:refs/pull/177/head"]);
2478 yield* runGit(repoDir, ["checkout", "main"]);
2479
2480 const setupCalls: ProjectSetupScriptRunnerInput[] = [];
2481 const { manager } = yield* makeManager({
2482 ghScenario: {
2483 pullRequest: {
2484 number: 177,
2485 title: "Worktree setup PR",
2486 url: "https://github.com/pingdotgg/codething-mvp/pull/177",
2487 baseRefName: "main",
2488 headRefName: "feature/pr-worktree-setup",
2489 state: "open",
2490 },
2491 },
2492 setupScriptRunner: {
2493 runForThread: (setupInput) =>
2494 Effect.sync(() => {
2495 setupCalls.push(setupInput);
2496 return { status: "no-script" as const };
2497 }),
2498 },
2499 });
2500
2501 const result = yield* preparePullRequestThread(manager, {
2502 cwd: repoDir,
2503 reference: "177",
2504 mode: "worktree",
2505 threadId: asThreadId("thread-pr-setup"),
2506 });
2507
2508 expect(result.worktreePath).not.toBeNull();
2509 expect(setupCalls).toHaveLength(1);
2510 expect(setupCalls[0]).toEqual({
2511 threadId: "thread-pr-setup",
2512 projectCwd: repoDir,
2513 worktreePath: result.worktreePath as string,
2514 });
2515 }),
2516 );
2517
2518 it.effect("preserves fork upstream tracking when preparing a worktree PR thread", () =>
2519 Effect.gen(function* () {
2520 const repoDir = yield* makeTempDir("t3code-git-manager-");
2521 yield* initRepo(repoDir);
2522 const originDir = yield* createBareRemote();
2523 const forkDir = yield* createBareRemote();
2524 yield* runGit(repoDir, ["remote", "add", "origin", originDir]);
2525 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
2526 yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]);
2527 yield* runGit(repoDir, ["checkout", "-b", "feature/pr-fork"]);
2528 fs.writeFileSync(path.join(repoDir, "fork.txt"), "fork\n");
2529 yield* runGit(repoDir, ["add", "fork.txt"]);
2530 yield* runGit(repoDir, ["commit", "-m", "Fork PR branch"]);
2531 yield* runGit(repoDir, ["push", "-u", "fork-seed", "feature/pr-fork"]);
2532 yield* runGit(repoDir, ["checkout", "main"]);
2533
2534 const { manager } = yield* makeManager({
2535 ghScenario: {
2536 pullRequest: {
2537 number: 81,
2538 title: "Fork PR",
2539 url: "https://github.com/pingdotgg/codething-mvp/pull/81",
2540 baseRefName: "main",
2541 headRefName: "feature/pr-fork",
2542 state: "open",
2543 isCrossRepository: true,
2544 headRepositoryNameWithOwner: "octocat/codething-mvp",
2545 headRepositoryOwnerLogin: "octocat",
2546 },
2547 repositoryCloneUrls: {
2548 "octocat/codething-mvp": {
2549 url: forkDir,
2550 sshUrl: forkDir,
2551 },
2552 },
2553 },
2554 });
2555
2556 const result = yield* preparePullRequestThread(manager, {
2557 cwd: repoDir,
2558 reference: "81",
2559 mode: "worktree",
2560 });
2561
2562 expect(result.worktreePath).not.toBeNull();
2563 const upstreamRef = (yield* runGit(result.worktreePath as string, [
2564 "rev-parse",
2565 "--abbrev-ref",
2566 "@{upstream}",
2567 ])).stdout.trim();
2568 expect(upstreamRef).toBe("fork-seed/feature/pr-fork");
2569 expect(upstreamRef.startsWith("origin/")).toBe(false);
2570 expect(
2571 (yield* runGit(result.worktreePath as string, [
2572 "config",
2573 "--get",
2574 "remote.fork-seed.url",
2575 ])).stdout.trim(),
2576 ).toBe(forkDir);
2577 }),
2578 );
2579
2580 it.effect("preserves fork upstream tracking when preparing a local PR thread", () =>
2581 Effect.gen(function* () {
2582 const repoDir = yield* makeTempDir("t3code-git-manager-");
2583 yield* initRepo(repoDir);
2584 const originDir = yield* createBareRemote();
2585 const forkDir = yield* createBareRemote();
2586 yield* runGit(repoDir, ["remote", "add", "origin", originDir]);
2587 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
2588 yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]);
2589 yield* runGit(repoDir, ["checkout", "-b", "feature/pr-local-fork"]);
2590 fs.writeFileSync(path.join(repoDir, "local-fork.txt"), "local fork\n");
2591 yield* runGit(repoDir, ["add", "local-fork.txt"]);
2592 yield* runGit(repoDir, ["commit", "-m", "Local fork PR branch"]);
2593 yield* runGit(repoDir, ["push", "-u", "fork-seed", "feature/pr-local-fork"]);
2594 yield* runGit(repoDir, ["checkout", "main"]);
2595 yield* runGit(repoDir, ["branch", "-D", "feature/pr-local-fork"]);
2596
2597 const { manager } = yield* makeManager({
2598 ghScenario: {
2599 pullRequest: {
2600 number: 82,
2601 title: "Local Fork PR",
2602 url: "https://github.com/pingdotgg/codething-mvp/pull/82",
2603 baseRefName: "main",
2604 headRefName: "feature/pr-local-fork",
2605 state: "open",
2606 isCrossRepository: true,
2607 headRepositoryNameWithOwner: "octocat/codething-mvp",
2608 headRepositoryOwnerLogin: "octocat",
2609 },
2610 repositoryCloneUrls: {
2611 "octocat/codething-mvp": {
2612 url: forkDir,
2613 sshUrl: forkDir,
2614 },
2615 },
2616 },
2617 });
2618
2619 const result = yield* preparePullRequestThread(manager, {
2620 cwd: repoDir,
2621 reference: "82",
2622 mode: "local",
2623 });
2624
2625 expect(result.worktreePath).toBeNull();
2626 expect(result.branch).toBe("feature/pr-local-fork");
2627 expect(
2628 (yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"])).stdout.trim(),
2629 ).toBe("fork-seed/feature/pr-local-fork");
2630 }),
2631 );
2632
2633 it.effect("derives fork repository identity from PR URL when GitHub omits nameWithOwner", () =>
2634 Effect.gen(function* () {
2635 const repoDir = yield* makeTempDir("t3code-git-manager-");
2636 yield* initRepo(repoDir);
2637 const originDir = yield* createBareRemote();
2638 const forkDir = yield* createBareRemote();
2639 yield* runGit(repoDir, ["remote", "add", "origin", originDir]);
2640 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
2641 yield* runGit(repoDir, ["remote", "add", "binbandit-seed", forkDir]);
2642 yield* runGit(repoDir, ["checkout", "-b", "fix/git-action-default-without-origin"]);
2643 fs.writeFileSync(path.join(repoDir, "derived-fork.txt"), "derived fork\n");
2644 yield* runGit(repoDir, ["add", "derived-fork.txt"]);
2645 yield* runGit(repoDir, ["commit", "-m", "Derived fork PR branch"]);
2646 yield* runGit(repoDir, [
2647 "push",
2648 "-u",
2649 "binbandit-seed",
2650 "fix/git-action-default-without-origin",
2651 ]);
2652 yield* runGit(repoDir, ["checkout", "main"]);
2653 yield* runGit(repoDir, ["branch", "-D", "fix/git-action-default-without-origin"]);
2654
2655 const { manager } = yield* makeManager({
2656 ghScenario: {
2657 pullRequest: {
2658 number: 642,
2659 title: "fix: use commit as the default git action without origin",
2660 url: "https://github.com/pingdotgg/t3code/pull/642",
2661 baseRefName: "main",
2662 headRefName: "fix/git-action-default-without-origin",
2663 state: "open",
2664 isCrossRepository: true,
2665 headRepositoryOwnerLogin: "binbandit",
2666 },
2667 repositoryCloneUrls: {
2668 "binbandit/t3code": {
2669 url: forkDir,
2670 sshUrl: forkDir,
2671 },
2672 },
2673 },
2674 });
2675
2676 const result = yield* preparePullRequestThread(manager, {
2677 cwd: repoDir,
2678 reference: "642",
2679 mode: "local",
2680 });
2681
2682 expect(result.branch).toBe("fix/git-action-default-without-origin");
2683 expect(result.worktreePath).toBeNull();
2684 expect(
2685 (yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"])).stdout.trim(),
2686 ).toBe("binbandit-seed/fix/git-action-default-without-origin");
2687 }),
2688 );
2689
2690 it.effect("reuses an existing dedicated worktree for the PR head branch", () =>
2691 Effect.gen(function* () {
2692 const repoDir = yield* makeTempDir("t3code-git-manager-");
2693 yield* initRepo(repoDir);
2694 yield* runGit(repoDir, ["checkout", "-b", "feature/pr-existing-worktree"]);
2695 fs.writeFileSync(path.join(repoDir, "existing.txt"), "existing\n");
2696 yield* runGit(repoDir, ["add", "existing.txt"]);
2697 yield* runGit(repoDir, ["commit", "-m", "Existing worktree branch"]);
2698 yield* runGit(repoDir, ["checkout", "main"]);
2699 const worktreePath = path.join(repoDir, "..", `pr-existing-${Date.now()}`);
2700 yield* runGit(repoDir, ["worktree", "add", worktreePath, "feature/pr-existing-worktree"]);
2701
2702 const setupCalls: ProjectSetupScriptRunnerInput[] = [];
2703 const { manager } = yield* makeManager({
2704 ghScenario: {
2705 pullRequest: {
2706 number: 78,
2707 title: "Existing worktree PR",
2708 url: "https://github.com/pingdotgg/codething-mvp/pull/78",
2709 baseRefName: "main",
2710 headRefName: "feature/pr-existing-worktree",
2711 state: "open",
2712 },
2713 },
2714 setupScriptRunner: {
2715 runForThread: (setupInput) =>
2716 Effect.sync(() => {
2717 setupCalls.push(setupInput);
2718 return { status: "no-script" as const };
2719 }),
2720 },
2721 });
2722
2723 const result = yield* preparePullRequestThread(manager, {
2724 cwd: repoDir,
2725 reference: "78",
2726 mode: "worktree",
2727 threadId: asThreadId("thread-pr-existing-worktree"),
2728 });
2729
2730 expect(result.worktreePath && fs.realpathSync.native(result.worktreePath)).toBe(
2731 fs.realpathSync.native(worktreePath),
2732 );
2733 expect(result.branch).toBe("feature/pr-existing-worktree");
2734 expect(setupCalls).toHaveLength(0);
2735 }),
2736 );
2737
2738 it.effect(
2739 "does not block fork PR worktree prep when the fork head branch collides with root main",
2740 () =>
2741 Effect.gen(function* () {
2742 const repoDir = yield* makeTempDir("t3code-git-manager-");
2743 yield* initRepo(repoDir);
2744 const originDir = yield* createBareRemote();
2745 const forkDir = yield* createBareRemote();
2746 yield* runGit(repoDir, ["remote", "add", "origin", originDir]);
2747 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
2748 yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]);
2749 yield* runGit(repoDir, ["checkout", "-b", "fork-main-source"]);
2750 fs.writeFileSync(path.join(repoDir, "fork-main.txt"), "fork main\n");
2751 yield* runGit(repoDir, ["add", "fork-main.txt"]);
2752 yield* runGit(repoDir, ["commit", "-m", "Fork main branch"]);
2753 yield* runGit(repoDir, ["push", "-u", "fork-seed", "fork-main-source:main"]);
2754 yield* runGit(repoDir, ["checkout", "main"]);
2755 const mainBefore = (yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim();
2756
2757 const { manager } = yield* makeManager({
2758 ghScenario: {
2759 pullRequest: {
2760 number: 91,
2761 title: "Fork main PR",
2762 url: "https://github.com/pingdotgg/codething-mvp/pull/91",
2763 baseRefName: "main",
2764 headRefName: "main",
2765 state: "open",
2766 isCrossRepository: true,
2767 headRepositoryNameWithOwner: "octocat/codething-mvp",
2768 headRepositoryOwnerLogin: "octocat",
2769 },
2770 repositoryCloneUrls: {
2771 "octocat/codething-mvp": {
2772 url: forkDir,
2773 sshUrl: forkDir,
2774 },
2775 },
2776 },
2777 });
2778
2779 const result = yield* preparePullRequestThread(manager, {
2780 cwd: repoDir,
2781 reference: "91",
2782 mode: "worktree",
2783 });
2784
2785 expect(result.branch).toBe("t3code/pr-91/main");
2786 expect(result.worktreePath).not.toBeNull();
2787 expect((yield* runGit(repoDir, ["branch", "--show-current"])).stdout.trim()).toBe("main");
2788 expect((yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim()).toBe(mainBefore);
2789 expect(
2790 (yield* runGit(result.worktreePath as string, [
2791 "branch",
2792 "--show-current",
2793 ])).stdout.trim(),
2794 ).toBe("t3code/pr-91/main");
2795 }),
2796 );
2797
2798 it.effect(
2799 "does not overwrite an existing local main branch when preparing a fork PR worktree",
2800 () =>
2801 Effect.gen(function* () {
2802 const repoDir = yield* makeTempDir("t3code-git-manager-");
2803 yield* initRepo(repoDir);
2804 const originDir = yield* createBareRemote();
2805 const forkDir = yield* createBareRemote();
2806 yield* runGit(repoDir, ["remote", "add", "origin", originDir]);
2807 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
2808 yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]);
2809 yield* runGit(repoDir, ["checkout", "-b", "fork-main-source"]);
2810 fs.writeFileSync(path.join(repoDir, "fork-main-second.txt"), "fork main second\n");
2811 yield* runGit(repoDir, ["add", "fork-main-second.txt"]);
2812 yield* runGit(repoDir, ["commit", "-m", "Fork main second branch"]);
2813 yield* runGit(repoDir, ["push", "-u", "fork-seed", "fork-main-source:main"]);
2814 yield* runGit(repoDir, ["checkout", "main"]);
2815 const localMainBefore = (yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim();
2816 yield* runGit(repoDir, ["checkout", "-b", "feature/root-branch"]);
2817
2818 const { manager } = yield* makeManager({
2819 ghScenario: {
2820 pullRequest: {
2821 number: 92,
2822 title: "Fork main overwrite PR",
2823 url: "https://github.com/pingdotgg/codething-mvp/pull/92",
2824 baseRefName: "main",
2825 headRefName: "main",
2826 state: "open",
2827 isCrossRepository: true,
2828 headRepositoryNameWithOwner: "octocat/codething-mvp",
2829 headRepositoryOwnerLogin: "octocat",
2830 },
2831 repositoryCloneUrls: {
2832 "octocat/codething-mvp": {
2833 url: forkDir,
2834 sshUrl: forkDir,
2835 },
2836 },
2837 },
2838 });
2839
2840 const result = yield* preparePullRequestThread(manager, {
2841 cwd: repoDir,
2842 reference: "92",
2843 mode: "worktree",
2844 });
2845
2846 expect(result.branch).toBe("t3code/pr-92/main");
2847 expect((yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim()).toBe(localMainBefore);
2848 expect(
2849 (yield* runGit(result.worktreePath as string, [
2850 "rev-parse",
2851 "--abbrev-ref",
2852 "@{upstream}",
2853 ])).stdout.trim(),
2854 ).toBe("fork-seed/main");
2855 }),
2856 );
2857
2858 it.effect("reuses an existing PR worktree and restores fork upstream tracking", () =>
2859 Effect.gen(function* () {
2860 const repoDir = yield* makeTempDir("t3code-git-manager-");
2861 yield* initRepo(repoDir);
2862 const originDir = yield* createBareRemote();
2863 const forkDir = yield* createBareRemote();
2864 yield* runGit(repoDir, ["remote", "add", "origin", originDir]);
2865 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
2866 yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]);
2867 yield* runGit(repoDir, ["checkout", "-b", "feature/pr-reused-fork"]);
2868 fs.writeFileSync(path.join(repoDir, "reused-fork.txt"), "reused fork\n");
2869 yield* runGit(repoDir, ["add", "reused-fork.txt"]);
2870 yield* runGit(repoDir, ["commit", "-m", "Reused fork PR branch"]);
2871 yield* runGit(repoDir, ["push", "-u", "fork-seed", "feature/pr-reused-fork"]);
2872 yield* runGit(repoDir, ["checkout", "main"]);
2873 const worktreePath = path.join(repoDir, "..", `pr-reused-fork-${Date.now()}`);
2874 yield* runGit(repoDir, ["worktree", "add", worktreePath, "feature/pr-reused-fork"]);
2875 yield* runGit(worktreePath, ["branch", "--unset-upstream"], true);
2876
2877 const { manager } = yield* makeManager({
2878 ghScenario: {
2879 pullRequest: {
2880 number: 83,
2881 title: "Reused Fork PR",
2882 url: "https://github.com/pingdotgg/codething-mvp/pull/83",
2883 baseRefName: "main",
2884 headRefName: "feature/pr-reused-fork",
2885 state: "open",
2886 isCrossRepository: true,
2887 headRepositoryNameWithOwner: "octocat/codething-mvp",
2888 headRepositoryOwnerLogin: "octocat",
2889 },
2890 repositoryCloneUrls: {
2891 "octocat/codething-mvp": {
2892 url: forkDir,
2893 sshUrl: forkDir,
2894 },
2895 },
2896 },
2897 });
2898
2899 const result = yield* preparePullRequestThread(manager, {
2900 cwd: repoDir,
2901 reference: "83",
2902 mode: "worktree",
2903 });
2904
2905 expect(result.worktreePath && fs.realpathSync.native(result.worktreePath)).toBe(
2906 fs.realpathSync.native(worktreePath),
2907 );
2908 expect(
2909 (yield* runGit(worktreePath, ["rev-parse", "--abbrev-ref", "@{upstream}"])).stdout.trim(),
2910 ).toBe("fork-seed/feature/pr-reused-fork");
2911 }),
2912 );
2913
2914 it.effect("does not fail PR worktree prep when setup terminal startup fails", () =>
2915 Effect.gen(function* () {
2916 const repoDir = yield* makeTempDir("t3code-git-manager-");
2917 yield* initRepo(repoDir);
2918 const remoteDir = yield* createBareRemote();
2919 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
2920 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
2921 yield* runGit(repoDir, ["checkout", "-b", "feature/pr-setup-failure"]);
2922 fs.writeFileSync(path.join(repoDir, "setup-failure.txt"), "setup failure\n");
2923 yield* runGit(repoDir, ["add", "setup-failure.txt"]);
2924 yield* runGit(repoDir, ["commit", "-m", "PR setup failure branch"]);
2925 yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-setup-failure"]);
2926 yield* runGit(repoDir, ["push", "origin", "HEAD:refs/pull/184/head"]);
2927 yield* runGit(repoDir, ["checkout", "main"]);
2928
2929 const { manager } = yield* makeManager({
2930 ghScenario: {
2931 pullRequest: {
2932 number: 184,
2933 title: "Setup failure PR",
2934 url: "https://github.com/pingdotgg/codething-mvp/pull/184",
2935 baseRefName: "main",
2936 headRefName: "feature/pr-setup-failure",
2937 state: "open",
2938 },
2939 },
2940 setupScriptRunner: {
2941 runForThread: () => Effect.fail(new Error("terminal start failed")),
2942 },
2943 });
2944
2945 const result = yield* preparePullRequestThread(manager, {
2946 cwd: repoDir,
2947 reference: "184",
2948 mode: "worktree",
2949 threadId: asThreadId("thread-pr-setup-failure"),
2950 });
2951
2952 expect(result.branch).toBe("feature/pr-setup-failure");
2953 expect(result.worktreePath).not.toBeNull();
2954 expect(fs.existsSync(result.worktreePath as string)).toBe(true);
2955 }),
2956 );
2957
2958 it.effect("rejects worktree prep when the PR head branch is checked out in the main repo", () =>
2959 Effect.gen(function* () {
2960 const repoDir = yield* makeTempDir("t3code-git-manager-");
2961 yield* initRepo(repoDir);
2962 yield* runGit(repoDir, ["checkout", "-b", "feature/pr-root-only"]);
2963
2964 const { manager } = yield* makeManager({
2965 ghScenario: {
2966 pullRequest: {
2967 number: 79,
2968 title: "Root-only PR",
2969 url: "https://github.com/pingdotgg/codething-mvp/pull/79",
2970 baseRefName: "main",
2971 headRefName: "feature/pr-root-only",
2972 state: "open",
2973 },
2974 },
2975 });
2976
2977 const errorMessage = yield* preparePullRequestThread(manager, {
2978 cwd: repoDir,
2979 reference: "79",
2980 mode: "worktree",
2981 }).pipe(
2982 Effect.flip,
2983 Effect.map((error) => error.message),
2984 );
2985
2986 expect(errorMessage).toContain("already checked out in the main repo");
2987 }),
2988 );
2989
2990 it.effect("emits ordered progress events for commit hooks", () =>
2991 Effect.gen(function* () {
2992 const repoDir = yield* makeTempDir("t3code-git-manager-");
2993 yield* initRepo(repoDir);
2994 fs.writeFileSync(path.join(repoDir, "hooked.txt"), "hooked\n");
2995 fs.writeFileSync(
2996 path.join(repoDir, ".git", "hooks", "pre-commit"),
2997 '#!/bin/sh\necho "hook: start" >&2\nsleep 1\necho "hook: end" >&2\n',
2998 { mode: 0o755 },
2999 );
3000
3001 const { manager } = yield* makeManager();
3002 const events: GitActionProgressEvent[] = [];
3003
3004 const result = yield* runStackedAction(
3005 manager,
3006 {
3007 cwd: repoDir,
3008 action: "commit",
3009 },
3010 {
3011 actionId: "action-1",
3012 progressReporter: {
3013 publish: (event) =>
3014 Effect.sync(() => {
3015 events.push(event);
3016 }),
3017 },
3018 },
3019 );
3020
3021 expect(result.commit.status).toBe("created");
3022 expect(events.map((event) => event.kind)).toContain("action_started");
3023 expect(events).toEqual(
3024 expect.arrayContaining([
3025 expect.objectContaining({
3026 kind: "phase_started",
3027 phase: "commit",
3028 }),
3029 expect.objectContaining({
3030 kind: "hook_started",
3031 hookName: "pre-commit",
3032 }),
3033 expect.objectContaining({
3034 kind: "hook_output",
3035 text: "hook: start",
3036 }),
3037 expect.objectContaining({
3038 kind: "hook_output",
3039 text: "hook: end",
3040 }),
3041 expect.objectContaining({
3042 kind: "hook_finished",
3043 hookName: "pre-commit",
3044 }),
3045 expect.objectContaining({
3046 kind: "action_finished",
3047 }),
3048 ]),
3049 );
3050 }),
3051 );
3052
3053 it.effect("emits action_failed when a commit hook rejects", () =>
3054 Effect.gen(function* () {
3055 const repoDir = yield* makeTempDir("t3code-git-manager-");
3056 yield* initRepo(repoDir);
3057 fs.writeFileSync(path.join(repoDir, "hook-failure.txt"), "broken\n");
3058 fs.writeFileSync(
3059 path.join(repoDir, ".git", "hooks", "pre-commit"),
3060 '#!/bin/sh\necho "hook: fail" >&2\nexit 1\n',
3061 { mode: 0o755 },
3062 );
3063
3064 const { manager } = yield* makeManager();
3065 const events: GitActionProgressEvent[] = [];
3066
3067 const errorMessage = yield* runStackedAction(
3068 manager,
3069 {
3070 cwd: repoDir,
3071 action: "commit",
3072 },
3073 {
3074 actionId: "action-2",
3075 progressReporter: {
3076 publish: (event) =>
3077 Effect.sync(() => {
3078 events.push(event);
3079 }),
3080 },
3081 },
3082 ).pipe(
3083 Effect.flip,
3084 Effect.map((error) => error.message),
3085 );
3086
3087 expect(errorMessage).toContain("hook: fail");
3088 expect(events).toEqual(
3089 expect.arrayContaining([
3090 expect.objectContaining({
3091 kind: "hook_started",
3092 hookName: "pre-commit",
3093 }),
3094 expect.objectContaining({
3095 kind: "action_failed",
3096 phase: "commit",
3097 }),
3098 ]),
3099 );
3100 }),
3101 );
3102
3103 it.effect("create_pr emits only the PR phase when the branch is already pushed", () =>
3104 Effect.gen(function* () {
3105 const repoDir = yield* makeTempDir("t3code-git-manager-");
3106 yield* initRepo(repoDir);
3107 yield* runGit(repoDir, ["checkout", "-b", "feature/pr-only-follow-up"]);
3108 const remoteDir = yield* createBareRemote();
3109 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
3110 fs.writeFileSync(path.join(repoDir, "pr-only.txt"), "pr only\n");
3111 yield* runGit(repoDir, ["add", "pr-only.txt"]);
3112 yield* runGit(repoDir, ["commit", "-m", "PR only branch"]);
3113 yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-only-follow-up"]);
3114
3115 const { manager } = yield* makeManager({
3116 ghScenario: {
3117 prListSequence: [
3118 JSON.stringify([]),
3119 JSON.stringify([
3120 {
3121 number: 201,
3122 title: "PR only branch",
3123 url: "https://github.com/pingdotgg/codething-mvp/pull/201",
3124 baseRefName: "main",
3125 headRefName: "feature/pr-only-follow-up",
3126 state: "OPEN",
3127 isCrossRepository: false,
3128 },
3129 ]),
3130 ],
3131 },
3132 });
3133 const events: GitActionProgressEvent[] = [];
3134
3135 const result = yield* runStackedAction(
3136 manager,
3137 {
3138 cwd: repoDir,
3139 action: "create_pr",
3140 },
3141 {
3142 actionId: "action-pr-only",
3143 progressReporter: {
3144 publish: (event) =>
3145 Effect.sync(() => {
3146 events.push(event);
3147 }),
3148 },
3149 },
3150 );
3151
3152 expect(result.commit.status).toBe("skipped_not_requested");
3153 expect(result.push.status).toBe("skipped_not_requested");
3154 expect(result.pr.status).toBe("created");
3155 expect(
3156 events.filter(
3157 (event): event is Extract<GitActionProgressEvent, { kind: "phase_started" }> =>
3158 event.kind === "phase_started",
3159 ),
3160 ).toEqual([
3161 expect.objectContaining({
3162 kind: "phase_started",
3163 phase: "pr",
3164 label: "Preparing PR...",
3165 }),
3166 expect.objectContaining({
3167 kind: "phase_started",
3168 phase: "pr",
3169 label: "Generating PR content...",
3170 }),
3171 expect.objectContaining({
3172 kind: "phase_started",
3173 phase: "pr",
3174 label: "Creating pull request...",
3175 }),
3176 ]);
3177 }),
3178 );
3179});
apps/server/src/git/GitManager.ts created+1769
......@@ -0,0 +1,1769 @@
1import { randomUUID } from "node:crypto";
2
3import {
4 Array as Arr,
5 Cache,
6 Context,
7 DateTime,
8 Duration,
9 Effect,
10 Exit,
11 FileSystem,
12 Layer,
13 Option,
14 Order,
15 Path,
16 Ref,
17} from "effect";
18import {
19 GitActionProgressEvent,
20 GitActionProgressPhase,
21 GitCommandError,
22 GitPreparePullRequestThreadInput,
23 GitPreparePullRequestThreadResult,
24 GitPullRequestRefInput,
25 GitResolvePullRequestResult,
26 GitRunStackedActionInput,
27 GitRunStackedActionResult,
28 GitStackedAction,
29 VcsStatusInput,
30 type VcsStatusLocalResult,
31 type VcsStatusRemoteResult,
32 VcsStatusResult,
33 ModelSelection,
34} from "@t3tools/contracts";
35import {
36 detectSourceControlProviderFromGitRemoteUrl,
37 mergeGitStatusParts,
38 resolveAutoFeatureBranchName,
39 sanitizeBranchFragment,
40 sanitizeFeatureBranchName,
41} from "@t3tools/shared/git";
42import {
43 getChangeRequestTerminologyForKind,
44 type ChangeRequestTerminology,
45} from "@t3tools/shared/sourceControl";
46
47import { GitManagerError } from "@t3tools/contracts";
48import { TextGeneration } from "../textGeneration/TextGeneration.ts";
49import { ProjectSetupScriptRunner } from "../project/Services/ProjectSetupScriptRunner.ts";
50import { extractBranchNameFromRemoteRef } from "./remoteRefs.ts";
51import { ServerSettingsService } from "../serverSettings.ts";
52import type { GitManagerServiceError } from "@t3tools/contracts";
53import { GitVcsDriver, type GitStatusDetails } from "../vcs/GitVcsDriver.ts";
54import { SourceControlProviderRegistry } from "../sourceControl/SourceControlProviderRegistry.ts";
55import type { ChangeRequest } from "@t3tools/contracts";
56
57export interface GitActionProgressReporter {
58 readonly publish: (event: GitActionProgressEvent) => Effect.Effect<void, never>;
59}
60
61export interface GitRunStackedActionOptions {
62 readonly actionId?: string;
63 readonly progressReporter?: GitActionProgressReporter;
64}
65
66export interface GitManagerShape {
67 readonly status: (
68 input: VcsStatusInput,
69 ) => Effect.Effect<VcsStatusResult, GitManagerServiceError>;
70 readonly localStatus: (
71 input: VcsStatusInput,
72 ) => Effect.Effect<VcsStatusLocalResult, GitManagerServiceError>;
73 readonly remoteStatus: (
74 input: VcsStatusInput,
75 ) => Effect.Effect<VcsStatusRemoteResult | null, GitManagerServiceError>;
76 readonly invalidateLocalStatus: (cwd: string) => Effect.Effect<void, never>;
77 readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect<void, never>;
78 readonly invalidateStatus: (cwd: string) => Effect.Effect<void, never>;
79 readonly resolvePullRequest: (
80 input: GitPullRequestRefInput,
81 ) => Effect.Effect<GitResolvePullRequestResult, GitManagerServiceError>;
82 readonly preparePullRequestThread: (
83 input: GitPreparePullRequestThreadInput,
84 ) => Effect.Effect<GitPreparePullRequestThreadResult, GitManagerServiceError>;
85 readonly runStackedAction: (
86 input: GitRunStackedActionInput,
87 options?: GitRunStackedActionOptions,
88 ) => Effect.Effect<GitRunStackedActionResult, GitManagerServiceError>;
89}
90
91export class GitManager extends Context.Service<GitManager, GitManagerShape>()(
92 "t3/git/GitManager",
93) {}
94
95const COMMIT_TIMEOUT_MS = 10 * 60_000;
96const MAX_PROGRESS_TEXT_LENGTH = 500;
97const SHORT_SHA_LENGTH = 7;
98const TOAST_DESCRIPTION_MAX = 72;
99const STATUS_RESULT_CACHE_TTL = Duration.seconds(1);
100const STATUS_RESULT_CACHE_CAPACITY = 2_048;
101type StripProgressContext<T> = T extends any ? Omit<T, "actionId" | "cwd" | "action"> : never;
102type GitActionProgressPayload = StripProgressContext<GitActionProgressEvent>;
103type GitActionProgressEmitter = (event: GitActionProgressPayload) => Effect.Effect<void, never>;
104
105function isNotGitRepositoryError(error: GitCommandError): boolean {
106 return error.message.toLowerCase().includes("not a git repository");
107}
108
109interface OpenPrInfo {
110 number: number;
111 title: string;
112 url: string;
113 baseRefName: string;
114 headRefName: string;
115}
116
117interface PullRequestInfo extends OpenPrInfo, PullRequestHeadRemoteInfo {
118 state: "open" | "closed" | "merged";
119 updatedAt: Option.Option<DateTime.Utc>;
120}
121
122const pullRequestUpdatedAtDescOrder: Order.Order<PullRequestInfo> = Order.mapInput(
123 Order.flip(Option.makeOrder(DateTime.Order)),
124 (pullRequest) => pullRequest.updatedAt,
125);
126
127interface ResolvedPullRequest {
128 number: number;
129 title: string;
130 url: string;
131 baseBranch: string;
132 headBranch: string;
133 state: "open" | "closed" | "merged";
134}
135
136interface PullRequestHeadRemoteInfo {
137 isCrossRepository?: boolean | undefined;
138 headRepositoryNameWithOwner?: string | null | undefined;
139 headRepositoryOwnerLogin?: string | null | undefined;
140}
141
142interface BranchHeadContext {
143 localBranch: string;
144 headBranch: string;
145 headSelectors: ReadonlyArray<string>;
146 preferredHeadSelector: string;
147 remoteName: string | null;
148 headRepositoryNameWithOwner: string | null;
149 headRepositoryOwnerLogin: string | null;
150 isCrossRepository: boolean;
151}
152
153function parseRepositoryNameFromPullRequestUrl(url: string): string | null {
154 const trimmed = url.trim();
155 const match = /^https:\/\/github\.com\/[^/]+\/([^/]+)\/pull\/\d+(?:\/.*)?$/i.exec(trimmed);
156 const repositoryName = match?.[1]?.trim() ?? "";
157 return repositoryName.length > 0 ? repositoryName : null;
158}
159
160function resolveHeadRepositoryNameWithOwner(
161 pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo,
162): string | null {
163 const explicitRepository = pullRequest.headRepositoryNameWithOwner?.trim() ?? "";
164 if (explicitRepository.length > 0) {
165 return explicitRepository;
166 }
167
168 if (!pullRequest.isCrossRepository) {
169 return null;
170 }
171
172 const ownerLogin = pullRequest.headRepositoryOwnerLogin?.trim() ?? "";
173 const repositoryName = parseRepositoryNameFromPullRequestUrl(pullRequest.url);
174 if (ownerLogin.length === 0 || !repositoryName) {
175 return null;
176 }
177
178 return `${ownerLogin}/${repositoryName}`;
179}
180
181function resolvePullRequestWorktreeLocalBranchName(
182 pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo,
183): string {
184 if (!pullRequest.isCrossRepository) {
185 return pullRequest.headBranch;
186 }
187
188 const sanitizedHeadBranch = sanitizeBranchFragment(pullRequest.headBranch).trim();
189 const suffix = sanitizedHeadBranch.length > 0 ? sanitizedHeadBranch : "head";
190 return `t3code/pr-${pullRequest.number}/${suffix}`;
191}
192
193function parseGitHubRepositoryNameWithOwnerFromRemoteUrl(url: string | null): string | null {
194 const trimmed = url?.trim() ?? "";
195 if (trimmed.length === 0) {
196 return null;
197 }
198
199 const match =
200 /^(?:git@github\.com:|ssh:\/\/git@github\.com\/|https:\/\/github\.com\/|git:\/\/github\.com\/)([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/i.exec(
201 trimmed,
202 );
203 const repositoryNameWithOwner = match?.[1]?.trim() ?? "";
204 return repositoryNameWithOwner.length > 0 ? repositoryNameWithOwner : null;
205}
206
207function parseRepositoryOwnerLogin(nameWithOwner: string | null): string | null {
208 const trimmed = nameWithOwner?.trim() ?? "";
209 if (trimmed.length === 0) {
210 return null;
211 }
212 const [ownerLogin] = trimmed.split("/");
213 const normalizedOwnerLogin = ownerLogin?.trim() ?? "";
214 return normalizedOwnerLogin.length > 0 ? normalizedOwnerLogin : null;
215}
216
217function normalizeOptionalString(value: string | null | undefined): string | null {
218 const trimmed = value?.trim() ?? "";
219 return trimmed.length > 0 ? trimmed : null;
220}
221
222function normalizeOptionalRepositoryNameWithOwner(value: string | null | undefined): string | null {
223 const normalized = normalizeOptionalString(value);
224 return normalized ? normalized.toLowerCase() : null;
225}
226
227function normalizeOptionalOwnerLogin(value: string | null | undefined): string | null {
228 const normalized = normalizeOptionalString(value);
229 return normalized ? normalized.toLowerCase() : null;
230}
231
232function resolvePullRequestHeadRepositoryNameWithOwner(
233 pr: PullRequestHeadRemoteInfo & { url: string },
234) {
235 const explicitRepository = normalizeOptionalString(pr.headRepositoryNameWithOwner);
236 if (explicitRepository) {
237 return explicitRepository;
238 }
239
240 if (!pr.isCrossRepository) {
241 return null;
242 }
243
244 const ownerLogin = normalizeOptionalString(pr.headRepositoryOwnerLogin);
245 const repositoryName = parseRepositoryNameFromPullRequestUrl(pr.url);
246 if (!ownerLogin || !repositoryName) {
247 return null;
248 }
249
250 return `${ownerLogin}/${repositoryName}`;
251}
252
253function matchesBranchHeadContext(
254 pr: PullRequestInfo,
255 headContext: Pick<
256 BranchHeadContext,
257 "headBranch" | "headRepositoryNameWithOwner" | "headRepositoryOwnerLogin" | "isCrossRepository"
258 >,
259): boolean {
260 if (pr.headRefName !== headContext.headBranch) {
261 return false;
262 }
263
264 const expectedHeadRepository = normalizeOptionalRepositoryNameWithOwner(
265 headContext.headRepositoryNameWithOwner,
266 );
267 const expectedHeadOwner =
268 normalizeOptionalOwnerLogin(headContext.headRepositoryOwnerLogin) ??
269 parseRepositoryOwnerLogin(expectedHeadRepository);
270 const prHeadRepository = normalizeOptionalRepositoryNameWithOwner(
271 resolvePullRequestHeadRepositoryNameWithOwner(pr),
272 );
273 const prHeadOwner =
274 normalizeOptionalOwnerLogin(pr.headRepositoryOwnerLogin) ??
275 parseRepositoryOwnerLogin(prHeadRepository);
276
277 if (headContext.isCrossRepository) {
278 if (pr.isCrossRepository === false) {
279 return false;
280 }
281 if ((expectedHeadRepository || expectedHeadOwner) && !prHeadRepository && !prHeadOwner) {
282 return false;
283 }
284 if (expectedHeadRepository && prHeadRepository && expectedHeadRepository !== prHeadRepository) {
285 return false;
286 }
287 if (expectedHeadOwner && prHeadOwner && expectedHeadOwner !== prHeadOwner) {
288 return false;
289 }
290 return true;
291 }
292
293 if (pr.isCrossRepository === true) {
294 return false;
295 }
296 if (expectedHeadRepository && prHeadRepository && expectedHeadRepository !== prHeadRepository) {
297 return false;
298 }
299 if (expectedHeadOwner && prHeadOwner && expectedHeadOwner !== prHeadOwner) {
300 return false;
301 }
302 return true;
303}
304
305function toPullRequestInfo(summary: ChangeRequest): PullRequestInfo {
306 return {
307 number: summary.number,
308 title: summary.title,
309 url: summary.url,
310 baseRefName: summary.baseRefName,
311 headRefName: summary.headRefName,
312 state: summary.state ?? "open",
313 updatedAt: summary.updatedAt,
314 ...(summary.isCrossRepository !== undefined
315 ? { isCrossRepository: summary.isCrossRepository }
316 : {}),
317 ...(summary.headRepositoryNameWithOwner !== undefined
318 ? { headRepositoryNameWithOwner: summary.headRepositoryNameWithOwner }
319 : {}),
320 ...(summary.headRepositoryOwnerLogin !== undefined
321 ? { headRepositoryOwnerLogin: summary.headRepositoryOwnerLogin }
322 : {}),
323 };
324}
325
326function gitManagerError(operation: string, detail: string, cause?: unknown): GitManagerError {
327 return new GitManagerError({
328 operation,
329 detail,
330 ...(cause !== undefined ? { cause } : {}),
331 });
332}
333
334function limitContext(value: string, maxChars: number): string {
335 if (value.length <= maxChars) return value;
336 return `${value.slice(0, maxChars)}\n\n[truncated]`;
337}
338
339function shortenSha(sha: string | undefined): string | null {
340 if (!sha) return null;
341 return sha.slice(0, SHORT_SHA_LENGTH);
342}
343
344function truncateText(
345 value: string | undefined,
346 maxLength = TOAST_DESCRIPTION_MAX,
347): string | undefined {
348 if (!value) return undefined;
349 if (value.length <= maxLength) return value;
350 if (maxLength <= 3) return "...".slice(0, maxLength);
351 return `${value.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`;
352}
353
354function withDescription(title: string, description: string | undefined) {
355 return description ? { title, description } : { title };
356}
357
358function summarizeGitActionResult(
359 result: Pick<GitRunStackedActionResult, "commit" | "push" | "pr">,
360 terms: ChangeRequestTerminology,
361): {
362 title: string;
363 description?: string;
364} {
365 if (result.pr.status === "created" || result.pr.status === "opened_existing") {
366 const prNumber = result.pr.number ? ` #${result.pr.number}` : "";
367 const title = `${result.pr.status === "created" ? "Created" : "Opened"} ${terms.shortLabel}${prNumber}`;
368 return withDescription(title, truncateText(result.pr.title));
369 }
370
371 if (result.push.status === "pushed") {
372 const shortSha = shortenSha(result.commit.commitSha);
373 const branch = result.push.upstreamBranch ?? result.push.branch;
374 const pushedCommitPart = shortSha ? ` ${shortSha}` : "";
375 const branchPart = branch ? ` to ${branch}` : "";
376 return withDescription(
377 `Pushed${pushedCommitPart}${branchPart}`,
378 truncateText(result.commit.subject),
379 );
380 }
381
382 if (result.commit.status === "created") {
383 const shortSha = shortenSha(result.commit.commitSha);
384 const title = shortSha ? `Committed ${shortSha}` : "Committed changes";
385 return withDescription(title, truncateText(result.commit.subject));
386 }
387
388 return { title: "Done" };
389}
390
391function sanitizeCommitMessage(generated: {
392 subject: string;
393 body: string;
394 branch?: string | undefined;
395}): {
396 subject: string;
397 body: string;
398 branch?: string | undefined;
399} {
400 const rawSubject = generated.subject.trim().split(/\r?\n/g)[0]?.trim() ?? "";
401 const subject = rawSubject.replace(/[.]+$/g, "").trim();
402 const safeSubject = subject.length > 0 ? subject.slice(0, 72).trimEnd() : "Update project files";
403 return {
404 subject: safeSubject,
405 body: generated.body.trim(),
406 ...(generated.branch !== undefined ? { branch: generated.branch } : {}),
407 };
408}
409
410function sanitizeProgressText(value: string): string | null {
411 const trimmed = value.trim();
412 if (trimmed.length === 0) {
413 return null;
414 }
415 if (trimmed.length <= MAX_PROGRESS_TEXT_LENGTH) {
416 return trimmed;
417 }
418 return trimmed.slice(0, MAX_PROGRESS_TEXT_LENGTH).trimEnd();
419}
420
421interface CommitAndBranchSuggestion {
422 subject: string;
423 body: string;
424 branch?: string | undefined;
425 commitMessage: string;
426}
427
428function isCommitAction(
429 action: GitStackedAction,
430): action is "commit" | "commit_push" | "commit_push_pr" {
431 return action === "commit" || action === "commit_push" || action === "commit_push_pr";
432}
433
434function formatCommitMessage(subject: string, body: string): string {
435 const trimmedBody = body.trim();
436 if (trimmedBody.length === 0) {
437 return subject;
438 }
439 return `${subject}\n\n${trimmedBody}`;
440}
441
442function parseCustomCommitMessage(raw: string): { subject: string; body: string } | null {
443 const normalized = raw.replace(/\r\n/g, "\n").trim();
444 if (normalized.length === 0) {
445 return null;
446 }
447
448 const [firstLine, ...rest] = normalized.split("\n");
449 const subject = firstLine?.trim() ?? "";
450 if (subject.length === 0) {
451 return null;
452 }
453
454 return {
455 subject,
456 body: rest.join("\n").trim(),
457 };
458}
459
460function appendUnique(values: string[], next: string | null | undefined): void {
461 const trimmed = next?.trim() ?? "";
462 if (trimmed.length === 0 || values.includes(trimmed)) {
463 return;
464 }
465 values.push(trimmed);
466}
467
468function toStatusPr(pr: PullRequestInfo): {
469 number: number;
470 title: string;
471 url: string;
472 baseRef: string;
473 headRef: string;
474 state: "open" | "closed" | "merged";
475} {
476 return {
477 number: pr.number,
478 title: pr.title,
479 url: pr.url,
480 baseRef: pr.baseRefName,
481 headRef: pr.headRefName,
482 state: pr.state,
483 };
484}
485
486function normalizePullRequestReference(reference: string): string {
487 const trimmed = reference.trim();
488 const hashNumber = /^#(\d+)$/.exec(trimmed);
489 return hashNumber?.[1] ?? trimmed;
490}
491
492function toResolvedPullRequest(pr: {
493 number: number;
494 title: string;
495 url: string;
496 baseRefName: string;
497 headRefName: string;
498 state?: "open" | "closed" | "merged";
499}): ResolvedPullRequest {
500 return {
501 number: pr.number,
502 title: pr.title,
503 url: pr.url,
504 baseBranch: pr.baseRefName,
505 headBranch: pr.headRefName,
506 state: pr.state ?? "open",
507 };
508}
509
510function shouldPreferSshRemote(url: string | null): boolean {
511 if (!url) return false;
512 const trimmed = url.trim();
513 return trimmed.startsWith("git@") || trimmed.startsWith("ssh://");
514}
515
516function toPullRequestHeadRemoteInfo(pr: {
517 isCrossRepository?: boolean | undefined;
518 headRepositoryNameWithOwner?: string | null | undefined;
519 headRepositoryOwnerLogin?: string | null | undefined;
520}): PullRequestHeadRemoteInfo {
521 return {
522 ...(pr.isCrossRepository !== undefined ? { isCrossRepository: pr.isCrossRepository } : {}),
523 ...(pr.headRepositoryNameWithOwner !== undefined
524 ? { headRepositoryNameWithOwner: pr.headRepositoryNameWithOwner }
525 : {}),
526 ...(pr.headRepositoryOwnerLogin !== undefined
527 ? { headRepositoryOwnerLogin: pr.headRepositoryOwnerLogin }
528 : {}),
529 };
530}
531
532export const makeGitManager = Effect.fn("makeGitManager")(function* () {
533 const gitCore = yield* GitVcsDriver;
534 const sourceControlProviders = yield* SourceControlProviderRegistry;
535 const textGeneration = yield* TextGeneration;
536 const projectSetupScriptRunner = yield* ProjectSetupScriptRunner;
537
538 const sourceControlProvider = (cwd: string) => sourceControlProviders.resolve({ cwd });
539 const serverSettingsService = yield* ServerSettingsService;
540
541 const createProgressEmitter = (
542 input: { cwd: string; action: GitStackedAction },
543 options?: GitRunStackedActionOptions,
544 ) => {
545 const actionId = options?.actionId ?? randomUUID();
546 const reporter = options?.progressReporter;
547
548 const emit = (event: GitActionProgressPayload) =>
549 reporter
550 ? reporter.publish({
551 actionId,
552 cwd: input.cwd,
553 action: input.action,
554 ...event,
555 } as GitActionProgressEvent)
556 : Effect.void;
557
558 return {
559 actionId,
560 emit,
561 };
562 };
563
564 const configurePullRequestHeadUpstreamBase = Effect.fn("configurePullRequestHeadUpstream")(
565 function* (
566 cwd: string,
567 pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo,
568 localBranch = pullRequest.headBranch,
569 ) {
570 const repositoryNameWithOwner = resolveHeadRepositoryNameWithOwner(pullRequest) ?? "";
571 if (repositoryNameWithOwner.length === 0) {
572 return;
573 }
574
575 const cloneUrls = yield* (yield* sourceControlProvider(cwd)).getRepositoryCloneUrls({
576 cwd,
577 repository: repositoryNameWithOwner,
578 });
579 const originRemoteUrl = yield* gitCore.readConfigValue(cwd, "remote.origin.url");
580 const remoteUrl = shouldPreferSshRemote(originRemoteUrl) ? cloneUrls.sshUrl : cloneUrls.url;
581 const preferredRemoteName =
582 pullRequest.headRepositoryOwnerLogin?.trim() ||
583 repositoryNameWithOwner.split("/")[0]?.trim() ||
584 "fork";
585 const remoteName = yield* gitCore.ensureRemote({
586 cwd,
587 preferredName: preferredRemoteName,
588 url: remoteUrl,
589 });
590
591 yield* gitCore.setBranchUpstream({
592 cwd,
593 branch: localBranch,
594 remoteName,
595 remoteBranch: pullRequest.headBranch,
596 });
597 },
598 );
599
600 const configurePullRequestHeadUpstream = (
601 cwd: string,
602 pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo,
603 localBranch = pullRequest.headBranch,
604 ) =>
605 configurePullRequestHeadUpstreamBase(cwd, pullRequest, localBranch).pipe(
606 Effect.catch((error) =>
607 Effect.logWarning(
608 `GitManager.configurePullRequestHeadUpstream: failed to configure upstream for ${localBranch} -> ${pullRequest.headBranch} in ${cwd}: ${error.message}`,
609 ).pipe(Effect.asVoid),
610 ),
611 );
612
613 const materializePullRequestHeadBranchBase = Effect.fn("materializePullRequestHeadBranch")(
614 function* (
615 cwd: string,
616 pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo,
617 localBranch = pullRequest.headBranch,
618 ) {
619 const repositoryNameWithOwner = resolveHeadRepositoryNameWithOwner(pullRequest) ?? "";
620
621 if (repositoryNameWithOwner.length === 0) {
622 yield* gitCore.fetchPullRequestBranch({
623 cwd,
624 prNumber: pullRequest.number,
625 branch: localBranch,
626 });
627 return;
628 }
629
630 const cloneUrls = yield* (yield* sourceControlProvider(cwd)).getRepositoryCloneUrls({
631 cwd,
632 repository: repositoryNameWithOwner,
633 });
634 const originRemoteUrl = yield* gitCore.readConfigValue(cwd, "remote.origin.url");
635 const remoteUrl = shouldPreferSshRemote(originRemoteUrl) ? cloneUrls.sshUrl : cloneUrls.url;
636 const preferredRemoteName =
637 pullRequest.headRepositoryOwnerLogin?.trim() ||
638 repositoryNameWithOwner.split("/")[0]?.trim() ||
639 "fork";
640 const remoteName = yield* gitCore.ensureRemote({
641 cwd,
642 preferredName: preferredRemoteName,
643 url: remoteUrl,
644 });
645
646 yield* gitCore.fetchRemoteBranch({
647 cwd,
648 remoteName,
649 remoteBranch: pullRequest.headBranch,
650 localBranch,
651 });
652 yield* gitCore.setBranchUpstream({
653 cwd,
654 branch: localBranch,
655 remoteName,
656 remoteBranch: pullRequest.headBranch,
657 });
658 },
659 );
660
661 const materializePullRequestHeadBranch = (
662 cwd: string,
663 pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo,
664 localBranch = pullRequest.headBranch,
665 ) =>
666 materializePullRequestHeadBranchBase(cwd, pullRequest, localBranch).pipe(
667 Effect.catch(() =>
668 gitCore.fetchPullRequestBranch({
669 cwd,
670 prNumber: pullRequest.number,
671 branch: localBranch,
672 }),
673 ),
674 );
675 const fileSystem = yield* FileSystem.FileSystem;
676 const path = yield* Path.Path;
677
678 const tempDir = process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp";
679 const canonicalizeExistingPath = (value: string) =>
680 fileSystem.realPath(value).pipe(Effect.catch(() => Effect.succeed(value)));
681 const normalizeStatusCacheKey = canonicalizeExistingPath;
682 const nonRepositoryStatusDetails = {
683 isRepo: false,
684 hasOriginRemote: false,
685 isDefaultBranch: false,
686 branch: null,
687 upstreamRef: null,
688 hasWorkingTreeChanges: false,
689 workingTree: { files: [], insertions: 0, deletions: 0 },
690 hasUpstream: false,
691 aheadCount: 0,
692 behindCount: 0,
693 } satisfies GitStatusDetails;
694 const readLocalStatus = Effect.fn("readLocalStatus")(function* (cwd: string) {
695 const details = yield* gitCore
696 .statusDetailsLocal(cwd)
697 .pipe(
698 Effect.catchIf(isNotGitRepositoryError, () => Effect.succeed(nonRepositoryStatusDetails)),
699 );
700 const hostingProvider = details.isRepo
701 ? yield* resolveHostingProvider(cwd, details.branch)
702 : null;
703
704 return {
705 isRepo: details.isRepo,
706 ...(hostingProvider ? { sourceControlProvider: hostingProvider } : {}),
707 hasPrimaryRemote: details.hasOriginRemote,
708 isDefaultRef: details.isDefaultBranch,
709 refName: details.branch,
710 hasWorkingTreeChanges: details.hasWorkingTreeChanges,
711 workingTree: details.workingTree,
712 } satisfies VcsStatusLocalResult;
713 });
714 const localStatusResultCache = yield* Cache.makeWith(readLocalStatus, {
715 capacity: STATUS_RESULT_CACHE_CAPACITY,
716 timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_RESULT_CACHE_TTL : Duration.zero),
717 });
718 const invalidateLocalStatusResultCache = (cwd: string) =>
719 normalizeStatusCacheKey(cwd).pipe(
720 Effect.flatMap((cacheKey) => Cache.invalidate(localStatusResultCache, cacheKey)),
721 );
722 const readRemoteStatus = Effect.fn("readRemoteStatus")(function* (cwd: string) {
723 const details = yield* gitCore
724 .statusDetails(cwd)
725 .pipe(Effect.catchIf(isNotGitRepositoryError, () => Effect.succeed(null)));
726 if (details === null || !details.isRepo) {
727 return null;
728 }
729
730 const pr =
731 details.branch !== null
732 ? yield* findLatestPr(cwd, {
733 branch: details.branch,
734 upstreamRef: details.upstreamRef,
735 }).pipe(
736 Effect.map((latest) => {
737 if (!latest) return null;
738 // On the default branch, only surface open PRs.
739 // Merged/closed matches are usually reverse-merge history, not the thread's PR context.
740 if (details.isDefaultBranch && latest.state !== "open") return null;
741 return toStatusPr(latest);
742 }),
743 Effect.catch(() => Effect.succeed(null)),
744 )
745 : null;
746
747 return {
748 hasUpstream: details.hasUpstream,
749 aheadCount: details.aheadCount,
750 behindCount: details.behindCount,
751 pr,
752 } satisfies VcsStatusRemoteResult;
753 });
754 const remoteStatusResultCache = yield* Cache.makeWith(readRemoteStatus, {
755 capacity: STATUS_RESULT_CACHE_CAPACITY,
756 timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_RESULT_CACHE_TTL : Duration.zero),
757 });
758 const invalidateRemoteStatusResultCache = (cwd: string) =>
759 normalizeStatusCacheKey(cwd).pipe(
760 Effect.flatMap((cacheKey) => Cache.invalidate(remoteStatusResultCache, cacheKey)),
761 );
762
763 const readConfigValueNullable = (cwd: string, key: string) =>
764 gitCore.readConfigValue(cwd, key).pipe(Effect.catch(() => Effect.succeed(null)));
765
766 const resolveHostingProvider = Effect.fn("resolveHostingProvider")(function* (
767 cwd: string,
768 branch: string | null,
769 ) {
770 const preferredRemoteName =
771 branch === null
772 ? "origin"
773 : ((yield* readConfigValueNullable(cwd, `branch.${branch}.remote`)) ?? "origin");
774 const remoteUrl =
775 (yield* readConfigValueNullable(cwd, `remote.${preferredRemoteName}.url`)) ??
776 (yield* readConfigValueNullable(cwd, "remote.origin.url"));
777
778 return remoteUrl ? detectSourceControlProviderFromGitRemoteUrl(remoteUrl) : null;
779 });
780
781 const resolveRemoteRepositoryContext = Effect.fn("resolveRemoteRepositoryContext")(function* (
782 cwd: string,
783 remoteName: string | null,
784 ) {
785 if (!remoteName) {
786 return {
787 repositoryNameWithOwner: null,
788 ownerLogin: null,
789 };
790 }
791
792 const remoteUrl = yield* readConfigValueNullable(cwd, `remote.${remoteName}.url`);
793 const repositoryNameWithOwner = parseGitHubRepositoryNameWithOwnerFromRemoteUrl(remoteUrl);
794 return {
795 repositoryNameWithOwner,
796 ownerLogin: parseRepositoryOwnerLogin(repositoryNameWithOwner),
797 };
798 });
799
800 const resolveBranchHeadContext = Effect.fn("resolveBranchHeadContext")(function* (
801 cwd: string,
802 details: { branch: string; upstreamRef: string | null },
803 ) {
804 const remoteName = yield* readConfigValueNullable(cwd, `branch.${details.branch}.remote`);
805 const headBranchFromUpstream = details.upstreamRef
806 ? extractBranchNameFromRemoteRef(details.upstreamRef, { remoteName })
807 : "";
808 const headBranch = headBranchFromUpstream.length > 0 ? headBranchFromUpstream : details.branch;
809 const shouldProbeLocalBranchSelector =
810 headBranchFromUpstream.length === 0 || headBranch === details.branch;
811
812 const [remoteRepository, originRepository] = yield* Effect.all(
813 [
814 resolveRemoteRepositoryContext(cwd, remoteName),
815 resolveRemoteRepositoryContext(cwd, "origin"),
816 ],
817 { concurrency: "unbounded" },
818 );
819
820 const isCrossRepository =
821 remoteRepository.repositoryNameWithOwner !== null &&
822 originRepository.repositoryNameWithOwner !== null
823 ? remoteRepository.repositoryNameWithOwner.toLowerCase() !==
824 originRepository.repositoryNameWithOwner.toLowerCase()
825 : remoteName !== null &&
826 remoteName !== "origin" &&
827 remoteRepository.repositoryNameWithOwner !== null;
828
829 const ownerHeadSelector =
830 remoteRepository.ownerLogin && headBranch.length > 0
831 ? `${remoteRepository.ownerLogin}:${headBranch}`
832 : null;
833 const remoteAliasHeadSelector =
834 remoteName && headBranch.length > 0 ? `${remoteName}:${headBranch}` : null;
835 const shouldProbeRemoteOwnedSelectors =
836 isCrossRepository || (remoteName !== null && remoteName !== "origin");
837
838 const headSelectors: string[] = [];
839 if (isCrossRepository && shouldProbeRemoteOwnedSelectors) {
840 appendUnique(headSelectors, ownerHeadSelector);
841 appendUnique(
842 headSelectors,
843 remoteAliasHeadSelector !== ownerHeadSelector ? remoteAliasHeadSelector : null,
844 );
845 }
846 if (shouldProbeLocalBranchSelector) {
847 appendUnique(headSelectors, details.branch);
848 }
849 appendUnique(headSelectors, headBranch !== details.branch ? headBranch : null);
850 if (!isCrossRepository && shouldProbeRemoteOwnedSelectors) {
851 appendUnique(headSelectors, ownerHeadSelector);
852 appendUnique(
853 headSelectors,
854 remoteAliasHeadSelector !== ownerHeadSelector ? remoteAliasHeadSelector : null,
855 );
856 }
857
858 return {
859 localBranch: details.branch,
860 headBranch,
861 headSelectors,
862 preferredHeadSelector:
863 ownerHeadSelector && isCrossRepository ? ownerHeadSelector : headBranch,
864 remoteName,
865 headRepositoryNameWithOwner: remoteRepository.repositoryNameWithOwner,
866 headRepositoryOwnerLogin: remoteRepository.ownerLogin,
867 isCrossRepository,
868 } satisfies BranchHeadContext;
869 });
870
871 const findOpenPr = Effect.fn("findOpenPr")(function* (
872 cwd: string,
873 headContext: Pick<
874 BranchHeadContext,
875 | "headBranch"
876 | "headSelectors"
877 | "headRepositoryNameWithOwner"
878 | "headRepositoryOwnerLogin"
879 | "isCrossRepository"
880 >,
881 ) {
882 for (const headSelector of headContext.headSelectors) {
883 const pullRequests = yield* (yield* sourceControlProvider(cwd)).listChangeRequests({
884 cwd,
885 headSelector,
886 state: "open",
887 limit: 1,
888 });
889 const normalizedPullRequests = pullRequests.map(toPullRequestInfo);
890
891 const firstPullRequest = normalizedPullRequests.find((pullRequest) =>
892 matchesBranchHeadContext(pullRequest, headContext),
893 );
894 if (firstPullRequest) {
895 return {
896 number: firstPullRequest.number,
897 title: firstPullRequest.title,
898 url: firstPullRequest.url,
899 baseRefName: firstPullRequest.baseRefName,
900 headRefName: firstPullRequest.headRefName,
901 state: "open",
902 updatedAt: Option.none(),
903 } satisfies PullRequestInfo;
904 }
905 }
906
907 return null;
908 });
909
910 const findLatestPr = Effect.fn("findLatestPr")(function* (
911 cwd: string,
912 details: { branch: string; upstreamRef: string | null },
913 ) {
914 const headContext = yield* resolveBranchHeadContext(cwd, details);
915 const parsedByNumber = new Map<number, PullRequestInfo>();
916
917 for (const headSelector of headContext.headSelectors) {
918 const pullRequests = yield* (yield* sourceControlProvider(cwd)).listChangeRequests({
919 cwd,
920 headSelector,
921 state: "all",
922 limit: 20,
923 });
924
925 for (const pr of pullRequests.map(toPullRequestInfo)) {
926 if (!matchesBranchHeadContext(pr, headContext)) {
927 continue;
928 }
929 parsedByNumber.set(pr.number, pr);
930 }
931 }
932
933 const parsed = Arr.sort(parsedByNumber.values(), pullRequestUpdatedAtDescOrder);
934
935 const latestOpenPr = parsed.find((pr) => pr.state === "open");
936 if (latestOpenPr) {
937 return latestOpenPr;
938 }
939 return parsed[0] ?? null;
940 });
941
942 const buildCompletionToast = Effect.fn("buildCompletionToast")(function* (
943 cwd: string,
944 result: Pick<GitRunStackedActionResult, "action" | "branch" | "commit" | "push" | "pr">,
945 ) {
946 const terms = yield* sourceControlProvider(cwd).pipe(
947 Effect.map((provider) => getChangeRequestTerminologyForKind(provider.kind)),
948 Effect.catch(() => Effect.succeed(getChangeRequestTerminologyForKind("unknown"))),
949 );
950 const summary = summarizeGitActionResult(result, terms);
951 let latestOpenPr: PullRequestInfo | null = null;
952 let currentBranchIsDefault = false;
953 let finalBranchContext: {
954 branch: string;
955 upstreamRef: string | null;
956 hasUpstream: boolean;
957 } | null = null;
958
959 if (result.action !== "commit") {
960 const finalStatus = yield* gitCore.statusDetails(cwd);
961 if (finalStatus.branch) {
962 finalBranchContext = {
963 branch: finalStatus.branch,
964 upstreamRef: finalStatus.upstreamRef,
965 hasUpstream: finalStatus.hasUpstream,
966 };
967 currentBranchIsDefault = finalStatus.isDefaultBranch;
968 }
969 }
970
971 const explicitResultPr =
972 (result.pr.status === "created" || result.pr.status === "opened_existing") && result.pr.url
973 ? {
974 url: result.pr.url,
975 state: "open" as const,
976 }
977 : null;
978 const shouldLookupExistingOpenPr =
979 (result.action === "commit_push" || result.action === "push") &&
980 result.push.status === "pushed" &&
981 result.branch.status !== "created" &&
982 !currentBranchIsDefault &&
983 explicitResultPr === null &&
984 finalBranchContext?.hasUpstream === true;
985
986 if (shouldLookupExistingOpenPr && finalBranchContext) {
987 latestOpenPr = yield* resolveBranchHeadContext(cwd, {
988 branch: finalBranchContext.branch,
989 upstreamRef: finalBranchContext.upstreamRef,
990 }).pipe(
991 Effect.flatMap((headContext) => findOpenPr(cwd, headContext)),
992 Effect.catch(() => Effect.succeed(null)),
993 );
994 }
995
996 const openPr = latestOpenPr ?? explicitResultPr;
997
998 const cta =
999 result.action === "commit" && result.commit.status === "created"
1000 ? {
1001 kind: "run_action" as const,
1002 label: "Push",
1003 action: { kind: "push" as const },
1004 }
1005 : (result.action === "push" ||
1006 result.action === "create_pr" ||
1007 result.action === "commit_push" ||
1008 result.action === "commit_push_pr") &&
1009 openPr?.url &&
1010 (!currentBranchIsDefault ||
1011 result.pr.status === "created" ||
1012 result.pr.status === "opened_existing")
1013 ? {
1014 kind: "open_pr" as const,
1015 label: `View ${terms.shortLabel}`,
1016 url: openPr.url,
1017 }
1018 : (result.action === "push" || result.action === "commit_push") &&
1019 result.push.status === "pushed" &&
1020 !currentBranchIsDefault
1021 ? {
1022 kind: "run_action" as const,
1023 label: `Create ${terms.shortLabel}`,
1024 action: { kind: "create_pr" as const },
1025 }
1026 : {
1027 kind: "none" as const,
1028 };
1029
1030 return {
1031 ...summary,
1032 cta,
1033 };
1034 });
1035
1036 const resolveBaseBranch = Effect.fn("resolveBaseBranch")(function* (
1037 cwd: string,
1038 branch: string,
1039 upstreamRef: string | null,
1040 headContext: Pick<BranchHeadContext, "isCrossRepository" | "remoteName">,
1041 ) {
1042 const configured = yield* gitCore.readConfigValue(cwd, `branch.${branch}.gh-merge-base`);
1043 if (configured) return configured;
1044
1045 if (upstreamRef && !headContext.isCrossRepository) {
1046 const upstreamBranch = extractBranchNameFromRemoteRef(upstreamRef, {
1047 remoteName: headContext.remoteName,
1048 });
1049 if (upstreamBranch.length > 0 && upstreamBranch !== branch) {
1050 return upstreamBranch;
1051 }
1052 }
1053
1054 const defaultFromProvider = yield* sourceControlProvider(cwd).pipe(
1055 Effect.flatMap((provider) => provider.getDefaultBranch({ cwd })),
1056 Effect.catch(() => Effect.succeed(null)),
1057 );
1058 if (defaultFromProvider) {
1059 return defaultFromProvider;
1060 }
1061
1062 return "main";
1063 });
1064
1065 const resolveCommitAndBranchSuggestion = Effect.fn("resolveCommitAndBranchSuggestion")(
1066 function* (input: {
1067 cwd: string;
1068 branch: string | null;
1069 commitMessage?: string;
1070 /** When true, also produce a semantic feature branch name. */
1071 includeBranch?: boolean;
1072 filePaths?: readonly string[];
1073 modelSelection: ModelSelection;
1074 }) {
1075 const context = yield* gitCore.prepareCommitContext(input.cwd, input.filePaths);
1076 if (!context) {
1077 return null;
1078 }
1079
1080 const customCommit = parseCustomCommitMessage(input.commitMessage ?? "");
1081 if (customCommit) {
1082 return {
1083 subject: customCommit.subject,
1084 body: customCommit.body,
1085 ...(input.includeBranch
1086 ? { branch: sanitizeFeatureBranchName(customCommit.subject) }
1087 : {}),
1088 commitMessage: formatCommitMessage(customCommit.subject, customCommit.body),
1089 };
1090 }
1091
1092 const generated = yield* textGeneration
1093 .generateCommitMessage({
1094 cwd: input.cwd,
1095 branch: input.branch,
1096 stagedSummary: limitContext(context.stagedSummary, 8_000),
1097 stagedPatch: limitContext(context.stagedPatch, 50_000),
1098 ...(input.includeBranch ? { includeBranch: true } : {}),
1099 modelSelection: input.modelSelection,
1100 })
1101 .pipe(Effect.map((result) => sanitizeCommitMessage(result)));
1102
1103 return {
1104 subject: generated.subject,
1105 body: generated.body,
1106 ...(generated.branch !== undefined ? { branch: generated.branch } : {}),
1107 commitMessage: formatCommitMessage(generated.subject, generated.body),
1108 };
1109 },
1110 );
1111
1112 const runCommitStep = Effect.fn("runCommitStep")(function* (
1113 modelSelection: ModelSelection,
1114 cwd: string,
1115 action: "commit" | "commit_push" | "commit_push_pr",
1116 branch: string | null,
1117 commitMessage?: string,
1118 preResolvedSuggestion?: CommitAndBranchSuggestion,
1119 filePaths?: readonly string[],
1120 progressReporter?: GitActionProgressReporter,
1121 actionId?: string,
1122 ) {
1123 const emit = (event: GitActionProgressPayload) =>
1124 progressReporter && actionId
1125 ? progressReporter.publish({
1126 actionId,
1127 cwd,
1128 action,
1129 ...event,
1130 } as GitActionProgressEvent)
1131 : Effect.void;
1132
1133 let suggestion: CommitAndBranchSuggestion | null | undefined = preResolvedSuggestion;
1134 if (!suggestion) {
1135 const needsGeneration = !commitMessage?.trim();
1136 if (needsGeneration) {
1137 yield* emit({
1138 kind: "phase_started",
1139 phase: "commit",
1140 label: "Generating commit message...",
1141 });
1142 }
1143 suggestion = yield* resolveCommitAndBranchSuggestion({
1144 cwd,
1145 branch,
1146 ...(commitMessage ? { commitMessage } : {}),
1147 ...(filePaths ? { filePaths } : {}),
1148 modelSelection,
1149 });
1150 }
1151 if (!suggestion) {
1152 return { status: "skipped_no_changes" as const };
1153 }
1154
1155 yield* emit({
1156 kind: "phase_started",
1157 phase: "commit",
1158 label: "Committing...",
1159 });
1160
1161 let currentHookName: string | null = null;
1162 const commitProgress =
1163 progressReporter && actionId
1164 ? {
1165 onOutputLine: ({ stream, text }: { stream: "stdout" | "stderr"; text: string }) => {
1166 const sanitized = sanitizeProgressText(text);
1167 if (!sanitized) {
1168 return Effect.void;
1169 }
1170 return emit({
1171 kind: "hook_output",
1172 hookName: currentHookName,
1173 stream,
1174 text: sanitized,
1175 });
1176 },
1177 onHookStarted: (hookName: string) => {
1178 currentHookName = hookName;
1179 return emit({
1180 kind: "hook_started",
1181 hookName,
1182 });
1183 },
1184 onHookFinished: ({
1185 hookName,
1186 exitCode,
1187 durationMs,
1188 }: {
1189 hookName: string;
1190 exitCode: number | null;
1191 durationMs: number | null;
1192 }) => {
1193 if (currentHookName === hookName) {
1194 currentHookName = null;
1195 }
1196 return emit({
1197 kind: "hook_finished",
1198 hookName,
1199 exitCode,
1200 durationMs,
1201 });
1202 },
1203 }
1204 : null;
1205 const { commitSha } = yield* gitCore.commit(cwd, suggestion.subject, suggestion.body, {
1206 timeoutMs: COMMIT_TIMEOUT_MS,
1207 ...(commitProgress ? { progress: commitProgress } : {}),
1208 });
1209 if (currentHookName !== null) {
1210 yield* emit({
1211 kind: "hook_finished",
1212 hookName: currentHookName,
1213 exitCode: 0,
1214 durationMs: null,
1215 });
1216 currentHookName = null;
1217 }
1218 return {
1219 status: "created" as const,
1220 commitSha,
1221 subject: suggestion.subject,
1222 };
1223 });
1224
1225 const runPrStep = Effect.fn("runPrStep")(function* (
1226 modelSelection: ModelSelection,
1227 cwd: string,
1228 fallbackBranch: string | null,
1229 emit: GitActionProgressEmitter,
1230 ) {
1231 const provider = yield* sourceControlProvider(cwd);
1232 const terms = getChangeRequestTerminologyForKind(provider.kind);
1233 const details = yield* gitCore.statusDetails(cwd);
1234 const branch = details.branch ?? fallbackBranch;
1235 if (!branch) {
1236 return yield* gitManagerError(
1237 "runPrStep",
1238 "Cannot create a pull request from detached HEAD.",
1239 );
1240 }
1241 if (!details.hasUpstream) {
1242 return yield* gitManagerError(
1243 "runPrStep",
1244 "Current branch has not been pushed. Push before creating a PR.",
1245 );
1246 }
1247
1248 const headContext = yield* resolveBranchHeadContext(cwd, {
1249 branch,
1250 upstreamRef: details.upstreamRef,
1251 });
1252
1253 const existing = yield* findOpenPr(cwd, headContext);
1254 if (existing) {
1255 return {
1256 status: "opened_existing" as const,
1257 url: existing.url,
1258 number: existing.number,
1259 baseBranch: existing.baseRefName,
1260 headBranch: existing.headRefName,
1261 title: existing.title,
1262 };
1263 }
1264
1265 const baseBranch = yield* resolveBaseBranch(cwd, branch, details.upstreamRef, headContext);
1266 yield* emit({
1267 kind: "phase_started",
1268 phase: "pr",
1269 label: `Generating ${terms.shortLabel} content...`,
1270 });
1271 const rangeContext = yield* gitCore.readRangeContext(cwd, baseBranch);
1272
1273 const generated = yield* textGeneration.generatePrContent({
1274 cwd,
1275 baseBranch,
1276 headBranch: headContext.headBranch,
1277 commitSummary: limitContext(rangeContext.commitSummary, 20_000),
1278 diffSummary: limitContext(rangeContext.diffSummary, 20_000),
1279 diffPatch: limitContext(rangeContext.diffPatch, 60_000),
1280 modelSelection,
1281 });
1282
1283 const bodyFile = path.join(tempDir, `t3code-pr-body-${process.pid}-${randomUUID()}.md`);
1284 yield* fileSystem
1285 .writeFileString(bodyFile, generated.body)
1286 .pipe(
1287 Effect.mapError((cause) =>
1288 gitManagerError("runPrStep", "Failed to write pull request body temp file.", cause),
1289 ),
1290 );
1291 yield* emit({
1292 kind: "phase_started",
1293 phase: "pr",
1294 label: `Creating ${terms.singular}...`,
1295 });
1296 yield* provider
1297 .createChangeRequest({
1298 cwd,
1299 baseRefName: baseBranch,
1300 headSelector: headContext.preferredHeadSelector,
1301 title: generated.title,
1302 bodyFile,
1303 })
1304 .pipe(Effect.ensuring(fileSystem.remove(bodyFile).pipe(Effect.catch(() => Effect.void))));
1305
1306 const created = yield* findOpenPr(cwd, headContext);
1307 if (!created) {
1308 return {
1309 status: "created" as const,
1310 baseBranch,
1311 headBranch: headContext.headBranch,
1312 title: generated.title,
1313 };
1314 }
1315
1316 return {
1317 status: "created" as const,
1318 url: created.url,
1319 number: created.number,
1320 baseBranch: created.baseRefName,
1321 headBranch: created.headRefName,
1322 title: created.title,
1323 };
1324 });
1325
1326 const localStatus: GitManagerShape["localStatus"] = Effect.fn("localStatus")(function* (input) {
1327 const cacheKey = yield* normalizeStatusCacheKey(input.cwd);
1328 return yield* Cache.get(localStatusResultCache, cacheKey);
1329 });
1330 const remoteStatus: GitManagerShape["remoteStatus"] = Effect.fn("remoteStatus")(
1331 function* (input) {
1332 const cacheKey = yield* normalizeStatusCacheKey(input.cwd);
1333 return yield* Cache.get(remoteStatusResultCache, cacheKey);
1334 },
1335 );
1336 const status: GitManagerShape["status"] = Effect.fn("status")(function* (input) {
1337 const [local, remote] = yield* Effect.all([localStatus(input), remoteStatus(input)]);
1338 return mergeGitStatusParts(local, remote);
1339 });
1340 const invalidateLocalStatus: GitManagerShape["invalidateLocalStatus"] = Effect.fn(
1341 "invalidateLocalStatus",
1342 )(function* (cwd) {
1343 yield* invalidateLocalStatusResultCache(cwd);
1344 });
1345 const invalidateRemoteStatus: GitManagerShape["invalidateRemoteStatus"] = Effect.fn(
1346 "invalidateRemoteStatus",
1347 )(function* (cwd) {
1348 yield* invalidateRemoteStatusResultCache(cwd);
1349 });
1350 const invalidateStatus: GitManagerShape["invalidateStatus"] = Effect.fn("invalidateStatus")(
1351 function* (cwd) {
1352 yield* invalidateLocalStatusResultCache(cwd);
1353 yield* invalidateRemoteStatusResultCache(cwd);
1354 },
1355 );
1356
1357 const resolvePullRequest: GitManagerShape["resolvePullRequest"] = Effect.fn("resolvePullRequest")(
1358 function* (input) {
1359 const pullRequest = yield* (yield* sourceControlProvider(input.cwd))
1360 .getChangeRequest({
1361 cwd: input.cwd,
1362 reference: normalizePullRequestReference(input.reference),
1363 })
1364 .pipe(Effect.map((resolved) => toResolvedPullRequest(resolved)));
1365
1366 return { pullRequest };
1367 },
1368 );
1369
1370 const preparePullRequestThread: GitManagerShape["preparePullRequestThread"] = Effect.fn(
1371 "preparePullRequestThread",
1372 )(function* (input) {
1373 const maybeRunSetupScript = (worktreePath: string) => {
1374 if (!input.threadId) {
1375 return Effect.void;
1376 }
1377 return projectSetupScriptRunner
1378 .runForThread({
1379 threadId: input.threadId,
1380 projectCwd: input.cwd,
1381 worktreePath,
1382 })
1383 .pipe(
1384 Effect.catch((error) =>
1385 Effect.logWarning(
1386 `GitManager.preparePullRequestThread: failed to launch worktree setup script for thread ${input.threadId} in ${worktreePath}: ${error.message}`,
1387 ).pipe(Effect.asVoid),
1388 ),
1389 );
1390 };
1391 return yield* Effect.gen(function* () {
1392 const normalizedReference = normalizePullRequestReference(input.reference);
1393 const rootWorktreePath = yield* canonicalizeExistingPath(input.cwd);
1394 const pullRequestSummary = yield* (yield* sourceControlProvider(input.cwd)).getChangeRequest({
1395 cwd: input.cwd,
1396 reference: normalizedReference,
1397 });
1398 const pullRequest = toResolvedPullRequest(pullRequestSummary);
1399
1400 if (input.mode === "local") {
1401 yield* (yield* sourceControlProvider(input.cwd)).checkoutChangeRequest({
1402 cwd: input.cwd,
1403 reference: normalizedReference,
1404 force: true,
1405 });
1406 const details = yield* gitCore.statusDetails(input.cwd);
1407 yield* configurePullRequestHeadUpstream(
1408 input.cwd,
1409 {
1410 ...pullRequest,
1411 ...toPullRequestHeadRemoteInfo(pullRequestSummary),
1412 },
1413 details.branch ?? pullRequest.headBranch,
1414 );
1415 return {
1416 pullRequest,
1417 branch: details.branch ?? pullRequest.headBranch,
1418 worktreePath: null,
1419 };
1420 }
1421
1422 const ensureExistingWorktreeUpstream = Effect.fn("ensureExistingWorktreeUpstream")(function* (
1423 worktreePath: string,
1424 ) {
1425 const details = yield* gitCore.statusDetails(worktreePath);
1426 yield* configurePullRequestHeadUpstream(
1427 worktreePath,
1428 {
1429 ...pullRequest,
1430 ...toPullRequestHeadRemoteInfo(pullRequestSummary),
1431 },
1432 details.branch ?? pullRequest.headBranch,
1433 );
1434 });
1435
1436 const pullRequestWithRemoteInfo = {
1437 ...pullRequest,
1438 ...toPullRequestHeadRemoteInfo(pullRequestSummary),
1439 } as const;
1440 const localPullRequestBranch =
1441 resolvePullRequestWorktreeLocalBranchName(pullRequestWithRemoteInfo);
1442
1443 const findLocalHeadBranch = Effect.fn("findLocalHeadBranch")(function* (cwd: string) {
1444 const result = yield* gitCore.listRefs({ cwd });
1445 const localBranch = result.refs.find(
1446 (branch) => !branch.isRemote && branch.name === localPullRequestBranch,
1447 );
1448 if (localBranch) {
1449 return localBranch;
1450 }
1451 if (localPullRequestBranch === pullRequest.headBranch) {
1452 return null;
1453 }
1454
1455 for (const branch of result.refs) {
1456 if (branch.isRemote || branch.name !== pullRequest.headBranch || !branch.worktreePath) {
1457 continue;
1458 }
1459
1460 const worktreePath = yield* canonicalizeExistingPath(branch.worktreePath);
1461 if (worktreePath !== rootWorktreePath) {
1462 return branch;
1463 }
1464 }
1465
1466 return null;
1467 });
1468
1469 const existingBranchBeforeFetch = yield* findLocalHeadBranch(input.cwd);
1470 const existingBranchBeforeFetchPath = existingBranchBeforeFetch?.worktreePath
1471 ? yield* canonicalizeExistingPath(existingBranchBeforeFetch.worktreePath)
1472 : null;
1473 if (
1474 existingBranchBeforeFetch?.worktreePath &&
1475 existingBranchBeforeFetchPath !== rootWorktreePath
1476 ) {
1477 yield* ensureExistingWorktreeUpstream(existingBranchBeforeFetch.worktreePath);
1478 return {
1479 pullRequest,
1480 branch: localPullRequestBranch,
1481 worktreePath: existingBranchBeforeFetch.worktreePath,
1482 };
1483 }
1484 if (existingBranchBeforeFetchPath === rootWorktreePath) {
1485 return yield* gitManagerError(
1486 "preparePullRequestThread",
1487 "This PR branch is already checked out in the main repo. Use Local, or switch the main repo off that branch before creating a worktree thread.",
1488 );
1489 }
1490
1491 yield* materializePullRequestHeadBranch(
1492 input.cwd,
1493 pullRequestWithRemoteInfo,
1494 localPullRequestBranch,
1495 );
1496
1497 const existingBranchAfterFetch = yield* findLocalHeadBranch(input.cwd);
1498 const existingBranchAfterFetchPath = existingBranchAfterFetch?.worktreePath
1499 ? yield* canonicalizeExistingPath(existingBranchAfterFetch.worktreePath)
1500 : null;
1501 if (
1502 existingBranchAfterFetch?.worktreePath &&
1503 existingBranchAfterFetchPath !== rootWorktreePath
1504 ) {
1505 yield* ensureExistingWorktreeUpstream(existingBranchAfterFetch.worktreePath);
1506 return {
1507 pullRequest,
1508 branch: localPullRequestBranch,
1509 worktreePath: existingBranchAfterFetch.worktreePath,
1510 };
1511 }
1512 if (existingBranchAfterFetchPath === rootWorktreePath) {
1513 return yield* gitManagerError(
1514 "preparePullRequestThread",
1515 "This PR branch is already checked out in the main repo. Use Local, or switch the main repo off that branch before creating a worktree thread.",
1516 );
1517 }
1518
1519 const worktree = yield* gitCore.createWorktree({
1520 cwd: input.cwd,
1521 refName: localPullRequestBranch,
1522 path: null,
1523 });
1524 yield* ensureExistingWorktreeUpstream(worktree.worktree.path);
1525 yield* maybeRunSetupScript(worktree.worktree.path);
1526
1527 return {
1528 pullRequest,
1529 branch: worktree.worktree.refName,
1530 worktreePath: worktree.worktree.path,
1531 };
1532 }).pipe(Effect.ensuring(invalidateStatus(input.cwd)));
1533 });
1534
1535 const runFeatureBranchStep = Effect.fn("runFeatureBranchStep")(function* (
1536 modelSelection: ModelSelection,
1537 cwd: string,
1538 branch: string | null,
1539 commitMessage?: string,
1540 filePaths?: readonly string[],
1541 ) {
1542 const suggestion = yield* resolveCommitAndBranchSuggestion({
1543 cwd,
1544 branch,
1545 ...(commitMessage ? { commitMessage } : {}),
1546 ...(filePaths ? { filePaths } : {}),
1547 includeBranch: true,
1548 modelSelection,
1549 });
1550 if (!suggestion) {
1551 return yield* gitManagerError(
1552 "runFeatureBranchStep",
1553 "Cannot create a feature branch because there are no changes to commit.",
1554 );
1555 }
1556
1557 const preferredBranch = suggestion.branch ?? sanitizeFeatureBranchName(suggestion.subject);
1558 const existingBranchNames = yield* gitCore.listLocalBranchNames(cwd);
1559 const resolvedBranch = resolveAutoFeatureBranchName(existingBranchNames, preferredBranch);
1560
1561 yield* gitCore.createRef({ cwd, refName: resolvedBranch });
1562 yield* Effect.scoped(gitCore.switchRef({ cwd, refName: resolvedBranch }));
1563
1564 return {
1565 branchStep: { status: "created" as const, name: resolvedBranch },
1566 resolvedCommitMessage: suggestion.commitMessage,
1567 resolvedCommitSuggestion: suggestion,
1568 };
1569 });
1570
1571 const runStackedAction: GitManagerShape["runStackedAction"] = Effect.fn("runStackedAction")(
1572 function* (input, options) {
1573 const progress = createProgressEmitter(input, options);
1574 const currentPhase = yield* Ref.make<Option.Option<GitActionProgressPhase>>(Option.none());
1575
1576 const runAction = Effect.fn("runStackedAction.runAction")(function* (): Effect.fn.Return<
1577 GitRunStackedActionResult,
1578 GitManagerServiceError
1579 > {
1580 const initialStatus = yield* gitCore.statusDetails(input.cwd);
1581 const wantsCommit = isCommitAction(input.action);
1582 const wantsPush =
1583 input.action === "push" ||
1584 input.action === "commit_push" ||
1585 input.action === "commit_push_pr" ||
1586 (input.action === "create_pr" &&
1587 (!initialStatus.hasUpstream || initialStatus.aheadCount > 0));
1588 const wantsPr = input.action === "create_pr" || input.action === "commit_push_pr";
1589
1590 if (input.featureBranch && !wantsCommit) {
1591 return yield* gitManagerError(
1592 "runStackedAction",
1593 "Feature-branch checkout is only supported for commit actions.",
1594 );
1595 }
1596 if (input.action === "push" && initialStatus.hasWorkingTreeChanges) {
1597 return yield* gitManagerError(
1598 "runStackedAction",
1599 "Commit or stash local changes before pushing.",
1600 );
1601 }
1602 if (input.action === "create_pr" && initialStatus.hasWorkingTreeChanges) {
1603 return yield* gitManagerError(
1604 "runStackedAction",
1605 "Commit local changes before creating a PR.",
1606 );
1607 }
1608
1609 const phases: GitActionProgressPhase[] = [
1610 ...(input.featureBranch ? (["branch"] as const) : []),
1611 ...(wantsCommit ? (["commit"] as const) : []),
1612 ...(wantsPush ? (["push"] as const) : []),
1613 ...(wantsPr ? (["pr"] as const) : []),
1614 ];
1615
1616 yield* progress.emit({
1617 kind: "action_started",
1618 phases,
1619 });
1620
1621 if (!input.featureBranch && wantsPush && !initialStatus.branch) {
1622 return yield* gitManagerError("runStackedAction", "Cannot push from detached HEAD.");
1623 }
1624 if (!input.featureBranch && wantsPr && !initialStatus.branch) {
1625 return yield* gitManagerError(
1626 "runStackedAction",
1627 "Cannot create a pull request from detached HEAD.",
1628 );
1629 }
1630
1631 let branchStep: { status: "created" | "skipped_not_requested"; name?: string };
1632 let commitMessageForStep = input.commitMessage;
1633 let preResolvedCommitSuggestion: CommitAndBranchSuggestion | undefined = undefined;
1634
1635 const modelSelection = yield* serverSettingsService.getSettings.pipe(
1636 Effect.map((settings) => settings.textGenerationModelSelection),
1637 Effect.mapError((cause) =>
1638 gitManagerError("runStackedAction", "Failed to get server settings.", cause),
1639 ),
1640 );
1641
1642 if (input.featureBranch) {
1643 yield* Ref.set(currentPhase, Option.some("branch"));
1644 yield* progress.emit({
1645 kind: "phase_started",
1646 phase: "branch",
1647 label: "Preparing feature branch...",
1648 });
1649 const result = yield* runFeatureBranchStep(
1650 modelSelection,
1651 input.cwd,
1652 initialStatus.branch,
1653 input.commitMessage,
1654 input.filePaths,
1655 );
1656 branchStep = result.branchStep;
1657 commitMessageForStep = result.resolvedCommitMessage;
1658 preResolvedCommitSuggestion = result.resolvedCommitSuggestion;
1659 } else {
1660 branchStep = { status: "skipped_not_requested" as const };
1661 }
1662
1663 const currentBranch = branchStep.name ?? initialStatus.branch;
1664 const commitAction = isCommitAction(input.action) ? input.action : null;
1665 const changeRequestTerms = wantsPr
1666 ? yield* sourceControlProvider(input.cwd).pipe(
1667 Effect.map((provider) => getChangeRequestTerminologyForKind(provider.kind)),
1668 Effect.catch(() => Effect.succeed(getChangeRequestTerminologyForKind("unknown"))),
1669 )
1670 : null;
1671
1672 const commit = commitAction
1673 ? yield* Ref.set(currentPhase, Option.some("commit")).pipe(
1674 Effect.flatMap(() =>
1675 runCommitStep(
1676 modelSelection,
1677 input.cwd,
1678 commitAction,
1679 currentBranch,
1680 commitMessageForStep,
1681 preResolvedCommitSuggestion,
1682 input.filePaths,
1683 options?.progressReporter,
1684 progress.actionId,
1685 ),
1686 ),
1687 )
1688 : { status: "skipped_not_requested" as const };
1689
1690 const push = wantsPush
1691 ? yield* progress
1692 .emit({
1693 kind: "phase_started",
1694 phase: "push",
1695 label: "Pushing...",
1696 })
1697 .pipe(
1698 Effect.tap(() => Ref.set(currentPhase, Option.some("push"))),
1699 Effect.flatMap(() => gitCore.pushCurrentBranch(input.cwd, currentBranch)),
1700 )
1701 : { status: "skipped_not_requested" as const };
1702
1703 const pr = wantsPr
1704 ? yield* progress
1705 .emit({
1706 kind: "phase_started",
1707 phase: "pr",
1708 label: `Preparing ${changeRequestTerms?.shortLabel ?? "PR"}...`,
1709 })
1710 .pipe(
1711 Effect.tap(() => Ref.set(currentPhase, Option.some("pr"))),
1712 Effect.flatMap(() =>
1713 runPrStep(modelSelection, input.cwd, currentBranch, progress.emit),
1714 ),
1715 )
1716 : { status: "skipped_not_requested" as const };
1717
1718 const toast = yield* buildCompletionToast(input.cwd, {
1719 action: input.action,
1720 branch: branchStep,
1721 commit,
1722 push,
1723 pr,
1724 });
1725
1726 const result = {
1727 action: input.action,
1728 branch: branchStep,
1729 commit,
1730 push,
1731 pr,
1732 toast,
1733 };
1734 yield* progress.emit({
1735 kind: "action_finished",
1736 result,
1737 });
1738 return result;
1739 });
1740
1741 return yield* runAction().pipe(
1742 Effect.ensuring(invalidateStatus(input.cwd)),
1743 Effect.tapError((error) =>
1744 Effect.flatMap(Ref.get(currentPhase), (phase) =>
1745 progress.emit({
1746 kind: "action_failed",
1747 phase: Option.getOrNull(phase),
1748 message: error.message,
1749 }),
1750 ),
1751 ),
1752 );
1753 },
1754 );
1755
1756 return {
1757 localStatus,
1758 remoteStatus,
1759 status,
1760 invalidateLocalStatus,
1761 invalidateRemoteStatus,
1762 invalidateStatus,
1763 resolvePullRequest,
1764 preparePullRequestThread,
1765 runStackedAction,
1766 } satisfies GitManagerShape;
1767});
1768
1769export const layer = Layer.effect(GitManager, makeGitManager());
apps/server/src/git/GitWorkflowService.test.ts created+132
......@@ -0,0 +1,132 @@
1import { assert, it } from "@effect/vitest";
2import { Effect, Layer } from "effect";
3import { describe, vi } from "vitest";
4
5import { GitManager } from "./GitManager.ts";
6import { GitWorkflowService, layer as GitWorkflowServiceLayer } from "./GitWorkflowService.ts";
7import { GitVcsDriver } from "../vcs/GitVcsDriver.ts";
8import { VcsDriverRegistry, type VcsDriverRegistryShape } from "../vcs/VcsDriverRegistry.ts";
9
10function makeLayer(input: { readonly detect: VcsDriverRegistryShape["detect"] }) {
11 return GitWorkflowServiceLayer.pipe(
12 Layer.provide(
13 Layer.mock(VcsDriverRegistry)({
14 detect: input.detect,
15 }),
16 ),
17 Layer.provide(Layer.mock(GitVcsDriver)({})),
18 Layer.provide(Layer.mock(GitManager)({})),
19 );
20}
21
22describe("GitWorkflowService", () => {
23 it.effect("returns an empty local status when no VCS repository is detected", () =>
24 Effect.gen(function* () {
25 const workflow = yield* GitWorkflowService;
26 const status = yield* workflow.localStatus({ cwd: "/not-a-repo" });
27
28 assert.deepStrictEqual(status, {
29 isRepo: false,
30 hasPrimaryRemote: false,
31 isDefaultRef: false,
32 refName: null,
33 hasWorkingTreeChanges: false,
34 workingTree: {
35 files: [],
36 insertions: 0,
37 deletions: 0,
38 },
39 });
40 }).pipe(
41 Effect.provide(
42 makeLayer({
43 detect: () => Effect.succeed(null),
44 }),
45 ),
46 ),
47 );
48
49 it.effect("returns an empty full status when no VCS repository is detected", () =>
50 Effect.gen(function* () {
51 const workflow = yield* GitWorkflowService;
52 const status = yield* workflow.status({ cwd: "/not-a-repo" });
53
54 assert.deepStrictEqual(status, {
55 isRepo: false,
56 hasPrimaryRemote: false,
57 isDefaultRef: false,
58 refName: null,
59 hasWorkingTreeChanges: false,
60 workingTree: {
61 files: [],
62 insertions: 0,
63 deletions: 0,
64 },
65 hasUpstream: false,
66 aheadCount: 0,
67 behindCount: 0,
68 pr: null,
69 });
70 }).pipe(
71 Effect.provide(
72 makeLayer({
73 detect: () => Effect.succeed(null),
74 }),
75 ),
76 ),
77 );
78
79 it.effect("does not call GitManager status methods when no VCS repository is detected", () => {
80 const localStatus = vi.fn();
81 const remoteStatus = vi.fn();
82 const status = vi.fn();
83
84 const testLayer = GitWorkflowServiceLayer.pipe(
85 Layer.provide(
86 Layer.mock(VcsDriverRegistry)({
87 detect: () => Effect.succeed(null),
88 }),
89 ),
90 Layer.provide(Layer.mock(GitVcsDriver)({})),
91 Layer.provide(
92 Layer.mock(GitManager)({
93 localStatus,
94 remoteStatus,
95 status,
96 }),
97 ),
98 );
99
100 return Effect.gen(function* () {
101 const workflow = yield* GitWorkflowService;
102 yield* workflow.localStatus({ cwd: "/not-a-repo" });
103 yield* workflow.remoteStatus({ cwd: "/not-a-repo" });
104 yield* workflow.status({ cwd: "/not-a-repo" });
105
106 assert.equal(localStatus.mock.calls.length, 0);
107 assert.equal(remoteStatus.mock.calls.length, 0);
108 assert.equal(status.mock.calls.length, 0);
109 }).pipe(Effect.provide(testLayer));
110 });
111
112 it.effect("returns an empty ref list when no VCS repository is detected", () =>
113 Effect.gen(function* () {
114 const workflow = yield* GitWorkflowService;
115 const refs = yield* workflow.listRefs({ cwd: "/not-a-repo" });
116
117 assert.deepStrictEqual(refs, {
118 refs: [],
119 isRepo: false,
120 hasPrimaryRemote: false,
121 nextCursor: null,
122 totalCount: 0,
123 });
124 }).pipe(
125 Effect.provide(
126 makeLayer({
127 detect: () => Effect.succeed(null),
128 }),
129 ),
130 ),
131 );
132});
apps/server/src/git/GitWorkflowService.ts created+313
......@@ -0,0 +1,313 @@
1import { Context, Effect, Layer } from "effect";
2
3import {
4 GitManagerError,
5 GitCommandError,
6 type VcsSwitchRefInput,
7 type VcsSwitchRefResult,
8 type VcsCreateRefInput,
9 type VcsCreateRefResult,
10 type VcsCreateWorktreeInput,
11 type VcsCreateWorktreeResult,
12 type VcsListRefsInput,
13 type VcsListRefsResult,
14 type GitManagerServiceError,
15 type GitPreparePullRequestThreadInput,
16 type GitPreparePullRequestThreadResult,
17 type GitPullRequestRefInput,
18 type VcsPullResult,
19 type VcsRemoveWorktreeInput,
20 type GitResolvePullRequestResult,
21 type GitRunStackedActionInput,
22 type GitRunStackedActionResult,
23 type VcsStatusInput,
24 type VcsStatusLocalResult,
25 type VcsStatusRemoteResult,
26 type VcsStatusResult,
27} from "@t3tools/contracts";
28
29import { GitManager, type GitRunStackedActionOptions } from "./GitManager.ts";
30import { GitVcsDriver } from "../vcs/GitVcsDriver.ts";
31import { VcsDriverRegistry } from "../vcs/VcsDriverRegistry.ts";
32
33export interface GitWorkflowServiceShape {
34 readonly status: (
35 input: VcsStatusInput,
36 ) => Effect.Effect<VcsStatusResult, GitManagerServiceError>;
37 readonly localStatus: (
38 input: VcsStatusInput,
39 ) => Effect.Effect<VcsStatusLocalResult, GitManagerServiceError>;
40 readonly remoteStatus: (
41 input: VcsStatusInput,
42 ) => Effect.Effect<VcsStatusRemoteResult | null, GitManagerServiceError>;
43 readonly invalidateLocalStatus: (cwd: string) => Effect.Effect<void, never>;
44 readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect<void, never>;
45 readonly invalidateStatus: (cwd: string) => Effect.Effect<void, never>;
46 readonly pullCurrentBranch: (cwd: string) => Effect.Effect<VcsPullResult, GitCommandError>;
47 readonly runStackedAction: (
48 input: GitRunStackedActionInput,
49 options?: GitRunStackedActionOptions,
50 ) => Effect.Effect<GitRunStackedActionResult, GitManagerServiceError>;
51 readonly resolvePullRequest: (
52 input: GitPullRequestRefInput,
53 ) => Effect.Effect<GitResolvePullRequestResult, GitManagerServiceError>;
54 readonly preparePullRequestThread: (
55 input: GitPreparePullRequestThreadInput,
56 ) => Effect.Effect<GitPreparePullRequestThreadResult, GitManagerServiceError>;
57 readonly listRefs: (input: VcsListRefsInput) => Effect.Effect<VcsListRefsResult, GitCommandError>;
58 readonly createWorktree: (
59 input: VcsCreateWorktreeInput,
60 ) => Effect.Effect<VcsCreateWorktreeResult, GitCommandError>;
61 readonly removeWorktree: (input: VcsRemoveWorktreeInput) => Effect.Effect<void, GitCommandError>;
62 readonly createRef: (
63 input: VcsCreateRefInput,
64 ) => Effect.Effect<VcsCreateRefResult, GitCommandError>;
65 readonly switchRef: (
66 input: VcsSwitchRefInput,
67 ) => Effect.Effect<VcsSwitchRefResult, GitCommandError>;
68 readonly renameBranch: (input: {
69 readonly cwd: string;
70 readonly oldBranch: string;
71 readonly newBranch: string;
72 }) => Effect.Effect<{ readonly branch: string }, GitManagerServiceError>;
73}
74
75export class GitWorkflowService extends Context.Service<
76 GitWorkflowService,
77 GitWorkflowServiceShape
78>()("t3/git/GitWorkflowService") {}
79
80const unsupportedGitWorkflow = (operation: string, cwd: string, detail: string) =>
81 new GitManagerError({
82 operation,
83 detail: `${detail} (${cwd})`,
84 });
85
86const unsupportedGitCommand = (operation: string, cwd: string, detail: string) =>
87 new GitCommandError({
88 operation,
89 command: "vcs-route",
90 cwd,
91 detail,
92 });
93
94function nonRepositoryLocalStatus(): VcsStatusLocalResult {
95 return {
96 isRepo: false,
97 hasPrimaryRemote: false,
98 isDefaultRef: false,
99 refName: null,
100 hasWorkingTreeChanges: false,
101 workingTree: {
102 files: [],
103 insertions: 0,
104 deletions: 0,
105 },
106 };
107}
108
109function nonRepositoryStatus(): VcsStatusResult {
110 return {
111 ...nonRepositoryLocalStatus(),
112 hasUpstream: false,
113 aheadCount: 0,
114 behindCount: 0,
115 pr: null,
116 };
117}
118
119function nonRepositoryListRefs(): VcsListRefsResult {
120 return {
121 refs: [],
122 isRepo: false,
123 hasPrimaryRemote: false,
124 nextCursor: null,
125 totalCount: 0,
126 };
127}
128
129export const make = Effect.fn("makeGitWorkflowService")(function* () {
130 const registry = yield* VcsDriverRegistry;
131 const git = yield* GitVcsDriver;
132 const gitManager = yield* GitManager;
133
134 const ensureGit = Effect.fn("GitWorkflowService.ensureGit")(function* (
135 operation: string,
136 cwd: string,
137 ) {
138 const handle = yield* registry
139 .resolve({ cwd })
140 .pipe(
141 Effect.mapError((error) =>
142 unsupportedGitWorkflow(
143 operation,
144 cwd,
145 error instanceof Error ? error.message : String(error),
146 ),
147 ),
148 );
149 if (handle.kind !== "git") {
150 return yield* unsupportedGitWorkflow(
151 operation,
152 cwd,
153 `The ${operation} workflow currently supports Git repositories only; detected ${handle.kind}.`,
154 );
155 }
156 });
157
158 const ensureGitCommand = Effect.fn("GitWorkflowService.ensureGitCommand")(function* (
159 operation: string,
160 cwd: string,
161 ) {
162 const handle = yield* registry
163 .resolve({ cwd })
164 .pipe(
165 Effect.mapError((error) =>
166 unsupportedGitCommand(
167 operation,
168 cwd,
169 error instanceof Error ? error.message : String(error),
170 ),
171 ),
172 );
173 if (handle.kind !== "git") {
174 return yield* unsupportedGitCommand(
175 operation,
176 cwd,
177 `The ${operation} command currently supports Git repositories only; detected ${handle.kind}.`,
178 );
179 }
180 });
181
182 const detectGitRepositoryForStatus = Effect.fn("GitWorkflowService.detectGitRepositoryForStatus")(
183 function* (operation: string, cwd: string) {
184 const handle = yield* registry
185 .detect({ cwd })
186 .pipe(
187 Effect.mapError((error) =>
188 unsupportedGitWorkflow(
189 operation,
190 cwd,
191 error instanceof Error ? error.message : String(error),
192 ),
193 ),
194 );
195 if (!handle) {
196 return false;
197 }
198 if (handle.kind !== "git") {
199 return yield* unsupportedGitWorkflow(
200 operation,
201 cwd,
202 `The ${operation} workflow currently supports Git repositories only; detected ${handle.kind}.`,
203 );
204 }
205 return true;
206 },
207 );
208
209 const detectGitRepositoryForCommand = Effect.fn(
210 "GitWorkflowService.detectGitRepositoryForCommand",
211 )(function* (operation: string, cwd: string) {
212 const handle = yield* registry
213 .detect({ cwd })
214 .pipe(
215 Effect.mapError((error) =>
216 unsupportedGitCommand(
217 operation,
218 cwd,
219 error instanceof Error ? error.message : String(error),
220 ),
221 ),
222 );
223 if (!handle) {
224 return false;
225 }
226 if (handle.kind !== "git") {
227 return yield* unsupportedGitCommand(
228 operation,
229 cwd,
230 `The ${operation} command currently supports Git repositories only; detected ${handle.kind}.`,
231 );
232 }
233 return true;
234 });
235
236 const routeGitManager =
237 <Input extends { readonly cwd: string }, Output>(
238 operation: string,
239 run: (input: Input) => Effect.Effect<Output, GitManagerServiceError>,
240 ) =>
241 (input: Input) =>
242 ensureGit(operation, input.cwd).pipe(Effect.andThen(run(input)));
243
244 return GitWorkflowService.of({
245 status: (input) =>
246 detectGitRepositoryForStatus("GitWorkflowService.status", input.cwd).pipe(
247 Effect.flatMap((isGitRepository) =>
248 isGitRepository ? gitManager.status(input) : Effect.succeed(nonRepositoryStatus()),
249 ),
250 ),
251 localStatus: (input) =>
252 detectGitRepositoryForStatus("GitWorkflowService.localStatus", input.cwd).pipe(
253 Effect.flatMap((isGitRepository) =>
254 isGitRepository
255 ? gitManager.localStatus(input)
256 : Effect.succeed(nonRepositoryLocalStatus()),
257 ),
258 ),
259 remoteStatus: (input) =>
260 detectGitRepositoryForStatus("GitWorkflowService.remoteStatus", input.cwd).pipe(
261 Effect.flatMap((isGitRepository) =>
262 isGitRepository ? gitManager.remoteStatus(input) : Effect.succeed(null),
263 ),
264 ),
265 invalidateLocalStatus: gitManager.invalidateLocalStatus,
266 invalidateRemoteStatus: gitManager.invalidateRemoteStatus,
267 invalidateStatus: gitManager.invalidateStatus,
268 pullCurrentBranch: (cwd) =>
269 ensureGitCommand("GitWorkflowService.pullCurrentBranch", cwd).pipe(
270 Effect.andThen(git.pullCurrentBranch(cwd)),
271 ),
272 runStackedAction: (input, options) =>
273 ensureGit("GitWorkflowService.runStackedAction", input.cwd).pipe(
274 Effect.andThen(gitManager.runStackedAction(input, options)),
275 ),
276 resolvePullRequest: routeGitManager(
277 "GitWorkflowService.resolvePullRequest",
278 gitManager.resolvePullRequest,
279 ),
280 preparePullRequestThread: routeGitManager(
281 "GitWorkflowService.preparePullRequestThread",
282 gitManager.preparePullRequestThread,
283 ),
284 listRefs: (input) =>
285 detectGitRepositoryForCommand("GitWorkflowService.listRefs", input.cwd).pipe(
286 Effect.flatMap((isGitRepository) =>
287 isGitRepository ? git.listRefs(input) : Effect.succeed(nonRepositoryListRefs()),
288 ),
289 ),
290 createWorktree: (input) =>
291 ensureGitCommand("GitWorkflowService.createWorktree", input.cwd).pipe(
292 Effect.andThen(git.createWorktree(input)),
293 ),
294 removeWorktree: (input) =>
295 ensureGitCommand("GitWorkflowService.removeWorktree", input.cwd).pipe(
296 Effect.andThen(git.removeWorktree(input)),
297 ),
298 createRef: (input) =>
299 ensureGitCommand("GitWorkflowService.createRef", input.cwd).pipe(
300 Effect.andThen(git.createRef(input)),
301 ),
302 switchRef: (input) =>
303 ensureGitCommand("GitWorkflowService.switchRef", input.cwd).pipe(
304 Effect.andThen(Effect.scoped(git.switchRef(input))),
305 ),
306 renameBranch: (input) =>
307 ensureGit("GitWorkflowService.renameBranch", input.cwd).pipe(
308 Effect.andThen(git.renameBranch(input)),
309 ),
310 });
311});
312
313export const layer = Layer.effect(GitWorkflowService, make());
apps/server/src/git/Layers/ClaudeTextGeneration.test.ts deleted-339
......@@ -1,339 +0,0 @@
1import { ClaudeSettings, ProviderInstanceId } from "@t3tools/contracts";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { it } from "@effect/vitest";
4import { Effect, FileSystem, Layer, Path, Schema } from "effect";
5import { createModelSelection } from "@t3tools/shared/model";
6import { expect } from "vitest";
7
8import { ServerConfig } from "../../config.ts";
9import { type TextGenerationShape } from "../Services/TextGeneration.ts";
10import { sanitizeThreadTitle } from "../Utils.ts";
11import { makeClaudeTextGeneration } from "./ClaudeTextGeneration.ts";
12
13const ClaudeTextGenerationTestLayer = ServerConfig.layerTest(process.cwd(), {
14 prefix: "t3code-claude-text-generation-test-",
15}).pipe(Layer.provideMerge(NodeServices.layer));
16
17function makeFakeClaudeBinary(dir: string) {
18 return Effect.gen(function* () {
19 const fs = yield* FileSystem.FileSystem;
20 const path = yield* Path.Path;
21 const binDir = path.join(dir, "bin");
22 const claudePath = path.join(binDir, "claude");
23 yield* fs.makeDirectory(binDir, { recursive: true });
24
25 yield* fs.writeFileString(
26 claudePath,
27 [
28 "#!/bin/sh",
29 'args="$*"',
30 'stdin_content="$(cat)"',
31 'if [ -n "$T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN" ]; then',
32 ' printf "%s" "$args" | grep -F -- "$T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN" >/dev/null || {',
33 ' printf "%s\\n" "args missing expected content" >&2',
34 " exit 2",
35 " }",
36 "fi",
37 'if [ -n "$T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN" ]; then',
38 ' if printf "%s" "$args" | grep -F -- "$T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN" >/dev/null; then',
39 ' printf "%s\\n" "args contained forbidden content" >&2',
40 " exit 3",
41 " fi",
42 "fi",
43 'if [ -n "$T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN" ]; then',
44 ' printf "%s" "$stdin_content" | grep -F -- "$T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN" >/dev/null || {',
45 ' printf "%s\\n" "stdin missing expected content" >&2',
46 " exit 4",
47 " }",
48 "fi",
49 'if [ -n "$T3_FAKE_CLAUDE_HOME_MUST_BE" ] && [ "$HOME" != "$T3_FAKE_CLAUDE_HOME_MUST_BE" ]; then',
50 ' printf "%s\\n" "HOME was $HOME" >&2',
51 " exit 5",
52 "fi",
53 'if [ -n "$T3_FAKE_CLAUDE_STDERR" ]; then',
54 ' printf "%s\\n" "$T3_FAKE_CLAUDE_STDERR" >&2',
55 "fi",
56 'printf "%s" "$T3_FAKE_CLAUDE_OUTPUT"',
57 'exit "${T3_FAKE_CLAUDE_EXIT_CODE:-0}"',
58 "",
59 ].join("\n"),
60 );
61 yield* fs.chmod(claudePath, 0o755);
62 return binDir;
63 });
64}
65
66function withFakeClaudeEnv<A, E, R>(
67 input: {
68 output: string;
69 exitCode?: number;
70 stderr?: string;
71 argsMustContain?: string;
72 argsMustNotContain?: string;
73 stdinMustContain?: string;
74 homeMustBe?: string;
75 claudeConfig?: Partial<ClaudeSettings>;
76 },
77 effectFn: (textGeneration: TextGenerationShape) => Effect.Effect<A, E, R>,
78) {
79 return Effect.gen(function* () {
80 const fs = yield* FileSystem.FileSystem;
81 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-claude-text-" });
82 const binDir = yield* makeFakeClaudeBinary(tempDir);
83 const previousPath = process.env.PATH;
84 const previousOutput = process.env.T3_FAKE_CLAUDE_OUTPUT;
85 const previousExitCode = process.env.T3_FAKE_CLAUDE_EXIT_CODE;
86 const previousStderr = process.env.T3_FAKE_CLAUDE_STDERR;
87 const previousArgsMustContain = process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN;
88 const previousArgsMustNotContain = process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN;
89 const previousStdinMustContain = process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN;
90 const previousHomeMustBe = process.env.T3_FAKE_CLAUDE_HOME_MUST_BE;
91
92 yield* Effect.acquireRelease(
93 Effect.sync(() => {
94 process.env.PATH = `${binDir}:${previousPath ?? ""}`;
95 process.env.T3_FAKE_CLAUDE_OUTPUT = input.output;
96
97 if (input.exitCode !== undefined) {
98 process.env.T3_FAKE_CLAUDE_EXIT_CODE = String(input.exitCode);
99 } else {
100 delete process.env.T3_FAKE_CLAUDE_EXIT_CODE;
101 }
102
103 if (input.stderr !== undefined) {
104 process.env.T3_FAKE_CLAUDE_STDERR = input.stderr;
105 } else {
106 delete process.env.T3_FAKE_CLAUDE_STDERR;
107 }
108
109 if (input.argsMustContain !== undefined) {
110 process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN = input.argsMustContain;
111 } else {
112 delete process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN;
113 }
114
115 if (input.argsMustNotContain !== undefined) {
116 process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN = input.argsMustNotContain;
117 } else {
118 delete process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN;
119 }
120
121 if (input.stdinMustContain !== undefined) {
122 process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN = input.stdinMustContain;
123 } else {
124 delete process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN;
125 }
126
127 if (input.homeMustBe !== undefined) {
128 process.env.T3_FAKE_CLAUDE_HOME_MUST_BE = input.homeMustBe;
129 } else {
130 delete process.env.T3_FAKE_CLAUDE_HOME_MUST_BE;
131 }
132 }),
133 () =>
134 Effect.sync(() => {
135 process.env.PATH = previousPath;
136
137 if (previousOutput === undefined) {
138 delete process.env.T3_FAKE_CLAUDE_OUTPUT;
139 } else {
140 process.env.T3_FAKE_CLAUDE_OUTPUT = previousOutput;
141 }
142
143 if (previousExitCode === undefined) {
144 delete process.env.T3_FAKE_CLAUDE_EXIT_CODE;
145 } else {
146 process.env.T3_FAKE_CLAUDE_EXIT_CODE = previousExitCode;
147 }
148
149 if (previousStderr === undefined) {
150 delete process.env.T3_FAKE_CLAUDE_STDERR;
151 } else {
152 process.env.T3_FAKE_CLAUDE_STDERR = previousStderr;
153 }
154
155 if (previousArgsMustContain === undefined) {
156 delete process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN;
157 } else {
158 process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN = previousArgsMustContain;
159 }
160
161 if (previousArgsMustNotContain === undefined) {
162 delete process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN;
163 } else {
164 process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN = previousArgsMustNotContain;
165 }
166
167 if (previousStdinMustContain === undefined) {
168 delete process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN;
169 } else {
170 process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN = previousStdinMustContain;
171 }
172
173 if (previousHomeMustBe === undefined) {
174 delete process.env.T3_FAKE_CLAUDE_HOME_MUST_BE;
175 } else {
176 process.env.T3_FAKE_CLAUDE_HOME_MUST_BE = previousHomeMustBe;
177 }
178 }),
179 );
180
181 const config = Schema.decodeSync(ClaudeSettings)(input.claudeConfig ?? {});
182 const textGeneration = yield* makeClaudeTextGeneration(config);
183 return yield* effectFn(textGeneration);
184 }).pipe(Effect.scoped);
185}
186
187it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGenerationLive", (it) => {
188 it.effect("forwards Claude thinking settings for Haiku without passing effort", () =>
189 withFakeClaudeEnv(
190 {
191 output: JSON.stringify({
192 structured_output: {
193 subject: "Add important change",
194 body: "",
195 },
196 }),
197 argsMustContain: '--settings {"alwaysThinkingEnabled":false}',
198 argsMustNotContain: "--effort",
199 },
200 (textGeneration) =>
201 Effect.gen(function* () {
202 const generated = yield* textGeneration.generateCommitMessage({
203 cwd: process.cwd(),
204 branch: "feature/claude-effect",
205 stagedSummary: "M README.md",
206 stagedPatch: "diff --git a/README.md b/README.md",
207 modelSelection: {
208 ...createModelSelection(ProviderInstanceId.make("claudeAgent"), "claude-haiku-4-5", [
209 { id: "thinking", value: false },
210 { id: "effort", value: "high" },
211 ]),
212 },
213 });
214
215 expect(generated.subject).toBe("Add important change");
216 }),
217 ),
218 );
219
220 it.effect("forwards Claude fast mode and supported effort", () =>
221 withFakeClaudeEnv(
222 {
223 output: JSON.stringify({
224 structured_output: {
225 title: "Improve orchestration flow",
226 body: "Body",
227 },
228 }),
229 argsMustContain: '--effort max --settings {"fastMode":true}',
230 },
231 (textGeneration) =>
232 Effect.gen(function* () {
233 const generated = yield* textGeneration.generatePrContent({
234 cwd: process.cwd(),
235 baseBranch: "main",
236 headBranch: "feature/claude-effect",
237 commitSummary: "Improve orchestration",
238 diffSummary: "1 file changed",
239 diffPatch: "diff --git a/README.md b/README.md",
240 modelSelection: {
241 ...createModelSelection(ProviderInstanceId.make("claudeAgent"), "claude-opus-4-6", [
242 { id: "effort", value: "max" },
243 { id: "fastMode", value: true },
244 ]),
245 },
246 });
247
248 expect(generated.title).toBe("Improve orchestration flow");
249 }),
250 ),
251 );
252
253 it.effect("generates thread titles through the Claude provider", () =>
254 withFakeClaudeEnv(
255 {
256 output: JSON.stringify({
257 structured_output: {
258 title:
259 ' "Reconnect failures after restart because the session state does not recover" ',
260 },
261 }),
262 stdinMustContain: "You write concise thread titles for coding conversations.",
263 },
264 (textGeneration) =>
265 Effect.gen(function* () {
266 const generated = yield* textGeneration.generateThreadTitle({
267 cwd: process.cwd(),
268 message: "Please investigate reconnect failures after restarting the session.",
269 modelSelection: {
270 instanceId: ProviderInstanceId.make("claudeAgent"),
271 model: "claude-sonnet-4-6",
272 },
273 });
274
275 expect(generated.title).toBe(
276 sanitizeThreadTitle(
277 '"Reconnect failures after restart because the session state does not recover"',
278 ),
279 );
280 }),
281 ),
282 );
283
284 it.effect("runs Claude text generation with the configured Claude HOME", () =>
285 Effect.gen(function* () {
286 const path = yield* Path.Path;
287 const claudeHome = path.join(process.cwd(), ".claude-work-test");
288 return yield* withFakeClaudeEnv(
289 {
290 output: JSON.stringify({
291 structured_output: {
292 title: "Use Claude home",
293 },
294 }),
295 homeMustBe: claudeHome,
296 claudeConfig: { homePath: claudeHome },
297 },
298 (textGeneration) =>
299 Effect.gen(function* () {
300 const generated = yield* textGeneration.generateThreadTitle({
301 cwd: process.cwd(),
302 message: "thread title",
303 modelSelection: {
304 instanceId: ProviderInstanceId.make("claudeAgent"),
305 model: "claude-sonnet-4-6",
306 },
307 });
308
309 expect(generated.title).toBe(sanitizeThreadTitle("Use Claude home"));
310 }),
311 );
312 }),
313 );
314
315 it.effect("falls back when Claude thread title normalization becomes whitespace-only", () =>
316 withFakeClaudeEnv(
317 {
318 output: JSON.stringify({
319 structured_output: {
320 title: ' """ """ ',
321 },
322 }),
323 },
324 (textGeneration) =>
325 Effect.gen(function* () {
326 const generated = yield* textGeneration.generateThreadTitle({
327 cwd: process.cwd(),
328 message: "Name this thread.",
329 modelSelection: {
330 instanceId: ProviderInstanceId.make("claudeAgent"),
331 model: "claude-sonnet-4-6",
332 },
333 });
334
335 expect(generated.title).toBe("New thread");
336 }),
337 ),
338 );
339});
apps/server/src/git/Layers/ClaudeTextGeneration.ts deleted-324
......@@ -1,324 +0,0 @@
1/**
2 * ClaudeTextGeneration – Text generation layer using the Claude CLI.
3 *
4 * Implements the same TextGenerationShape contract as CodexTextGeneration but
5 * delegates to the `claude` CLI (`claude -p`) with structured JSON output
6 * instead of the `codex exec` CLI.
7 *
8 * @module ClaudeTextGeneration
9 */
10import { Effect, Option, Schema, Stream } from "effect";
11import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
12
13import { type ClaudeSettings, type ModelSelection } from "@t3tools/contracts";
14import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
15
16import { TextGenerationError } from "@t3tools/contracts";
17import { type TextGenerationShape } from "../Services/TextGeneration.ts";
18import {
19 buildBranchNamePrompt,
20 buildCommitMessagePrompt,
21 buildPrContentPrompt,
22 buildThreadTitlePrompt,
23} from "../Prompts.ts";
24import {
25 normalizeCliError,
26 sanitizeCommitSubject,
27 sanitizePrTitle,
28 sanitizeThreadTitle,
29 toJsonSchemaObject,
30} from "../Utils.ts";
31import {
32 getModelSelectionStringOptionValue,
33 getProviderOptionDescriptors,
34} from "@t3tools/shared/model";
35import {
36 getClaudeModelCapabilities,
37 normalizeClaudeCliEffort,
38 resolveClaudeApiModelId,
39 resolveClaudeEffort,
40} from "../../provider/Layers/ClaudeProvider.ts";
41import { makeClaudeEnvironment } from "../../provider/Drivers/ClaudeHome.ts";
42
43const CLAUDE_TIMEOUT_MS = 180_000;
44
45/**
46 * Schema for the wrapper JSON returned by `claude -p --output-format json`.
47 * We only care about `structured_output`.
48 */
49const ClaudeOutputEnvelope = Schema.Struct({
50 structured_output: Schema.Unknown,
51});
52
53export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(function* (
54 claudeSettings: ClaudeSettings,
55 environment: NodeJS.ProcessEnv = process.env,
56) {
57 const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
58 const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment);
59
60 const readStreamAsString = <E>(
61 operation: string,
62 stream: Stream.Stream<Uint8Array, E>,
63 ): Effect.Effect<string, TextGenerationError> =>
64 stream.pipe(
65 Stream.decodeText(),
66 Stream.runFold(
67 () => "",
68 (acc, chunk) => acc + chunk,
69 ),
70 Effect.mapError((cause) =>
71 normalizeCliError("claude", operation, cause, "Failed to collect process output"),
72 ),
73 );
74
75 /**
76 * Spawn the Claude CLI with structured JSON output and return the parsed,
77 * schema-validated result.
78 */
79 const runClaudeJson = Effect.fn("runClaudeJson")(function* <S extends Schema.Top>({
80 operation,
81 cwd,
82 prompt,
83 outputSchemaJson,
84 modelSelection,
85 }: {
86 operation:
87 | "generateCommitMessage"
88 | "generatePrContent"
89 | "generateBranchName"
90 | "generateThreadTitle";
91 cwd: string;
92 prompt: string;
93 outputSchemaJson: S;
94 modelSelection: ModelSelection;
95 }): Effect.fn.Return<S["Type"], TextGenerationError, S["DecodingServices"]> {
96 const jsonSchemaStr = JSON.stringify(toJsonSchemaObject(outputSchemaJson));
97 const caps = getClaudeModelCapabilities(modelSelection.model);
98 const descriptors = getProviderOptionDescriptors({
99 caps,
100 selections: modelSelection.options,
101 });
102 const findDescriptor = (id: string) => descriptors.find((descriptor) => descriptor.id === id);
103 const rawEffortSelection = getModelSelectionStringOptionValue(modelSelection, "effort");
104 const resolvedEffort = resolveClaudeEffort(caps, rawEffortSelection);
105 const cliEffort = normalizeClaudeCliEffort(resolvedEffort);
106 const thinkingDescriptor = findDescriptor("thinking");
107 const fastModeDescriptor = findDescriptor("fastMode");
108 const thinking =
109 thinkingDescriptor?.type === "boolean" ? thinkingDescriptor.currentValue : undefined;
110 const fastMode =
111 fastModeDescriptor?.type === "boolean" ? fastModeDescriptor.currentValue : undefined;
112 const settings = {
113 ...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}),
114 ...(fastMode ? { fastMode: true } : {}),
115 };
116
117 const runClaudeCommand = Effect.fn("runClaudeJson.runClaudeCommand")(function* () {
118 const command = ChildProcess.make(
119 claudeSettings.binaryPath || "claude",
120 [
121 "-p",
122 "--output-format",
123 "json",
124 "--json-schema",
125 jsonSchemaStr,
126 "--model",
127 resolveClaudeApiModelId(modelSelection),
128 ...(cliEffort ? ["--effort", cliEffort] : []),
129 ...(Object.keys(settings).length > 0 ? ["--settings", JSON.stringify(settings)] : []),
130 "--dangerously-skip-permissions",
131 ],
132 {
133 env: claudeEnvironment,
134 cwd,
135 shell: process.platform === "win32",
136 stdin: {
137 stream: Stream.encodeText(Stream.make(prompt)),
138 },
139 },
140 );
141
142 const child = yield* commandSpawner
143 .spawn(command)
144 .pipe(
145 Effect.mapError((cause) =>
146 normalizeCliError("claude", operation, cause, "Failed to spawn Claude CLI process"),
147 ),
148 );
149
150 const [stdout, stderr, exitCode] = yield* Effect.all(
151 [
152 readStreamAsString(operation, child.stdout),
153 readStreamAsString(operation, child.stderr),
154 child.exitCode.pipe(
155 Effect.mapError((cause) =>
156 normalizeCliError("claude", operation, cause, "Failed to read Claude CLI exit code"),
157 ),
158 ),
159 ],
160 { concurrency: "unbounded" },
161 );
162
163 if (exitCode !== 0) {
164 const stderrDetail = stderr.trim();
165 const stdoutDetail = stdout.trim();
166 const detail = stderrDetail.length > 0 ? stderrDetail : stdoutDetail;
167 return yield* new TextGenerationError({
168 operation,
169 detail:
170 detail.length > 0
171 ? `Claude CLI command failed: ${detail}`
172 : `Claude CLI command failed with code ${exitCode}.`,
173 });
174 }
175
176 return stdout;
177 });
178
179 const rawStdout = yield* runClaudeCommand().pipe(
180 Effect.scoped,
181 Effect.timeoutOption(CLAUDE_TIMEOUT_MS),
182 Effect.flatMap(
183 Option.match({
184 onNone: () =>
185 Effect.fail(
186 new TextGenerationError({ operation, detail: "Claude CLI request timed out." }),
187 ),
188 onSome: (value) => Effect.succeed(value),
189 }),
190 ),
191 );
192
193 const envelope = yield* Schema.decodeEffect(Schema.fromJsonString(ClaudeOutputEnvelope))(
194 rawStdout,
195 ).pipe(
196 Effect.catchTag("SchemaError", (cause) =>
197 Effect.fail(
198 new TextGenerationError({
199 operation,
200 detail: "Claude CLI returned unexpected output format.",
201 cause,
202 }),
203 ),
204 ),
205 );
206
207 return yield* Schema.decodeEffect(outputSchemaJson)(envelope.structured_output).pipe(
208 Effect.catchTag("SchemaError", (cause) =>
209 Effect.fail(
210 new TextGenerationError({
211 operation,
212 detail: "Claude returned invalid structured output.",
213 cause,
214 }),
215 ),
216 ),
217 );
218 });
219
220 // ---------------------------------------------------------------------------
221 // TextGenerationShape methods
222 // ---------------------------------------------------------------------------
223
224 const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn(
225 "ClaudeTextGeneration.generateCommitMessage",
226 )(function* (input) {
227 const { prompt, outputSchema } = buildCommitMessagePrompt({
228 branch: input.branch,
229 stagedSummary: input.stagedSummary,
230 stagedPatch: input.stagedPatch,
231 includeBranch: input.includeBranch === true,
232 });
233
234 const generated = yield* runClaudeJson({
235 operation: "generateCommitMessage",
236 cwd: input.cwd,
237 prompt,
238 outputSchemaJson: outputSchema,
239 modelSelection: input.modelSelection,
240 });
241
242 return {
243 subject: sanitizeCommitSubject(generated.subject),
244 body: generated.body.trim(),
245 ...("branch" in generated && typeof generated.branch === "string"
246 ? { branch: sanitizeFeatureBranchName(generated.branch) }
247 : {}),
248 };
249 });
250
251 const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn(
252 "ClaudeTextGeneration.generatePrContent",
253 )(function* (input) {
254 const { prompt, outputSchema } = buildPrContentPrompt({
255 baseBranch: input.baseBranch,
256 headBranch: input.headBranch,
257 commitSummary: input.commitSummary,
258 diffSummary: input.diffSummary,
259 diffPatch: input.diffPatch,
260 });
261
262 const generated = yield* runClaudeJson({
263 operation: "generatePrContent",
264 cwd: input.cwd,
265 prompt,
266 outputSchemaJson: outputSchema,
267 modelSelection: input.modelSelection,
268 });
269
270 return {
271 title: sanitizePrTitle(generated.title),
272 body: generated.body.trim(),
273 };
274 });
275
276 const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn(
277 "ClaudeTextGeneration.generateBranchName",
278 )(function* (input) {
279 const { prompt, outputSchema } = buildBranchNamePrompt({
280 message: input.message,
281 attachments: input.attachments,
282 });
283
284 const generated = yield* runClaudeJson({
285 operation: "generateBranchName",
286 cwd: input.cwd,
287 prompt,
288 outputSchemaJson: outputSchema,
289 modelSelection: input.modelSelection,
290 });
291
292 return {
293 branch: sanitizeBranchFragment(generated.branch),
294 };
295 });
296
297 const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn(
298 "ClaudeTextGeneration.generateThreadTitle",
299 )(function* (input) {
300 const { prompt, outputSchema } = buildThreadTitlePrompt({
301 message: input.message,
302 attachments: input.attachments,
303 });
304
305 const generated = yield* runClaudeJson({
306 operation: "generateThreadTitle",
307 cwd: input.cwd,
308 prompt,
309 outputSchemaJson: outputSchema,
310 modelSelection: input.modelSelection,
311 });
312
313 return {
314 title: sanitizeThreadTitle(generated.title),
315 };
316 });
317
318 return {
319 generateCommitMessage,
320 generatePrContent,
321 generateBranchName,
322 generateThreadTitle,
323 } satisfies TextGenerationShape;
324});
apps/server/src/git/Layers/CodexTextGeneration.test.ts deleted-592
......@@ -1,592 +0,0 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { it } from "@effect/vitest";
3import { Effect, FileSystem, Layer, Path, Result, Schema } from "effect";
4import { createModelSelection } from "@t3tools/shared/model";
5import { expect } from "vitest";
6
7import { CodexSettings, ProviderInstanceId, TextGenerationError } from "@t3tools/contracts";
8
9import { ServerConfig } from "../../config.ts";
10import { type TextGenerationShape } from "../Services/TextGeneration.ts";
11import { makeCodexTextGeneration } from "./CodexTextGeneration.ts";
12
13const DEFAULT_TEST_MODEL_SELECTION = createModelSelection(
14 ProviderInstanceId.make("codex"),
15 "gpt-5.4-mini",
16);
17
18const CodexTextGenerationTestLayer = ServerConfig.layerTest(process.cwd(), {
19 prefix: "t3code-codex-text-generation-test-",
20}).pipe(Layer.provideMerge(NodeServices.layer));
21
22function makeFakeCodexBinary(
23 dir: string,
24 input: {
25 output: string;
26 exitCode?: number;
27 stderr?: string;
28 requireImage?: boolean;
29 requireFastServiceTier?: boolean;
30 requireReasoningEffort?: string;
31 forbidReasoningEffort?: boolean;
32 stdinMustContain?: string;
33 stdinMustNotContain?: string;
34 },
35) {
36 return Effect.gen(function* () {
37 const fs = yield* FileSystem.FileSystem;
38 const path = yield* Path.Path;
39 const binDir = path.join(dir, "bin");
40 const codexPath = path.join(binDir, "codex");
41 yield* fs.makeDirectory(binDir, { recursive: true });
42
43 yield* fs.writeFileString(
44 codexPath,
45 [
46 "#!/bin/sh",
47 'output_path=""',
48 'seen_image="0"',
49 'seen_fast_service_tier="0"',
50 'seen_reasoning_effort=""',
51 "while [ $# -gt 0 ]; do",
52 ' if [ "$1" = "--image" ]; then',
53 " shift",
54 ' if [ -n "$1" ]; then',
55 ' seen_image="1"',
56 " fi",
57 " shift",
58 " continue",
59 " fi",
60 ' if [ "$1" = "--config" ]; then',
61 " shift",
62 ' if [ "$1" = "service_tier=\\"fast\\"" ]; then',
63 ' seen_fast_service_tier="1"',
64 " fi",
65 ' case "$1" in',
66 " model_reasoning_effort=*)",
67 ' seen_reasoning_effort="$1"',
68 " ;;",
69 " esac",
70 " shift",
71 " continue",
72 " fi",
73 ' if [ "$1" = "--output-last-message" ]; then',
74 " shift",
75 ' output_path="$1"',
76 " shift",
77 " continue",
78 " fi",
79 " shift",
80 "done",
81 'stdin_content="$(cat)"',
82 ...(input.requireImage
83 ? [
84 'if [ "$seen_image" != "1" ]; then',
85 ' printf "%s\\n" "missing --image input" >&2',
86 ` exit 2`,
87 "fi",
88 ]
89 : []),
90 ...(input.requireFastServiceTier
91 ? [
92 'if [ "$seen_fast_service_tier" != "1" ]; then',
93 ' printf "%s\\n" "missing fast service tier config" >&2',
94 ` exit 5`,
95 "fi",
96 ]
97 : []),
98 ...(input.requireReasoningEffort !== undefined
99 ? [
100 `if [ "$seen_reasoning_effort" != "model_reasoning_effort=\\"${input.requireReasoningEffort}\\"" ]; then`,
101 ' printf "%s\\n" "unexpected reasoning effort config: $seen_reasoning_effort" >&2',
102 ` exit 6`,
103 "fi",
104 ]
105 : []),
106 ...(input.forbidReasoningEffort
107 ? [
108 'if [ -n "$seen_reasoning_effort" ]; then',
109 ' printf "%s\\n" "reasoning effort config should be omitted: $seen_reasoning_effort" >&2',
110 ` exit 7`,
111 "fi",
112 ]
113 : []),
114 ...(input.stdinMustContain !== undefined
115 ? [
116 `if ! printf "%s" "$stdin_content" | grep -F -- ${JSON.stringify(input.stdinMustContain)} >/dev/null; then`,
117 ' printf "%s\\n" "stdin missing expected content" >&2',
118 ` exit 3`,
119 "fi",
120 ]
121 : []),
122 ...(input.stdinMustNotContain !== undefined
123 ? [
124 `if printf "%s" "$stdin_content" | grep -F -- ${JSON.stringify(input.stdinMustNotContain)} >/dev/null; then`,
125 ' printf "%s\\n" "stdin contained forbidden content" >&2',
126 ` exit 4`,
127 "fi",
128 ]
129 : []),
130 ...(input.stderr !== undefined
131 ? [`printf "%s\\n" ${JSON.stringify(input.stderr)} >&2`]
132 : []),
133 'if [ -n "$output_path" ]; then',
134 " cat > \"$output_path\" <<'__T3CODE_FAKE_CODEX_OUTPUT__'",
135 input.output,
136 "__T3CODE_FAKE_CODEX_OUTPUT__",
137 "fi",
138 `exit ${input.exitCode ?? 0}`,
139 "",
140 ].join("\n"),
141 );
142 yield* fs.chmod(codexPath, 0o755);
143 return codexPath;
144 });
145}
146
147function withFakeCodexEnv<A, E, R>(
148 input: {
149 output: string;
150 exitCode?: number;
151 stderr?: string;
152 requireImage?: boolean;
153 requireFastServiceTier?: boolean;
154 requireReasoningEffort?: string;
155 forbidReasoningEffort?: boolean;
156 stdinMustContain?: string;
157 stdinMustNotContain?: string;
158 },
159 effectFn: (textGeneration: TextGenerationShape) => Effect.Effect<A, E, R>,
160) {
161 return Effect.gen(function* () {
162 const fs = yield* FileSystem.FileSystem;
163 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-codex-text-" });
164 const codexPath = yield* makeFakeCodexBinary(tempDir, input);
165 const config = Schema.decodeSync(CodexSettings)({ binaryPath: codexPath });
166 const textGeneration = yield* makeCodexTextGeneration(config);
167 return yield* effectFn(textGeneration);
168 }).pipe(Effect.scoped);
169}
170
171it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
172 it.effect("generates and sanitizes commit messages without branch by default", () =>
173 withFakeCodexEnv(
174 {
175 output: JSON.stringify({
176 subject:
177 " Add important change to the system with too much detail and a trailing period.\nsecondary line",
178 body: "\n- added migration\n- updated tests\n",
179 }),
180 stdinMustNotContain: "branch must be a short semantic git branch fragment",
181 },
182 (textGeneration) =>
183 Effect.gen(function* () {
184 const generated = yield* textGeneration.generateCommitMessage({
185 cwd: process.cwd(),
186 branch: "feature/codex-effect",
187 stagedSummary: "M README.md",
188 stagedPatch: "diff --git a/README.md b/README.md",
189 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
190 });
191
192 expect(generated.subject.length).toBeLessThanOrEqual(72);
193 expect(generated.subject.endsWith(".")).toBe(false);
194 expect(generated.body).toBe("- added migration\n- updated tests");
195 expect(generated.branch).toBeUndefined();
196 }),
197 ),
198 );
199
200 it.effect(
201 "forwards codex fast mode and non-default reasoning effort into codex exec config",
202 () =>
203 withFakeCodexEnv(
204 {
205 output: JSON.stringify({
206 subject: "Add important change",
207 body: "",
208 }),
209 requireFastServiceTier: true,
210 requireReasoningEffort: "xhigh",
211 stdinMustNotContain: "branch must be a short semantic git branch fragment",
212 },
213 (textGeneration) =>
214 textGeneration.generateCommitMessage({
215 cwd: process.cwd(),
216 branch: "feature/codex-effect",
217 stagedSummary: "M README.md",
218 stagedPatch: "diff --git a/README.md b/README.md",
219 modelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [
220 { id: "reasoningEffort", value: "xhigh" },
221 { id: "fastMode", value: true },
222 ]),
223 }),
224 ),
225 );
226
227 it.effect("defaults git text generation codex effort to low", () =>
228 withFakeCodexEnv(
229 {
230 output: JSON.stringify({
231 subject: "Add important change",
232 body: "",
233 }),
234 requireReasoningEffort: "low",
235 },
236 (textGeneration) =>
237 textGeneration.generateCommitMessage({
238 cwd: process.cwd(),
239 branch: "feature/codex-effect",
240 stagedSummary: "M README.md",
241 stagedPatch: "diff --git a/README.md b/README.md",
242 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
243 }),
244 ),
245 );
246
247 it.effect("generates commit message with branch when includeBranch is true", () =>
248 withFakeCodexEnv(
249 {
250 output: JSON.stringify({
251 subject: "Add important change",
252 body: "",
253 branch: "fix/important-system-change",
254 }),
255 stdinMustContain: "branch must be a short semantic git branch fragment",
256 },
257 (textGeneration) =>
258 Effect.gen(function* () {
259 const generated = yield* textGeneration.generateCommitMessage({
260 cwd: process.cwd(),
261 branch: "feature/codex-effect",
262 stagedSummary: "M README.md",
263 stagedPatch: "diff --git a/README.md b/README.md",
264 includeBranch: true,
265 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
266 });
267
268 expect(generated.subject).toBe("Add important change");
269 expect(generated.branch).toBe("feature/fix/important-system-change");
270 }),
271 ),
272 );
273
274 it.effect("generates PR content and trims markdown body", () =>
275 withFakeCodexEnv(
276 {
277 output: JSON.stringify({
278 title: " Improve orchestration flow\nwith ignored suffix",
279 body: "\n## Summary\n- improve flow\n\n## Testing\n- bun test\n\n",
280 }),
281 },
282 (textGeneration) =>
283 Effect.gen(function* () {
284 const generated = yield* textGeneration.generatePrContent({
285 cwd: process.cwd(),
286 baseBranch: "main",
287 headBranch: "feature/codex-effect",
288 commitSummary: "feat: improve orchestration flow",
289 diffSummary: "2 files changed",
290 diffPatch: "diff --git a/a.ts b/a.ts",
291 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
292 });
293
294 expect(generated.title).toBe("Improve orchestration flow");
295 expect(generated.body.startsWith("## Summary")).toBe(true);
296 expect(generated.body.endsWith("\n\n")).toBe(false);
297 }),
298 ),
299 );
300
301 it.effect("generates branch names and normalizes branch fragments", () =>
302 withFakeCodexEnv(
303 {
304 output: JSON.stringify({
305 branch: " Feat/Session ",
306 }),
307 stdinMustNotContain: "Image attachments supplied to the model",
308 },
309 (textGeneration) =>
310 Effect.gen(function* () {
311 const generated = yield* textGeneration.generateBranchName({
312 cwd: process.cwd(),
313 message: "Please update session handling.",
314 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
315 });
316
317 expect(generated.branch).toBe("feat/session");
318 }),
319 ),
320 );
321
322 it.effect("generates thread titles and trims them for sidebar use", () =>
323 withFakeCodexEnv(
324 {
325 output: JSON.stringify({
326 title:
327 ' "Investigate websocket reconnect regressions after worktree restore" \nignored line',
328 }),
329 },
330 (textGeneration) =>
331 Effect.gen(function* () {
332 const generated = yield* textGeneration.generateThreadTitle({
333 cwd: process.cwd(),
334 message: "Please investigate websocket reconnect regressions after a worktree restore.",
335 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
336 });
337
338 expect(generated.title).toBe("Investigate websocket reconnect regressions aft...");
339 }),
340 ),
341 );
342
343 it.effect("falls back when thread title normalization becomes whitespace-only", () =>
344 withFakeCodexEnv(
345 {
346 output: JSON.stringify({
347 title: ' """ """ ',
348 }),
349 },
350 (textGeneration) =>
351 Effect.gen(function* () {
352 const generated = yield* textGeneration.generateThreadTitle({
353 cwd: process.cwd(),
354 message: "Name this thread.",
355 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
356 });
357
358 expect(generated.title).toBe("New thread");
359 }),
360 ),
361 );
362
363 it.effect("trims whitespace exposed after quote removal in thread titles", () =>
364 withFakeCodexEnv(
365 {
366 output: JSON.stringify({
367 title: ` "' hello world '" `,
368 }),
369 },
370 (textGeneration) =>
371 Effect.gen(function* () {
372 const generated = yield* textGeneration.generateThreadTitle({
373 cwd: process.cwd(),
374 message: "Name this thread.",
375 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
376 });
377
378 expect(generated.title).toBe("hello world");
379 }),
380 ),
381 );
382
383 it.effect("omits attachment metadata section when no attachments are provided", () =>
384 withFakeCodexEnv(
385 {
386 output: JSON.stringify({
387 branch: "fix/session-timeout",
388 }),
389 stdinMustNotContain: "Attachment metadata:",
390 },
391 (textGeneration) =>
392 Effect.gen(function* () {
393 const generated = yield* textGeneration.generateBranchName({
394 cwd: process.cwd(),
395 message: "Fix timeout behavior.",
396 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
397 });
398
399 expect(generated.branch).toBe("fix/session-timeout");
400 }),
401 ),
402 );
403
404 it.effect("passes image attachments through as codex image inputs", () =>
405 withFakeCodexEnv(
406 {
407 output: JSON.stringify({
408 branch: "fix/ui-regression",
409 }),
410 requireImage: true,
411 stdinMustContain: "Attachment metadata:",
412 },
413 (textGeneration) =>
414 Effect.gen(function* () {
415 const fs = yield* FileSystem.FileSystem;
416 const path = yield* Path.Path;
417 const { attachmentsDir } = yield* ServerConfig;
418 const attachmentId = `thread-branch-image-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
419 const attachmentPath = path.join(attachmentsDir, `${attachmentId}.png`);
420 yield* fs.makeDirectory(attachmentsDir, { recursive: true });
421 yield* fs.writeFile(attachmentPath, Buffer.from("hello"));
422
423 const generated = yield* textGeneration.generateBranchName({
424 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
425 cwd: process.cwd(),
426 message: "Fix layout bug from screenshot.",
427 attachments: [
428 {
429 type: "image",
430 id: attachmentId,
431 name: "bug.png",
432 mimeType: "image/png",
433 sizeBytes: 5,
434 },
435 ],
436 });
437
438 expect(generated.branch).toBe("fix/ui-regression");
439 }),
440 ),
441 );
442
443 it.effect("resolves persisted attachment ids to files for codex image inputs", () =>
444 withFakeCodexEnv(
445 {
446 output: JSON.stringify({
447 branch: "fix/ui-regression",
448 }),
449 requireImage: true,
450 },
451 (textGeneration) =>
452 Effect.gen(function* () {
453 const fs = yield* FileSystem.FileSystem;
454 const path = yield* Path.Path;
455 const { attachmentsDir } = yield* ServerConfig;
456 const attachmentId = `thread-1-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
457 const imagePath = path.join(attachmentsDir, `${attachmentId}.png`);
458 yield* fs.makeDirectory(attachmentsDir, { recursive: true });
459 yield* fs.writeFile(imagePath, Buffer.from("hello"));
460
461 const generated = yield* textGeneration
462 .generateBranchName({
463 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
464 cwd: process.cwd(),
465 message: "Fix layout bug from screenshot.",
466 attachments: [
467 {
468 type: "image",
469 id: attachmentId,
470 name: "bug.png",
471 mimeType: "image/png",
472 sizeBytes: 5,
473 },
474 ],
475 })
476 .pipe(
477 Effect.tap(() =>
478 fs.stat(imagePath).pipe(
479 Effect.map((fileInfo) => {
480 expect(fileInfo.type).toBe("File");
481 }),
482 ),
483 ),
484 Effect.ensuring(fs.remove(imagePath).pipe(Effect.catch(() => Effect.void))),
485 );
486
487 expect(generated.branch).toBe("fix/ui-regression");
488 }),
489 ),
490 );
491
492 it.effect("ignores missing attachment ids for codex image inputs", () =>
493 withFakeCodexEnv(
494 {
495 output: JSON.stringify({
496 branch: "fix/ui-regression",
497 }),
498 requireImage: true,
499 },
500 (textGeneration) =>
501 Effect.gen(function* () {
502 const fs = yield* FileSystem.FileSystem;
503 const path = yield* Path.Path;
504 const { attachmentsDir } = yield* ServerConfig;
505 const missingAttachmentId = `thread-missing-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
506 const missingPath = path.join(attachmentsDir, `${missingAttachmentId}.png`);
507 yield* fs.remove(missingPath).pipe(Effect.catch(() => Effect.void));
508
509 const result = yield* textGeneration
510 .generateBranchName({
511 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
512 cwd: process.cwd(),
513 message: "Fix layout bug from screenshot.",
514 attachments: [
515 {
516 type: "image",
517 id: missingAttachmentId,
518 name: "outside.png",
519 mimeType: "image/png",
520 sizeBytes: 5,
521 },
522 ],
523 })
524 .pipe(Effect.result);
525
526 expect(Result.isFailure(result)).toBe(true);
527 if (Result.isFailure(result)) {
528 expect(result.failure).toBeInstanceOf(TextGenerationError);
529 expect(result.failure.message).toContain("missing --image input");
530 }
531 }),
532 ),
533 );
534
535 it.effect(
536 "fails with typed TextGenerationError when codex returns wrong branch payload shape",
537 () =>
538 withFakeCodexEnv(
539 {
540 output: JSON.stringify({
541 title: "This is not a branch payload",
542 }),
543 },
544 (textGeneration) =>
545 Effect.gen(function* () {
546 const result = yield* textGeneration
547 .generateBranchName({
548 cwd: process.cwd(),
549 message: "Fix websocket reconnect flake",
550 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
551 })
552 .pipe(Effect.result);
553
554 expect(Result.isFailure(result)).toBe(true);
555 if (Result.isFailure(result)) {
556 expect(result.failure).toBeInstanceOf(TextGenerationError);
557 expect(result.failure.message).toContain("Codex returned invalid structured output");
558 }
559 }),
560 ),
561 );
562
563 it.effect("returns typed TextGenerationError when codex exits non-zero", () =>
564 withFakeCodexEnv(
565 {
566 output: JSON.stringify({ subject: "ignored", body: "" }),
567 exitCode: 1,
568 stderr: "codex execution failed",
569 },
570 (textGeneration) =>
571 Effect.gen(function* () {
572 const result = yield* textGeneration
573 .generateCommitMessage({
574 cwd: process.cwd(),
575 branch: "feature/codex-error",
576 stagedSummary: "M README.md",
577 stagedPatch: "diff --git a/README.md b/README.md",
578 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
579 })
580 .pipe(Effect.result);
581
582 expect(Result.isFailure(result)).toBe(true);
583 if (Result.isFailure(result)) {
584 expect(result.failure).toBeInstanceOf(TextGenerationError);
585 expect(result.failure.message).toContain(
586 "Codex CLI command failed: codex execution failed",
587 );
588 }
589 }),
590 ),
591 );
592});
apps/server/src/git/Layers/CodexTextGeneration.ts deleted-392
......@@ -1,392 +0,0 @@
1import { Effect, FileSystem, Option, Path, Random, Schema, Scope, Stream } from "effect";
2import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
3
4import { type CodexSettings, type ModelSelection } from "@t3tools/contracts";
5import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
6
7import { resolveAttachmentPath } from "../../attachmentStore.ts";
8import { ServerConfig } from "../../config.ts";
9import { expandHomePath } from "../../pathExpansion.ts";
10import { TextGenerationError } from "@t3tools/contracts";
11import {
12 type BranchNameGenerationInput,
13 type ThreadTitleGenerationResult,
14 type TextGenerationShape,
15} from "../Services/TextGeneration.ts";
16import {
17 buildBranchNamePrompt,
18 buildCommitMessagePrompt,
19 buildPrContentPrompt,
20 buildThreadTitlePrompt,
21} from "../Prompts.ts";
22import {
23 normalizeCliError,
24 sanitizeCommitSubject,
25 sanitizePrTitle,
26 sanitizeThreadTitle,
27 toJsonSchemaObject,
28} from "../Utils.ts";
29import {
30 getModelSelectionBooleanOptionValue,
31 getModelSelectionStringOptionValue,
32} from "@t3tools/shared/model";
33
34const CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT = "low";
35const CODEX_TIMEOUT_MS = 180_000;
36/**
37 * Build a Codex text-generation closure bound to a specific `CodexSettings`
38 * payload. See `makeCodexAdapter` for the overall per-instance rationale.
39 */
40export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(function* (
41 codexConfig: CodexSettings,
42 environment: NodeJS.ProcessEnv = process.env,
43) {
44 const fileSystem = yield* FileSystem.FileSystem;
45 const path = yield* Path.Path;
46 const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
47 const serverConfig = yield* Effect.service(ServerConfig);
48
49 type MaterializedImageAttachments = {
50 readonly imagePaths: ReadonlyArray<string>;
51 };
52
53 const readStreamAsString = <E>(
54 operation: string,
55 stream: Stream.Stream<Uint8Array, E>,
56 ): Effect.Effect<string, TextGenerationError> =>
57 stream.pipe(
58 Stream.decodeText(),
59 Stream.runFold(
60 () => "",
61 (acc, chunk) => acc + chunk,
62 ),
63 Effect.mapError((cause) =>
64 normalizeCliError("codex", operation, cause, "Failed to collect process output"),
65 ),
66 );
67
68 const writeTempFile = (
69 operation: string,
70 prefix: string,
71 content: string,
72 ): Effect.Effect<string, TextGenerationError, Scope.Scope> => {
73 return Effect.gen(function* () {
74 const tempFileId = yield* Random.nextUUIDv4;
75 return yield* fileSystem
76 .makeTempFileScoped({
77 prefix: `t3code-${prefix}-${process.pid}-${tempFileId}.tmp`,
78 })
79 .pipe(Effect.tap((filePath) => fileSystem.writeFileString(filePath, content)));
80 }).pipe(
81 Effect.mapError(
82 (cause) =>
83 new TextGenerationError({
84 operation,
85 detail: `Failed to write temp file`,
86 cause,
87 }),
88 ),
89 );
90 };
91
92 const safeUnlink = (filePath: string): Effect.Effect<void, never> =>
93 fileSystem.remove(filePath).pipe(Effect.catch(() => Effect.void));
94
95 const materializeImageAttachments = Effect.fn("materializeImageAttachments")(function* (
96 _operation:
97 | "generateCommitMessage"
98 | "generatePrContent"
99 | "generateBranchName"
100 | "generateThreadTitle",
101 attachments: BranchNameGenerationInput["attachments"],
102 ): Effect.fn.Return<MaterializedImageAttachments, TextGenerationError> {
103 if (!attachments || attachments.length === 0) {
104 return { imagePaths: [] };
105 }
106
107 const imagePaths: string[] = [];
108 for (const attachment of attachments) {
109 if (attachment.type !== "image") {
110 continue;
111 }
112
113 const resolvedPath = resolveAttachmentPath({
114 attachmentsDir: serverConfig.attachmentsDir,
115 attachment,
116 });
117 if (!resolvedPath || !path.isAbsolute(resolvedPath)) {
118 continue;
119 }
120 const fileInfo = yield* fileSystem
121 .stat(resolvedPath)
122 .pipe(Effect.catch(() => Effect.succeed(null)));
123 if (!fileInfo || fileInfo.type !== "File") {
124 continue;
125 }
126 imagePaths.push(resolvedPath);
127 }
128 return { imagePaths };
129 });
130
131 const runCodexJson = Effect.fn("runCodexJson")(function* <S extends Schema.Top>({
132 operation,
133 cwd,
134 prompt,
135 outputSchemaJson,
136 imagePaths = [],
137 cleanupPaths = [],
138 modelSelection,
139 }: {
140 operation:
141 | "generateCommitMessage"
142 | "generatePrContent"
143 | "generateBranchName"
144 | "generateThreadTitle";
145 cwd: string;
146 prompt: string;
147 outputSchemaJson: S;
148 imagePaths?: ReadonlyArray<string>;
149 cleanupPaths?: ReadonlyArray<string>;
150 modelSelection: ModelSelection;
151 }): Effect.fn.Return<S["Type"], TextGenerationError, S["DecodingServices"]> {
152 const schemaPath = yield* writeTempFile(
153 operation,
154 "codex-schema",
155 JSON.stringify(toJsonSchemaObject(outputSchemaJson)),
156 );
157 const outputPath = yield* writeTempFile(operation, "codex-output", "");
158
159 const runCodexCommand = Effect.fn("runCodexJson.runCodexCommand")(function* () {
160 const reasoningEffort =
161 getModelSelectionStringOptionValue(modelSelection, "reasoningEffort") ??
162 CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT;
163 const command = ChildProcess.make(
164 codexConfig.binaryPath || "codex",
165 [
166 "exec",
167 "--ephemeral",
168 "--skip-git-repo-check",
169 "-s",
170 "read-only",
171 "--model",
172 modelSelection.model,
173 "--config",
174 `model_reasoning_effort="${reasoningEffort}"`,
175 ...(getModelSelectionBooleanOptionValue(modelSelection, "fastMode") === true
176 ? ["--config", `service_tier="fast"`]
177 : []),
178 "--output-schema",
179 schemaPath,
180 "--output-last-message",
181 outputPath,
182 ...imagePaths.flatMap((imagePath) => ["--image", imagePath]),
183 "-",
184 ],
185 {
186 env: {
187 ...environment,
188 ...(codexConfig.homePath ? { CODEX_HOME: expandHomePath(codexConfig.homePath) } : {}),
189 },
190 cwd,
191 shell: process.platform === "win32",
192 stdin: {
193 stream: Stream.encodeText(Stream.make(prompt)),
194 },
195 },
196 );
197
198 const child = yield* commandSpawner
199 .spawn(command)
200 .pipe(
201 Effect.mapError((cause) =>
202 normalizeCliError("codex", operation, cause, "Failed to spawn Codex CLI process"),
203 ),
204 );
205
206 const [stdout, stderr, exitCode] = yield* Effect.all(
207 [
208 readStreamAsString(operation, child.stdout),
209 readStreamAsString(operation, child.stderr),
210 child.exitCode.pipe(
211 Effect.mapError((cause) =>
212 normalizeCliError("codex", operation, cause, "Failed to read Codex CLI exit code"),
213 ),
214 ),
215 ],
216 { concurrency: "unbounded" },
217 );
218
219 if (exitCode !== 0) {
220 const stderrDetail = stderr.trim();
221 const stdoutDetail = stdout.trim();
222 const detail = stderrDetail.length > 0 ? stderrDetail : stdoutDetail;
223 return yield* new TextGenerationError({
224 operation,
225 detail:
226 detail.length > 0
227 ? `Codex CLI command failed: ${detail}`
228 : `Codex CLI command failed with code ${exitCode}.`,
229 });
230 }
231 });
232
233 const cleanup = Effect.all(
234 [schemaPath, outputPath, ...cleanupPaths].map((filePath) => safeUnlink(filePath)),
235 {
236 concurrency: "unbounded",
237 },
238 ).pipe(Effect.asVoid);
239
240 return yield* Effect.gen(function* () {
241 yield* runCodexCommand().pipe(
242 Effect.scoped,
243 Effect.timeoutOption(CODEX_TIMEOUT_MS),
244 Effect.flatMap(
245 Option.match({
246 onNone: () =>
247 Effect.fail(
248 new TextGenerationError({ operation, detail: "Codex CLI request timed out." }),
249 ),
250 onSome: () => Effect.void,
251 }),
252 ),
253 );
254
255 return yield* fileSystem.readFileString(outputPath).pipe(
256 Effect.mapError(
257 (cause) =>
258 new TextGenerationError({
259 operation,
260 detail: "Failed to read Codex output file.",
261 cause,
262 }),
263 ),
264 Effect.flatMap(Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson))),
265 Effect.catchTag("SchemaError", (cause) =>
266 Effect.fail(
267 new TextGenerationError({
268 operation,
269 detail: "Codex returned invalid structured output.",
270 cause,
271 }),
272 ),
273 ),
274 );
275 }).pipe(Effect.ensuring(cleanup));
276 });
277
278 const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn(
279 "CodexTextGeneration.generateCommitMessage",
280 )(function* (input) {
281 const { prompt, outputSchema } = buildCommitMessagePrompt({
282 branch: input.branch,
283 stagedSummary: input.stagedSummary,
284 stagedPatch: input.stagedPatch,
285 includeBranch: input.includeBranch === true,
286 });
287
288 const generated = yield* runCodexJson({
289 operation: "generateCommitMessage",
290 cwd: input.cwd,
291 prompt,
292 outputSchemaJson: outputSchema,
293 modelSelection: input.modelSelection,
294 });
295
296 return {
297 subject: sanitizeCommitSubject(generated.subject),
298 body: generated.body.trim(),
299 ...("branch" in generated && typeof generated.branch === "string"
300 ? { branch: sanitizeFeatureBranchName(generated.branch) }
301 : {}),
302 };
303 });
304
305 const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn(
306 "CodexTextGeneration.generatePrContent",
307 )(function* (input) {
308 const { prompt, outputSchema } = buildPrContentPrompt({
309 baseBranch: input.baseBranch,
310 headBranch: input.headBranch,
311 commitSummary: input.commitSummary,
312 diffSummary: input.diffSummary,
313 diffPatch: input.diffPatch,
314 });
315
316 const generated = yield* runCodexJson({
317 operation: "generatePrContent",
318 cwd: input.cwd,
319 prompt,
320 outputSchemaJson: outputSchema,
321 modelSelection: input.modelSelection,
322 });
323
324 return {
325 title: sanitizePrTitle(generated.title),
326 body: generated.body.trim(),
327 };
328 });
329
330 const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn(
331 "CodexTextGeneration.generateBranchName",
332 )(function* (input) {
333 const { imagePaths } = yield* materializeImageAttachments(
334 "generateBranchName",
335 input.attachments,
336 );
337 const { prompt, outputSchema } = buildBranchNamePrompt({
338 message: input.message,
339 attachments: input.attachments,
340 });
341
342 const generated = yield* runCodexJson({
343 operation: "generateBranchName",
344 cwd: input.cwd,
345 prompt,
346 outputSchemaJson: outputSchema,
347 imagePaths,
348 modelSelection: input.modelSelection,
349 });
350
351 return {
352 branch: sanitizeBranchFragment(generated.branch),
353 };
354 });
355
356 const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn(
357 "CodexTextGeneration.generateThreadTitle",
358 )(function* (input) {
359 const { imagePaths } = yield* materializeImageAttachments(
360 "generateThreadTitle",
361 input.attachments,
362 );
363 const { prompt, outputSchema } = buildThreadTitlePrompt({
364 message: input.message,
365 attachments: input.attachments,
366 });
367
368 const generated = yield* runCodexJson({
369 operation: "generateThreadTitle",
370 cwd: input.cwd,
371 prompt,
372 outputSchemaJson: outputSchema,
373 imagePaths,
374 modelSelection: input.modelSelection,
375 });
376
377 return {
378 title: sanitizeThreadTitle(generated.title),
379 } satisfies ThreadTitleGenerationResult;
380 });
381
382 return {
383 generateCommitMessage,
384 generatePrContent,
385 generateBranchName,
386 generateThreadTitle,
387 } satisfies TextGenerationShape;
388});
389
390// NOTE: `CodexTextGenerationLive` (the singleton Layer) has been removed.
391// `makeCodexTextGeneration(codexConfig)` is now invoked directly by
392// `CodexDriver.create()` for each configured instance.
apps/server/src/git/Layers/CursorTextGeneration.test.ts deleted-264
......@@ -1,264 +0,0 @@
1import * as path from "node:path";
2import * as os from "node:os";
3import { fileURLToPath } from "node:url";
4import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
5
6import * as NodeServices from "@effect/platform-node/NodeServices";
7import { it } from "@effect/vitest";
8import { Effect, Layer, Schema } from "effect";
9import { createModelSelection } from "@t3tools/shared/model";
10import { expect } from "vitest";
11
12import { CursorSettings, ProviderInstanceId } from "@t3tools/contracts";
13
14import { ServerConfig } from "../../config.ts";
15import { type TextGenerationShape } from "../Services/TextGeneration.ts";
16import { makeCursorTextGeneration } from "./CursorTextGeneration.ts";
17
18const __dirname = path.dirname(fileURLToPath(import.meta.url));
19const mockAgentPath = path.join(__dirname, "../../../scripts/acp-mock-agent.ts");
20
21function shellSingleQuote(value: string): string {
22 return `'${value.replaceAll("'", `'"'"'`)}'`;
23}
24
25const CursorTextGenerationTestLayer = ServerConfig.layerTest(process.cwd(), {
26 prefix: "t3code-cursor-text-generation-test-",
27}).pipe(Layer.provideMerge(NodeServices.layer));
28
29function makeAcpAgentWrapper(dir: string, env: Record<string, string>): string {
30 const binDir = path.join(dir, "bin");
31 const agentPath = path.join(binDir, "agent");
32 mkdirSync(binDir, { recursive: true });
33 writeFileSync(
34 agentPath,
35 [
36 "#!/bin/sh",
37 ...Object.entries(env).map(([key, value]) => `export ${key}=${shellSingleQuote(value)}`),
38 'if [ "$1" != "acp" ]; then',
39 ' printf "%s\\n" "unexpected args: $*" >&2',
40 " exit 11",
41 "fi",
42 `exec bun ${JSON.stringify(mockAgentPath)}`,
43 "",
44 ].join("\n"),
45 "utf8",
46 );
47 chmodSync(agentPath, 0o755);
48 return agentPath;
49}
50
51function withFakeAcpAgent<A, E, R>(
52 env: Record<string, string>,
53 effectFn: (textGeneration: TextGenerationShape) => Effect.Effect<A, E, R>,
54) {
55 return Effect.gen(function* () {
56 const tempDir = mkdtempSync(path.join(os.tmpdir(), "t3code-cursor-text-acp-"));
57 yield* Effect.addFinalizer(() =>
58 Effect.sync(() => {
59 rmSync(tempDir, { recursive: true, force: true });
60 }),
61 );
62 const agentPath = makeAcpAgentWrapper(tempDir, env);
63 const config = Schema.decodeSync(CursorSettings)({ binaryPath: agentPath });
64 const textGeneration = yield* makeCursorTextGeneration(config);
65 return yield* effectFn(textGeneration);
66 }).pipe(Effect.scoped);
67}
68
69function waitForFileContent(path: string): Effect.Effect<string> {
70 return Effect.promise(async () => {
71 const deadline = Date.now() + 5_000;
72 for (;;) {
73 try {
74 return readFileSync(path, "utf8");
75 } catch (error) {
76 if (Date.now() >= deadline) {
77 throw error instanceof Error ? error : new Error(String(error));
78 }
79 }
80 await new Promise((resolve) => setTimeout(resolve, 25));
81 }
82 });
83}
84
85it.layer(CursorTextGenerationTestLayer)("CursorTextGenerationLive", (it) => {
86 it.effect("uses ACP model config options instead of raw CLI model ids", () => {
87 const requestLogDir = mkdtempSync(path.join(os.tmpdir(), "t3code-cursor-text-log-"));
88 const requestLogPath = path.join(requestLogDir, "requests.ndjson");
89
90 return withFakeAcpAgent(
91 {
92 T3_ACP_REQUEST_LOG_PATH: requestLogPath,
93 T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({
94 subject: "Add generated commit message",
95 body: "- verify cursor acp model config path",
96 }),
97 },
98 (textGeneration) =>
99 Effect.gen(function* () {
100 const generated = yield* textGeneration.generateCommitMessage({
101 cwd: process.cwd(),
102 branch: "feature/cursor-text-generation",
103 stagedSummary: "M apps/server/src/git/Layers/CursorTextGeneration.ts",
104 stagedPatch:
105 "diff --git a/apps/server/src/git/Layers/CursorTextGeneration.ts b/apps/server/src/git/Layers/CursorTextGeneration.ts",
106 modelSelection: {
107 ...createModelSelection(ProviderInstanceId.make("cursor"), "gpt-5.4", [
108 { id: "reasoning", value: "xhigh" },
109 { id: "fastMode", value: true },
110 { id: "contextWindow", value: "1m" },
111 ]),
112 },
113 });
114
115 expect(generated.subject).toBe("Add generated commit message");
116 expect(generated.body).toBe("- verify cursor acp model config path");
117
118 const requests = readFileSync(requestLogPath, "utf8")
119 .trim()
120 .split("\n")
121 .filter((line) => line.length > 0)
122 .map(
123 (line) => JSON.parse(line) as { method?: string; params?: Record<string, unknown> },
124 );
125
126 expect(
127 requests.find((request) => request.method === "initialize")?.params?.clientCapabilities,
128 ).toMatchObject({
129 _meta: {
130 parameterizedModelPicker: true,
131 },
132 });
133 expect(
134 requests.some(
135 (request) =>
136 request.method === "session/set_config_option" &&
137 request.params?.configId === "model" &&
138 request.params?.value === "gpt-5.4",
139 ),
140 ).toBe(true);
141 expect(
142 requests.some(
143 (request) =>
144 request.method === "session/set_config_option" &&
145 request.params?.configId === "reasoning" &&
146 request.params?.value === "extra-high",
147 ),
148 ).toBe(true);
149 expect(
150 requests.some(
151 (request) =>
152 request.method === "session/set_config_option" &&
153 request.params?.configId === "context" &&
154 request.params?.value === "1m",
155 ),
156 ).toBe(true);
157 expect(
158 requests.some(
159 (request) =>
160 request.method === "session/set_config_option" &&
161 request.params?.configId === "fast" &&
162 request.params?.value === "true",
163 ),
164 ).toBe(true);
165 expect(
166 requests.find((request) => request.method === "session/prompt")?.params?.prompt,
167 ).toEqual(
168 expect.arrayContaining([
169 expect.objectContaining({
170 type: "text",
171 text: expect.stringContaining("Staged patch:"),
172 }),
173 ]),
174 );
175
176 rmSync(requestLogDir, { recursive: true, force: true });
177 }),
178 );
179 });
180
181 it.effect("accepts json objects with extra assistant text around them", () =>
182 withFakeAcpAgent(
183 {
184 T3_ACP_PROMPT_RESPONSE_TEXT:
185 'Sure, here is the JSON:\n```json\n{\n "subject": "Update README dummy comment with attribution and date",\n "body": ""\n}\n```\nDone.',
186 },
187 (textGeneration) =>
188 Effect.gen(function* () {
189 const generated = yield* textGeneration.generateCommitMessage({
190 cwd: process.cwd(),
191 branch: "feature/cursor-noisy-json",
192 stagedSummary: "M README.md",
193 stagedPatch: "diff --git a/README.md b/README.md",
194 modelSelection: {
195 instanceId: ProviderInstanceId.make("cursor"),
196 model: "composer-2",
197 },
198 });
199
200 expect(generated.subject).toBe("Update README dummy comment with attribution and date");
201 expect(generated.body).toBe("");
202 }),
203 ),
204 );
205
206 it.effect("generates thread titles through Cursor ACP text generation", () =>
207 withFakeAcpAgent(
208 {
209 T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({
210 title: '"Trim reconnect spinner status after resume."',
211 }),
212 },
213 (textGeneration) =>
214 Effect.gen(function* () {
215 const generated = yield* textGeneration.generateThreadTitle({
216 cwd: process.cwd(),
217 message: "Fix the reconnect spinner after a resumed session.",
218 modelSelection: {
219 instanceId: ProviderInstanceId.make("cursor"),
220 model: "composer-2",
221 },
222 });
223
224 expect(generated.title).toBe("Trim reconnect spinner status after resume.");
225 }),
226 ),
227 );
228
229 it.effect("closes the ACP child process after text generation completes", () => {
230 const exitLogDir = mkdtempSync(path.join(os.tmpdir(), "t3code-cursor-text-exit-log-"));
231 const exitLogPath = path.join(exitLogDir, "exit.log");
232
233 return withFakeAcpAgent(
234 {
235 T3_ACP_EXIT_LOG_PATH: exitLogPath,
236 T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({
237 subject: "Close runtime after generation",
238 body: "",
239 }),
240 },
241 (textGeneration) =>
242 Effect.gen(function* () {
243 const generated = yield* textGeneration.generateCommitMessage({
244 cwd: process.cwd(),
245 branch: "feature/cursor-runtime-close",
246 stagedSummary: "M apps/server/src/git/Layers/CursorTextGeneration.ts",
247 stagedPatch:
248 "diff --git a/apps/server/src/git/Layers/CursorTextGeneration.ts b/apps/server/src/git/Layers/CursorTextGeneration.ts",
249 modelSelection: {
250 instanceId: ProviderInstanceId.make("cursor"),
251 model: "composer-2",
252 },
253 });
254
255 expect(generated.subject).toBe("Close runtime after generation");
256
257 const exitLog = yield* waitForFileContent(exitLogPath);
258 expect(exitLog).toContain("exit:0");
259
260 rmSync(exitLogDir, { recursive: true, force: true });
261 }),
262 );
263 });
264});
apps/server/src/git/Layers/CursorTextGeneration.ts deleted-283
......@@ -1,283 +0,0 @@
1import { Effect, Option, Ref, Schema } from "effect";
2import { ChildProcessSpawner } from "effect/unstable/process";
3
4import { type CursorSettings, type ModelSelection } from "@t3tools/contracts";
5import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
6
7import { TextGenerationError } from "@t3tools/contracts";
8import {
9 type ThreadTitleGenerationResult,
10 type TextGenerationShape,
11} from "../Services/TextGeneration.ts";
12import {
13 buildBranchNamePrompt,
14 buildCommitMessagePrompt,
15 buildPrContentPrompt,
16 buildThreadTitlePrompt,
17} from "../Prompts.ts";
18import {
19 extractJsonObject,
20 sanitizeCommitSubject,
21 sanitizePrTitle,
22 sanitizeThreadTitle,
23} from "../Utils.ts";
24import {
25 applyCursorAcpModelSelection,
26 makeCursorAcpRuntime,
27} from "../../provider/acp/CursorAcpSupport.ts";
28
29const CURSOR_TIMEOUT_MS = 180_000;
30
31function mapCursorAcpError(
32 operation:
33 | "generateCommitMessage"
34 | "generatePrContent"
35 | "generateBranchName"
36 | "generateThreadTitle",
37 detail: string,
38 cause: unknown,
39): TextGenerationError {
40 return new TextGenerationError({
41 operation,
42 detail,
43 ...(cause !== undefined ? { cause } : {}),
44 });
45}
46
47function isTextGenerationError(error: unknown): error is TextGenerationError {
48 return (
49 typeof error === "object" &&
50 error !== null &&
51 "_tag" in error &&
52 error._tag === "TextGenerationError"
53 );
54}
55
56/**
57 * Build a Cursor text-generation closure bound to a specific `CursorSettings`
58 * payload. See `makeCodexAdapter` for the overall per-instance rationale.
59 */
60export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(function* (
61 cursorSettings: CursorSettings,
62 environment: NodeJS.ProcessEnv = process.env,
63) {
64 const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
65
66 const runCursorJson = <S extends Schema.Top>({
67 operation,
68 cwd,
69 prompt,
70 outputSchemaJson,
71 modelSelection,
72 }: {
73 operation:
74 | "generateCommitMessage"
75 | "generatePrContent"
76 | "generateBranchName"
77 | "generateThreadTitle";
78 cwd: string;
79 prompt: string;
80 outputSchemaJson: S;
81 modelSelection: ModelSelection;
82 }): Effect.Effect<S["Type"], TextGenerationError, S["DecodingServices"]> =>
83 Effect.gen(function* () {
84 const outputRef = yield* Ref.make("");
85 const runtime = yield* makeCursorAcpRuntime({
86 cursorSettings,
87 environment,
88 childProcessSpawner: commandSpawner,
89 cwd,
90 clientInfo: { name: "t3-code-git-text", version: "0.0.0" },
91 });
92
93 yield* runtime.handleSessionUpdate((notification) => {
94 const update = notification.update;
95 if (update.sessionUpdate !== "agent_message_chunk") {
96 return Effect.void;
97 }
98 const content = update.content;
99 if (content.type !== "text") {
100 return Effect.void;
101 }
102 return Ref.update(outputRef, (current) => current + content.text);
103 });
104
105 const promptResult = yield* Effect.gen(function* () {
106 yield* runtime.start();
107 yield* Effect.ignore(runtime.setMode("ask"));
108 yield* applyCursorAcpModelSelection({
109 runtime,
110 model: modelSelection.model,
111 selections: modelSelection.options,
112 mapError: ({ cause, configId, step }) =>
113 mapCursorAcpError(
114 operation,
115 step === "set-config-option"
116 ? `Failed to set Cursor ACP config option "${configId}" for text generation.`
117 : "Failed to set Cursor ACP base model for text generation.",
118 cause,
119 ),
120 });
121
122 return yield* runtime.prompt({
123 prompt: [{ type: "text", text: prompt }],
124 });
125 }).pipe(
126 Effect.timeoutOption(CURSOR_TIMEOUT_MS),
127 Effect.flatMap(
128 Option.match({
129 onNone: () =>
130 Effect.fail(
131 new TextGenerationError({
132 operation,
133 detail: "Cursor Agent request timed out.",
134 }),
135 ),
136 onSome: (value) => Effect.succeed(value),
137 }),
138 ),
139 Effect.mapError((cause) =>
140 isTextGenerationError(cause)
141 ? cause
142 : mapCursorAcpError(operation, "Cursor ACP request failed.", cause),
143 ),
144 );
145
146 const rawResult = (yield* Ref.get(outputRef)).trim();
147 if (!rawResult) {
148 return yield* new TextGenerationError({
149 operation,
150 detail:
151 promptResult.stopReason === "cancelled"
152 ? "Cursor ACP request was cancelled."
153 : "Cursor Agent returned empty output.",
154 });
155 }
156
157 return yield* Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson))(
158 extractJsonObject(rawResult),
159 ).pipe(
160 Effect.catchTag("SchemaError", (cause) =>
161 Effect.fail(
162 new TextGenerationError({
163 operation,
164 detail: "Cursor Agent returned invalid structured output.",
165 cause,
166 }),
167 ),
168 ),
169 );
170 }).pipe(
171 Effect.mapError((cause) =>
172 isTextGenerationError(cause)
173 ? cause
174 : mapCursorAcpError(operation, "Cursor ACP text generation failed.", cause),
175 ),
176 Effect.scoped,
177 );
178
179 const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn(
180 "CursorTextGeneration.generateCommitMessage",
181 )(function* (input) {
182 const { prompt, outputSchema } = buildCommitMessagePrompt({
183 branch: input.branch,
184 stagedSummary: input.stagedSummary,
185 stagedPatch: input.stagedPatch,
186 includeBranch: input.includeBranch === true,
187 });
188
189 const generated = yield* runCursorJson({
190 operation: "generateCommitMessage",
191 cwd: input.cwd,
192 prompt,
193 outputSchemaJson: outputSchema,
194 modelSelection: input.modelSelection,
195 });
196
197 return {
198 subject: sanitizeCommitSubject(generated.subject),
199 body: generated.body.trim(),
200 ...("branch" in generated && typeof generated.branch === "string"
201 ? { branch: sanitizeFeatureBranchName(generated.branch) }
202 : {}),
203 };
204 });
205
206 const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn(
207 "CursorTextGeneration.generatePrContent",
208 )(function* (input) {
209 const { prompt, outputSchema } = buildPrContentPrompt({
210 baseBranch: input.baseBranch,
211 headBranch: input.headBranch,
212 commitSummary: input.commitSummary,
213 diffSummary: input.diffSummary,
214 diffPatch: input.diffPatch,
215 });
216
217 const generated = yield* runCursorJson({
218 operation: "generatePrContent",
219 cwd: input.cwd,
220 prompt,
221 outputSchemaJson: outputSchema,
222 modelSelection: input.modelSelection,
223 });
224
225 return {
226 title: sanitizePrTitle(generated.title),
227 body: generated.body.trim(),
228 };
229 });
230
231 const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn(
232 "CursorTextGeneration.generateBranchName",
233 )(function* (input) {
234 const { prompt, outputSchema } = buildBranchNamePrompt({
235 message: input.message,
236 attachments: input.attachments,
237 });
238
239 const generated = yield* runCursorJson({
240 operation: "generateBranchName",
241 cwd: input.cwd,
242 prompt,
243 outputSchemaJson: outputSchema,
244 modelSelection: input.modelSelection,
245 });
246
247 return {
248 branch: sanitizeBranchFragment(generated.branch),
249 };
250 });
251
252 const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn(
253 "CursorTextGeneration.generateThreadTitle",
254 )(function* (input) {
255 const { prompt, outputSchema } = buildThreadTitlePrompt({
256 message: input.message,
257 attachments: input.attachments,
258 });
259
260 const generated = yield* runCursorJson({
261 operation: "generateThreadTitle",
262 cwd: input.cwd,
263 prompt,
264 outputSchemaJson: outputSchema,
265 modelSelection: input.modelSelection,
266 });
267
268 return {
269 title: sanitizeThreadTitle(generated.title),
270 } satisfies ThreadTitleGenerationResult;
271 });
272
273 return {
274 generateCommitMessage,
275 generatePrContent,
276 generateBranchName,
277 generateThreadTitle,
278 } satisfies TextGenerationShape;
279});
280
281// NOTE: `CursorTextGenerationLive` (the singleton Layer) has been removed.
282// `makeCursorTextGeneration(cursorConfig)` is now invoked directly by
283// `CursorDriver.create()` so each provider instance owns its own closure.
apps/server/src/git/Layers/GitCore.test.ts deleted-2333
......@@ -1,2333 +0,0 @@
1import { existsSync } from "node:fs";
2import path from "node:path";
3
4import * as NodeServices from "@effect/platform-node/NodeServices";
5import { it } from "@effect/vitest";
6import { Effect, FileSystem, Layer, PlatformError, Scope } from "effect";
7import { describe, expect, vi } from "vitest";
8
9import { GitCoreLive, makeGitCore } from "./GitCore.ts";
10import { GitCore, type GitCoreShape } from "../Services/GitCore.ts";
11import { GitCommandError } from "@t3tools/contracts";
12import { type ProcessRunResult, runProcess } from "../../processRunner.ts";
13import { ServerConfig } from "../../config.ts";
14
15// ── Helpers ──
16
17const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-core-test-" });
18const GitCoreTestLayer = GitCoreLive.pipe(
19 Layer.provide(ServerConfigLayer),
20 Layer.provide(NodeServices.layer),
21);
22const TestLayer = Layer.mergeAll(NodeServices.layer, GitCoreTestLayer);
23
24function makeTmpDir(
25 prefix = "git-test-",
26): Effect.Effect<string, PlatformError.PlatformError, FileSystem.FileSystem | Scope.Scope> {
27 return Effect.gen(function* () {
28 const fileSystem = yield* FileSystem.FileSystem;
29 return yield* fileSystem.makeTempDirectoryScoped({ prefix });
30 });
31}
32
33function writeTextFile(
34 filePath: string,
35 contents: string,
36): Effect.Effect<void, PlatformError.PlatformError, FileSystem.FileSystem> {
37 return Effect.gen(function* () {
38 const fileSystem = yield* FileSystem.FileSystem;
39 yield* fileSystem.writeFileString(filePath, contents);
40 });
41}
42
43function removePath(
44 targetPath: string,
45): Effect.Effect<void, PlatformError.PlatformError, FileSystem.FileSystem> {
46 return Effect.gen(function* () {
47 const fileSystem = yield* FileSystem.FileSystem;
48 yield* fileSystem.remove(targetPath, { recursive: true, force: true });
49 });
50}
51
52function makeDirectory(
53 dirPath: string,
54): Effect.Effect<void, PlatformError.PlatformError, FileSystem.FileSystem> {
55 return Effect.gen(function* () {
56 const fileSystem = yield* FileSystem.FileSystem;
57 yield* fileSystem.makeDirectory(dirPath, { recursive: true });
58 });
59}
60
61/** Run a raw git command for test setup (not under test). */
62function git(
63 cwd: string,
64 args: ReadonlyArray<string>,
65 env?: NodeJS.ProcessEnv,
66): Effect.Effect<string, GitCommandError, GitCore> {
67 return Effect.gen(function* () {
68 const gitCore = yield* GitCore;
69 const result = yield* gitCore.execute({
70 operation: "GitCore.test.git",
71 cwd,
72 args,
73 ...(env ? { env } : {}),
74 timeoutMs: 10_000,
75 });
76 return result.stdout.trim();
77 });
78}
79
80function configureRemote(
81 cwd: string,
82 remoteName: string,
83 remotePath: string,
84 fetchNamespace: string,
85): Effect.Effect<string, GitCommandError, GitCore> {
86 return Effect.gen(function* () {
87 yield* git(cwd, ["config", `remote.${remoteName}.url`, remotePath]);
88 return yield* git(cwd, [
89 "config",
90 "--replace-all",
91 `remote.${remoteName}.fetch`,
92 `+refs/heads/*:refs/remotes/${fetchNamespace}/*`,
93 ]);
94 });
95}
96
97function runShellCommand(input: {
98 command: string;
99 cwd: string;
100 timeoutMs?: number;
101 maxOutputBytes?: number;
102}): Effect.Effect<ProcessRunResult, Error> {
103 return Effect.promise(() => {
104 const shellPath =
105 process.platform === "win32"
106 ? (process.env.ComSpec ?? "cmd.exe")
107 : (process.env.SHELL ?? "/bin/sh");
108
109 const args =
110 process.platform === "win32" ? ["/d", "/s", "/c", input.command] : ["-lc", input.command];
111
112 return runProcess(shellPath, args, {
113 cwd: input.cwd,
114 timeoutMs: input.timeoutMs ?? 30_000,
115 allowNonZeroExit: true,
116 maxBufferBytes: input.maxOutputBytes ?? 1_000_000,
117 outputMode: "truncate",
118 });
119 });
120}
121
122const makeIsolatedGitCore = (executeOverride: GitCoreShape["execute"]) =>
123 makeGitCore({ executeOverride }).pipe(
124 Effect.provide(Layer.provideMerge(ServerConfigLayer, NodeServices.layer)),
125 );
126
127/** Create a repo with an initial commit so branches work. */
128function initRepoWithCommit(
129 cwd: string,
130): Effect.Effect<
131 { initialBranch: string },
132 GitCommandError | PlatformError.PlatformError,
133 GitCore | FileSystem.FileSystem
134> {
135 return Effect.gen(function* () {
136 const core = yield* GitCore;
137 yield* core.initRepo({ cwd });
138 yield* git(cwd, ["config", "user.email", "test@test.com"]);
139 yield* git(cwd, ["config", "user.name", "Test"]);
140 yield* writeTextFile(path.join(cwd, "README.md"), "# test\n");
141 yield* git(cwd, ["add", "."]);
142 yield* git(cwd, ["commit", "-m", "initial commit"]);
143 const initialBranch = yield* git(cwd, ["branch", "--show-current"]);
144 return { initialBranch };
145 });
146}
147
148function commitWithDate(
149 cwd: string,
150 fileName: string,
151 fileContents: string,
152 dateIsoString: string,
153 message: string,
154): Effect.Effect<
155 void,
156 GitCommandError | PlatformError.PlatformError,
157 GitCore | FileSystem.FileSystem
158> {
159 return Effect.gen(function* () {
160 yield* writeTextFile(path.join(cwd, fileName), fileContents);
161 yield* git(cwd, ["add", fileName]);
162 yield* git(cwd, ["commit", "-m", message], {
163 ...process.env,
164 GIT_AUTHOR_DATE: dateIsoString,
165 GIT_COMMITTER_DATE: dateIsoString,
166 });
167 });
168}
169
170function buildLargeText(lineCount = 20_000): string {
171 return Array.from({ length: lineCount }, (_, index) => `line ${String(index).padStart(5, "0")}`)
172 .join("\n")
173 .concat("\n");
174}
175
176function splitNullSeparatedPaths(input: string): string[] {
177 return input
178 .split("\0")
179 .map((value) => value.trim())
180 .filter((value) => value.length > 0);
181}
182
183// ── Tests ──
184
185it.layer(TestLayer)("git integration", (it) => {
186 describe("shell process execution", () => {
187 it.effect("caps captured output when maxOutputBytes is exceeded", () =>
188 Effect.gen(function* () {
189 const result = yield* runShellCommand({
190 command: `node -e "process.stdout.write('x'.repeat(2000))"`,
191 cwd: process.cwd(),
192 timeoutMs: 10_000,
193 maxOutputBytes: 128,
194 });
195
196 expect(result.code).toBe(0);
197 expect(result.stdout.length).toBeLessThanOrEqual(128);
198 expect(result.stdoutTruncated || result.stderrTruncated).toBe(true);
199 }),
200 );
201 });
202
203 // ── initGitRepo ──
204
205 describe("initGitRepo", () => {
206 it.effect("creates a valid git repo", () =>
207 Effect.gen(function* () {
208 const tmp = yield* makeTmpDir();
209 yield* (yield* GitCore).initRepo({ cwd: tmp });
210 expect(existsSync(path.join(tmp, ".git"))).toBe(true);
211 }),
212 );
213
214 it.effect("listGitBranches reports isRepo: true after init + commit", () =>
215 Effect.gen(function* () {
216 const tmp = yield* makeTmpDir();
217 yield* initRepoWithCommit(tmp);
218 const result = yield* (yield* GitCore).listBranches({ cwd: tmp });
219 expect(result.isRepo).toBe(true);
220 expect(result.hasOriginRemote).toBe(false);
221 expect(result.branches.length).toBeGreaterThanOrEqual(1);
222 }),
223 );
224 });
225
226 describe("workspace helpers", () => {
227 it.effect("filterIgnoredPaths chunks large path lists and preserves kept paths", () =>
228 Effect.gen(function* () {
229 const cwd = "/virtual/repo";
230 const relativePaths = Array.from({ length: 340 }, (_, index) => {
231 const prefix = index % 3 === 0 ? "ignored" : "kept";
232 return `${prefix}/segment-${String(index).padStart(4, "0")}/${"x".repeat(900)}.ts`;
233 });
234 const expectedPaths = relativePaths.filter(
235 (relativePath) => !relativePath.startsWith("ignored/"),
236 );
237
238 const seenChunks: string[][] = [];
239 const core = yield* makeIsolatedGitCore((input) => {
240 if (
241 input.args.join(" ") !==
242 "-c core.fsmonitor=false -c core.untrackedCache=false check-ignore --no-index -z --stdin"
243 ) {
244 return Effect.fail(
245 new GitCommandError({
246 operation: input.operation,
247 command: `git ${input.args.join(" ")}`,
248 cwd: input.cwd,
249 detail: "unexpected git command in chunking test",
250 }),
251 );
252 }
253
254 const chunkPaths = splitNullSeparatedPaths(input.stdin ?? "");
255 seenChunks.push(chunkPaths);
256 const ignoredPaths = chunkPaths.filter((relativePath) =>
257 relativePath.startsWith("ignored/"),
258 );
259
260 return Effect.succeed({
261 code: ignoredPaths.length > 0 ? 0 : 1,
262 stdout: ignoredPaths.length > 0 ? `${ignoredPaths.join("\0")}\0` : "",
263 stderr: "",
264 stdoutTruncated: false,
265 stderrTruncated: false,
266 });
267 });
268
269 const result = yield* core.filterIgnoredPaths(cwd, relativePaths);
270
271 expect(seenChunks.length).toBeGreaterThan(1);
272 expect(seenChunks.flat()).toEqual(relativePaths);
273 expect(result).toEqual(expectedPaths);
274 }),
275 );
276
277 it.effect("listWorkspaceFiles disables fsmonitor and untracked cache helpers", () =>
278 Effect.gen(function* () {
279 const core = yield* makeIsolatedGitCore((input) => {
280 expect(input.args).toEqual([
281 "-c",
282 "core.fsmonitor=false",
283 "-c",
284 "core.untrackedCache=false",
285 "ls-files",
286 "--cached",
287 "--others",
288 "--exclude-standard",
289 "-z",
290 ]);
291 return Effect.succeed({
292 code: 0,
293 stdout: "src/index.ts\0README.md\0",
294 stderr: "",
295 stdoutTruncated: false,
296 stderrTruncated: false,
297 });
298 });
299
300 const result = yield* core.listWorkspaceFiles("/virtual/repo");
301 expect(result.paths).toEqual(["src/index.ts", "README.md"]);
302 expect(result.truncated).toBe(false);
303 }),
304 );
305 });
306
307 // ── listGitBranches ──
308
309 describe("listGitBranches", () => {
310 it.effect("returns isRepo: false for non-git directory", () =>
311 Effect.gen(function* () {
312 const tmp = yield* makeTmpDir();
313 const result = yield* (yield* GitCore).listBranches({ cwd: tmp });
314 expect(result.isRepo).toBe(false);
315 expect(result.hasOriginRemote).toBe(false);
316 expect(result.branches).toEqual([]);
317 }),
318 );
319
320 it.effect("returns isRepo: false for deleted directories", () =>
321 Effect.gen(function* () {
322 const tmp = yield* makeTmpDir();
323 const deletedDir = path.join(tmp, "deleted-repo");
324 yield* makeDirectory(deletedDir);
325 yield* removePath(deletedDir);
326
327 const result = yield* (yield* GitCore).listBranches({ cwd: deletedDir });
328
329 expect(result.isRepo).toBe(false);
330 expect(result.hasOriginRemote).toBe(false);
331 expect(result.branches).toEqual([]);
332 }),
333 );
334
335 it.effect("returns the current branch with current: true", () =>
336 Effect.gen(function* () {
337 const tmp = yield* makeTmpDir();
338 yield* initRepoWithCommit(tmp);
339 const result = yield* (yield* GitCore).listBranches({ cwd: tmp });
340 const current = result.branches.find((b) => b.current);
341 expect(current).toBeDefined();
342 expect(current!.current).toBe(true);
343 }),
344 );
345
346 it.effect("does not include detached HEAD pseudo-refs as branches", () =>
347 Effect.gen(function* () {
348 const tmp = yield* makeTmpDir();
349 yield* initRepoWithCommit(tmp);
350 yield* git(tmp, ["checkout", "--detach", "HEAD"]);
351
352 const result = yield* (yield* GitCore).listBranches({ cwd: tmp });
353 expect(result.branches.some((branch) => branch.name.startsWith("("))).toBe(false);
354 expect(result.branches.some((branch) => branch.current)).toBe(false);
355 }),
356 );
357
358 it.effect("keeps current branch first and sorts the remaining branches by recency", () =>
359 Effect.gen(function* () {
360 const tmp = yield* makeTmpDir();
361 yield* initRepoWithCommit(tmp);
362 const initialBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find(
363 (branch) => branch.current,
364 )!.name;
365
366 yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "older-branch" });
367 yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "older-branch" });
368 yield* commitWithDate(
369 tmp,
370 "older.txt",
371 "older branch change\n",
372 "Thu, 1 Jan 2037 00:00:00 +0000",
373 "older branch change",
374 );
375
376 yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: initialBranch });
377 yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "newer-branch" });
378 yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "newer-branch" });
379 yield* commitWithDate(
380 tmp,
381 "newer.txt",
382 "newer branch change\n",
383 "Fri, 1 Jan 2038 00:00:00 +0000",
384 "newer branch change",
385 );
386
387 // Switch away to show current branch is pinned, then remaining branches are recency-sorted.
388 yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "older-branch" });
389
390 const result = yield* (yield* GitCore).listBranches({ cwd: tmp });
391 expect(result.branches[0]!.name).toBe("older-branch");
392 expect(result.branches[1]!.name).toBe("newer-branch");
393 }),
394 );
395
396 it.effect("keeps default branch right after current branch", () =>
397 Effect.gen(function* () {
398 const tmp = yield* makeTmpDir();
399 const remote = yield* makeTmpDir();
400 yield* initRepoWithCommit(tmp);
401 const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find(
402 (branch) => branch.current,
403 )!.name;
404
405 yield* git(remote, ["init", "--bare"]);
406 yield* git(tmp, ["remote", "add", "origin", remote]);
407 yield* git(tmp, ["push", "-u", "origin", defaultBranch]);
408 yield* git(tmp, ["remote", "set-head", "origin", defaultBranch]);
409
410 yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "current-branch" });
411 yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "current-branch" });
412 yield* commitWithDate(
413 tmp,
414 "current.txt",
415 "current change\n",
416 "Thu, 1 Jan 2037 00:00:00 +0000",
417 "current change",
418 );
419
420 yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: defaultBranch });
421 yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "newer-branch" });
422 yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "newer-branch" });
423 yield* commitWithDate(
424 tmp,
425 "newer.txt",
426 "newer change\n",
427 "Fri, 1 Jan 2038 00:00:00 +0000",
428 "newer change",
429 );
430
431 yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "current-branch" });
432
433 const result = yield* (yield* GitCore).listBranches({ cwd: tmp });
434 expect(result.branches[0]!.name).toBe("current-branch");
435 expect(result.branches[1]!.name).toBe(defaultBranch);
436 expect(result.branches[2]!.name).toBe("newer-branch");
437 }),
438 );
439
440 it.effect("lists multiple branches after creating them", () =>
441 Effect.gen(function* () {
442 const tmp = yield* makeTmpDir();
443 yield* initRepoWithCommit(tmp);
444 yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature-a" });
445 yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature-b" });
446
447 const result = yield* (yield* GitCore).listBranches({ cwd: tmp });
448 const names = result.branches.map((b) => b.name);
449 expect(names).toContain("feature-a");
450 expect(names).toContain("feature-b");
451 }),
452 );
453
454 it.effect("paginates branch results and returns paging metadata", () =>
455 Effect.gen(function* () {
456 const tmp = yield* makeTmpDir();
457 const { initialBranch } = yield* initRepoWithCommit(tmp);
458 yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature-a" });
459 yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature-b" });
460 yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature-c" });
461
462 const firstPage = yield* (yield* GitCore).listBranches({ cwd: tmp, limit: 2 });
463 expect(firstPage.totalCount).toBe(4);
464 expect(firstPage.nextCursor).toBe(2);
465 expect(firstPage.branches.map((branch) => branch.name)).toEqual([
466 initialBranch,
467 "feature-a",
468 ]);
469
470 const secondPage = yield* (yield* GitCore).listBranches({
471 cwd: tmp,
472 cursor: firstPage.nextCursor ?? 0,
473 limit: 2,
474 });
475 expect(secondPage.totalCount).toBe(4);
476 expect(secondPage.nextCursor).toBeNull();
477 expect(secondPage.branches.map((branch) => branch.name)).toEqual([
478 "feature-b",
479 "feature-c",
480 ]);
481 }),
482 );
483
484 it.effect("parses separate branch names when column.ui is always enabled", () =>
485 Effect.gen(function* () {
486 const tmp = yield* makeTmpDir();
487 const { initialBranch } = yield* initRepoWithCommit(tmp);
488 const createdBranchNames = [
489 "go-bin",
490 "copilot/rewrite-cli-in-go",
491 "copilot/rewrite-cli-in-rust",
492 ] as const;
493 for (const branchName of createdBranchNames) {
494 yield* (yield* GitCore).createBranch({ cwd: tmp, branch: branchName });
495 }
496 yield* git(tmp, ["config", "column.ui", "always"]);
497
498 const rawBranchOutput = yield* git(tmp, ["branch", "--no-color"], {
499 ...process.env,
500 COLUMNS: "120",
501 });
502 expect(
503 rawBranchOutput
504 .split("\n")
505 .some(
506 (line) =>
507 createdBranchNames.filter((branchName) => line.includes(branchName)).length >= 2,
508 ),
509 ).toBe(true);
510
511 const realGitCore = yield* GitCore;
512 const core = yield* makeIsolatedGitCore((input) =>
513 realGitCore.execute(
514 input.args[0] === "branch"
515 ? {
516 ...input,
517 env: { ...input.env, COLUMNS: "120" },
518 }
519 : input,
520 ),
521 );
522
523 const result = yield* core.listBranches({ cwd: tmp });
524 const localBranchNames = result.branches
525 .filter((branch) => !branch.isRemote)
526 .map((branch) => branch.name);
527
528 expect(localBranchNames).toHaveLength(4);
529 expect(localBranchNames).toEqual(
530 expect.arrayContaining([initialBranch, ...createdBranchNames]),
531 );
532 expect(
533 localBranchNames.some(
534 (branchName) =>
535 createdBranchNames.filter((createdBranch) => branchName.includes(createdBranch))
536 .length >= 2,
537 ),
538 ).toBe(false);
539 }),
540 );
541
542 it.effect("isDefault is false when no remote exists", () =>
543 Effect.gen(function* () {
544 const tmp = yield* makeTmpDir();
545 yield* initRepoWithCommit(tmp);
546 const result = yield* (yield* GitCore).listBranches({ cwd: tmp });
547 expect(result.branches.every((b) => b.isDefault === false)).toBe(true);
548 }),
549 );
550
551 it.effect("lists local branches first and remote branches last", () =>
552 Effect.gen(function* () {
553 const remote = yield* makeTmpDir();
554 const tmp = yield* makeTmpDir();
555
556 yield* git(remote, ["init", "--bare"]);
557 yield* initRepoWithCommit(tmp);
558 const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find(
559 (branch) => branch.current,
560 )!.name;
561
562 yield* git(tmp, ["remote", "add", "origin", remote]);
563 yield* git(tmp, ["push", "-u", "origin", defaultBranch]);
564
565 yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature/local-only" });
566
567 const remoteOnlyBranch = "feature/remote-only";
568 yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: defaultBranch });
569 yield* git(tmp, ["checkout", "-b", remoteOnlyBranch]);
570 yield* git(tmp, ["push", "-u", "origin", remoteOnlyBranch]);
571 yield* git(tmp, ["checkout", defaultBranch]);
572 yield* git(tmp, ["branch", "-D", remoteOnlyBranch]);
573
574 const result = yield* (yield* GitCore).listBranches({ cwd: tmp });
575 const firstRemoteIndex = result.branches.findIndex((branch) => branch.isRemote);
576
577 expect(result.hasOriginRemote).toBe(true);
578 expect(firstRemoteIndex).toBeGreaterThan(0);
579 expect(result.branches.slice(0, firstRemoteIndex).every((branch) => !branch.isRemote)).toBe(
580 true,
581 );
582 expect(result.branches.slice(firstRemoteIndex).every((branch) => branch.isRemote)).toBe(
583 true,
584 );
585 expect(
586 result.branches.some(
587 (branch) => branch.name === "feature/local-only" && !branch.isRemote,
588 ),
589 ).toBe(true);
590 expect(
591 result.branches.some(
592 (branch) => branch.name === "origin/feature/remote-only" && branch.isRemote,
593 ),
594 ).toBe(true);
595 }),
596 );
597
598 it.effect("includes remoteName metadata for remotes with slash in the name", () =>
599 Effect.gen(function* () {
600 const remote = yield* makeTmpDir();
601 const tmp = yield* makeTmpDir();
602 const remoteName = "my-org/upstream";
603
604 yield* git(remote, ["init", "--bare"]);
605 yield* initRepoWithCommit(tmp);
606 const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find(
607 (branch) => branch.current,
608 )!.name;
609
610 yield* git(tmp, ["remote", "add", remoteName, remote]);
611 yield* git(tmp, ["push", "-u", remoteName, defaultBranch]);
612
613 const remoteOnlyBranch = "feature/remote-with-remote-name";
614 yield* git(tmp, ["checkout", "-b", remoteOnlyBranch]);
615 yield* git(tmp, ["push", "-u", remoteName, remoteOnlyBranch]);
616 yield* git(tmp, ["checkout", defaultBranch]);
617 yield* git(tmp, ["branch", "-D", remoteOnlyBranch]);
618
619 const result = yield* (yield* GitCore).listBranches({ cwd: tmp });
620 const remoteBranch = result.branches.find(
621 (branch) => branch.name === `${remoteName}/${remoteOnlyBranch}`,
622 );
623
624 expect(remoteBranch).toBeDefined();
625 expect(remoteBranch?.isRemote).toBe(true);
626 expect(remoteBranch?.remoteName).toBe(remoteName);
627 }),
628 );
629
630 it.effect(
631 "filters branch queries before pagination and dedupes origin refs with local matches",
632 () =>
633 Effect.gen(function* () {
634 const remote = yield* makeTmpDir();
635 const tmp = yield* makeTmpDir();
636
637 yield* git(remote, ["init", "--bare"]);
638 const { initialBranch } = yield* initRepoWithCommit(tmp);
639 yield* git(tmp, ["remote", "add", "origin", remote]);
640 yield* git(tmp, ["push", "-u", "origin", initialBranch]);
641
642 yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature/demo" });
643 yield* git(tmp, ["push", "-u", "origin", "feature/demo"]);
644
645 yield* git(tmp, ["checkout", "-b", "feature/remote-only"]);
646 yield* git(tmp, ["push", "-u", "origin", "feature/remote-only"]);
647 yield* git(tmp, ["checkout", initialBranch]);
648 yield* git(tmp, ["branch", "-D", "feature/remote-only"]);
649
650 const result = yield* (yield* GitCore).listBranches({
651 cwd: tmp,
652 query: "feature/",
653 limit: 10,
654 });
655
656 expect(result.totalCount).toBe(2);
657 expect(result.nextCursor).toBeNull();
658 expect(result.branches.map((branch) => branch.name)).toEqual([
659 "feature/demo",
660 "origin/feature/remote-only",
661 ]);
662 }),
663 );
664 });
665
666 // ── checkoutGitBranch ──
667
668 describe("checkoutGitBranch", () => {
669 it.effect("checks out an existing branch", () =>
670 Effect.gen(function* () {
671 const tmp = yield* makeTmpDir();
672 yield* initRepoWithCommit(tmp);
673 yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature" });
674
675 yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "feature" });
676
677 const result = yield* (yield* GitCore).listBranches({ cwd: tmp });
678 const current = result.branches.find((b) => b.current);
679 expect(current!.name).toBe("feature");
680 }),
681 );
682
683 it.effect("refreshes upstream behind count after checkout when remote branch advanced", () =>
684 Effect.gen(function* () {
685 const context = yield* Effect.context<never>();
686 const runPromise = Effect.runPromiseWith(context);
687
688 const remote = yield* makeTmpDir();
689 const source = yield* makeTmpDir();
690 const clone = yield* makeTmpDir();
691 yield* git(remote, ["init", "--bare"]);
692
693 yield* initRepoWithCommit(source);
694 const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find(
695 (branch) => branch.current,
696 )!.name;
697 yield* git(source, ["remote", "add", "origin", remote]);
698 yield* git(source, ["push", "-u", "origin", defaultBranch]);
699
700 const featureBranch = "feature-behind";
701 yield* (yield* GitCore).createBranch({ cwd: source, branch: featureBranch });
702 yield* (yield* GitCore).checkoutBranch({ cwd: source, branch: featureBranch });
703 yield* writeTextFile(path.join(source, "feature.txt"), "feature base\n");
704 yield* git(source, ["add", "feature.txt"]);
705 yield* git(source, ["commit", "-m", "feature base"]);
706 yield* git(source, ["push", "-u", "origin", featureBranch]);
707 yield* (yield* GitCore).checkoutBranch({ cwd: source, branch: defaultBranch });
708
709 yield* git(clone, ["clone", remote, "."]);
710 yield* git(clone, ["config", "user.email", "test@test.com"]);
711 yield* git(clone, ["config", "user.name", "Test"]);
712 yield* git(clone, ["checkout", "-b", featureBranch, "--track", `origin/${featureBranch}`]);
713 yield* writeTextFile(path.join(clone, "feature.txt"), "feature from remote\n");
714 yield* git(clone, ["add", "feature.txt"]);
715 yield* git(clone, ["commit", "-m", "remote feature update"]);
716 yield* git(clone, ["push", "origin", featureBranch]);
717
718 yield* (yield* GitCore).checkoutBranch({ cwd: source, branch: featureBranch });
719 const core = yield* GitCore;
720 yield* Effect.promise(() =>
721 vi.waitFor(
722 async () => {
723 const details = await runPromise(core.statusDetails(source));
724 expect(details.branch).toBe(featureBranch);
725 expect(details.aheadCount).toBe(0);
726 expect(details.behindCount).toBe(1);
727 },
728 {
729 timeout: 10_000,
730 interval: 100,
731 },
732 ),
733 );
734 }),
735 );
736
737 it.effect("statusDetails remains successful when upstream refresh fails after checkout", () =>
738 Effect.gen(function* () {
739 const remote = yield* makeTmpDir();
740 const source = yield* makeTmpDir();
741 yield* git(remote, ["init", "--bare"]);
742
743 yield* initRepoWithCommit(source);
744 const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find(
745 (branch) => branch.current,
746 )!.name;
747 yield* git(source, ["remote", "add", "origin", remote]);
748 yield* git(source, ["push", "-u", "origin", defaultBranch]);
749
750 const featureBranch = "feature-refresh-failure";
751 yield* git(source, ["branch", featureBranch]);
752 yield* git(source, ["checkout", featureBranch]);
753 yield* writeTextFile(path.join(source, "feature.txt"), "feature base\n");
754 yield* git(source, ["add", "feature.txt"]);
755 yield* git(source, ["commit", "-m", "feature base"]);
756 yield* git(source, ["push", "-u", "origin", featureBranch]);
757 yield* git(source, ["checkout", defaultBranch]);
758
759 const realGitCore = yield* GitCore;
760 let refreshFetchAttempts = 0;
761 const core = yield* makeIsolatedGitCore((input) => {
762 if (input.args[0] === "--git-dir" && input.args[2] === "fetch") {
763 refreshFetchAttempts += 1;
764 return Effect.fail(
765 new GitCommandError({
766 operation: "git.test.refreshFailure",
767 command: `git ${input.args.join(" ")}`,
768 cwd: input.cwd,
769 detail: "simulated fetch timeout",
770 }),
771 );
772 }
773 return realGitCore.execute(input);
774 });
775 yield* core.checkoutBranch({ cwd: source, branch: featureBranch });
776 const status = yield* core.statusDetails(source);
777 expect(refreshFetchAttempts).toBe(1);
778 expect(status.branch).toBe(featureBranch);
779 expect(status.upstreamRef).toBe(`origin/${featureBranch}`);
780 expect(yield* git(source, ["branch", "--show-current"])).toBe(featureBranch);
781 }),
782 );
783
784 it.effect("defers upstream refresh until statusDetails is requested", () =>
785 Effect.gen(function* () {
786 const remote = yield* makeTmpDir();
787 const source = yield* makeTmpDir();
788 yield* git(remote, ["init", "--bare"]);
789
790 yield* initRepoWithCommit(source);
791 const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find(
792 (branch) => branch.current,
793 )!.name;
794 yield* git(source, ["remote", "add", "origin", remote]);
795 yield* git(source, ["push", "-u", "origin", defaultBranch]);
796
797 const featureBranch = "feature/scoped-fetch";
798 yield* git(source, ["checkout", "-b", featureBranch]);
799 yield* writeTextFile(path.join(source, "feature.txt"), "feature base\n");
800 yield* git(source, ["add", "feature.txt"]);
801 yield* git(source, ["commit", "-m", "feature base"]);
802 yield* git(source, ["push", "-u", "origin", featureBranch]);
803 yield* git(source, ["checkout", defaultBranch]);
804
805 const realGitCore = yield* GitCore;
806 let refreshFetchAttempts = 0;
807 const core = yield* makeIsolatedGitCore((input) => {
808 if (input.args[0] === "--git-dir" && input.args[2] === "fetch") {
809 refreshFetchAttempts += 1;
810 return Effect.succeed({
811 code: 0,
812 stdout: "",
813 stderr: "",
814 stdoutTruncated: false,
815 stderrTruncated: false,
816 });
817 }
818 return realGitCore.execute(input);
819 });
820 yield* core.checkoutBranch({ cwd: source, branch: featureBranch });
821 yield* Effect.promise(() => new Promise<void>((resolve) => setTimeout(resolve, 50)));
822 expect(refreshFetchAttempts).toBe(0);
823 const status = yield* core.statusDetails(source);
824 expect(status.branch).toBe(featureBranch);
825 expect(refreshFetchAttempts).toBe(1);
826 }),
827 );
828
829 it.effect("coalesces upstream refreshes across sibling worktrees on the same remote", () =>
830 Effect.gen(function* () {
831 const ok = (stdout = "") =>
832 Effect.succeed({
833 code: 0,
834 stdout,
835 stderr: "",
836 stdoutTruncated: false,
837 stderrTruncated: false,
838 });
839
840 let fetchCount = 0;
841 const core = yield* makeIsolatedGitCore((input) => {
842 if (
843 input.args[0] === "rev-parse" &&
844 input.args[1] === "--abbrev-ref" &&
845 input.args[2] === "--symbolic-full-name" &&
846 input.args[3] === "@{upstream}"
847 ) {
848 return ok(
849 input.cwd === "/repo/worktrees/pr-123" ? "origin/feature/pr-123\n" : "origin/main\n",
850 );
851 }
852 if (input.args[0] === "remote") {
853 return ok("origin\n");
854 }
855 if (input.args[0] === "rev-parse" && input.args[1] === "--git-common-dir") {
856 return ok("/repo/.git\n");
857 }
858 if (input.args[0] === "--git-dir" && input.args[2] === "fetch") {
859 fetchCount += 1;
860 expect(input.cwd).toBe("/repo");
861 expect(input.args).toEqual([
862 "--git-dir",
863 "/repo/.git",
864 "fetch",
865 "--quiet",
866 "--no-tags",
867 "origin",
868 ]);
869 return ok();
870 }
871 if (input.operation === "GitCore.statusDetails.status") {
872 return ok(
873 input.cwd === "/repo/worktrees/pr-123"
874 ? "# branch.head feature/pr-123\n# branch.upstream origin/feature/pr-123\n# branch.ab +0 -0\n"
875 : "# branch.head main\n# branch.upstream origin/main\n# branch.ab +0 -0\n",
876 );
877 }
878 if (
879 input.operation === "GitCore.statusDetails.unstagedNumstat" ||
880 input.operation === "GitCore.statusDetails.stagedNumstat"
881 ) {
882 return ok();
883 }
884 if (input.operation === "GitCore.statusDetails.defaultRef") {
885 return ok("refs/remotes/origin/main\n");
886 }
887 return Effect.fail(
888 new GitCommandError({
889 operation: input.operation,
890 command: `git ${input.args.join(" ")}`,
891 cwd: input.cwd,
892 detail: "Unexpected git command in shared refresh cache test.",
893 }),
894 );
895 });
896
897 yield* core.statusDetails("/repo/worktrees/main");
898 yield* core.statusDetails("/repo/worktrees/pr-123");
899 expect(fetchCount).toBe(1);
900 }),
901 );
902
903 it.effect(
904 "briefly backs off failed upstream refreshes across sibling worktrees on one remote",
905 () =>
906 Effect.gen(function* () {
907 const ok = (stdout = "") =>
908 Effect.succeed({
909 code: 0,
910 stdout,
911 stderr: "",
912 stdoutTruncated: false,
913 stderrTruncated: false,
914 });
915
916 let fetchCount = 0;
917 const core = yield* makeIsolatedGitCore((input) => {
918 if (
919 input.args[0] === "rev-parse" &&
920 input.args[1] === "--abbrev-ref" &&
921 input.args[2] === "--symbolic-full-name" &&
922 input.args[3] === "@{upstream}"
923 ) {
924 return ok(
925 input.cwd === "/repo/worktrees/pr-123"
926 ? "origin/feature/pr-123\n"
927 : "origin/main\n",
928 );
929 }
930 if (input.args[0] === "remote") {
931 return ok("origin\n");
932 }
933 if (input.args[0] === "rev-parse" && input.args[1] === "--git-common-dir") {
934 return ok("/repo/.git\n");
935 }
936 if (input.args[0] === "--git-dir" && input.args[2] === "fetch") {
937 fetchCount += 1;
938 return Effect.fail(
939 new GitCommandError({
940 operation: input.operation,
941 command: `git ${input.args.join(" ")}`,
942 cwd: input.cwd,
943 detail: "simulated fetch timeout",
944 }),
945 );
946 }
947 if (input.operation === "GitCore.statusDetails.status") {
948 return ok(
949 input.cwd === "/repo/worktrees/pr-123"
950 ? "# branch.head feature/pr-123\n# branch.upstream origin/feature/pr-123\n# branch.ab +0 -0\n"
951 : "# branch.head main\n# branch.upstream origin/main\n# branch.ab +0 -0\n",
952 );
953 }
954 if (
955 input.operation === "GitCore.statusDetails.unstagedNumstat" ||
956 input.operation === "GitCore.statusDetails.stagedNumstat"
957 ) {
958 return ok();
959 }
960 if (input.operation === "GitCore.statusDetails.defaultRef") {
961 return ok("refs/remotes/origin/main\n");
962 }
963 return Effect.fail(
964 new GitCommandError({
965 operation: input.operation,
966 command: `git ${input.args.join(" ")}`,
967 cwd: input.cwd,
968 detail: "Unexpected git command in refresh failure cooldown test.",
969 }),
970 );
971 });
972
973 yield* core.statusDetails("/repo/worktrees/main");
974 yield* core.statusDetails("/repo/worktrees/pr-123");
975 expect(fetchCount).toBe(1);
976 }),
977 );
978
979 it.effect("throws when branch does not exist", () =>
980 Effect.gen(function* () {
981 const tmp = yield* makeTmpDir();
982 yield* initRepoWithCommit(tmp);
983 const result = yield* Effect.result(
984 (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "nonexistent" }),
985 );
986 expect(result._tag).toBe("Failure");
987 }),
988 );
989
990 it.effect("does not silently checkout a local branch when a remote ref no longer exists", () =>
991 Effect.gen(function* () {
992 const remote = yield* makeTmpDir();
993 const source = yield* makeTmpDir();
994 yield* git(remote, ["init", "--bare"]);
995
996 yield* initRepoWithCommit(source);
997 const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find(
998 (branch) => branch.current,
999 )!.name;
1000 yield* git(source, ["remote", "add", "origin", remote]);
1001 yield* git(source, ["push", "-u", "origin", defaultBranch]);
1002
1003 yield* (yield* GitCore).createBranch({ cwd: source, branch: "feature" });
1004
1005 const checkoutResult = yield* Effect.result(
1006 (yield* GitCore).checkoutBranch({ cwd: source, branch: "origin/feature" }),
1007 );
1008 expect(checkoutResult._tag).toBe("Failure");
1009 expect(yield* git(source, ["branch", "--show-current"])).toBe(defaultBranch);
1010 }),
1011 );
1012
1013 it.effect("checks out a remote tracking branch when remote name contains slashes", () =>
1014 Effect.gen(function* () {
1015 const remote = yield* makeTmpDir();
1016 const prefixRemote = yield* makeTmpDir();
1017 const source = yield* makeTmpDir();
1018 const prefixFetchNamespace = "prefix-my-org";
1019 const prefixRemoteName = "my-org";
1020 const remoteName = "my-org/upstream";
1021 const featureBranch = "feature";
1022 yield* git(remote, ["init", "--bare"]);
1023 yield* git(prefixRemote, ["init", "--bare"]);
1024
1025 yield* initRepoWithCommit(source);
1026 const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find(
1027 (branch) => branch.current,
1028 )!.name;
1029 yield* configureRemote(source, prefixRemoteName, prefixRemote, prefixFetchNamespace);
1030 yield* configureRemote(source, remoteName, remote, remoteName);
1031 yield* git(source, ["push", "-u", remoteName, defaultBranch]);
1032
1033 yield* git(source, ["checkout", "-b", featureBranch]);
1034 yield* writeTextFile(path.join(source, "feature.txt"), "feature content\n");
1035 yield* git(source, ["add", "feature.txt"]);
1036 yield* git(source, ["commit", "-m", "feature commit"]);
1037 yield* git(source, ["push", "-u", remoteName, featureBranch]);
1038 yield* git(source, ["checkout", defaultBranch]);
1039 yield* git(source, ["branch", "-D", featureBranch]);
1040
1041 const checkoutResult = yield* (yield* GitCore).checkoutBranch({
1042 cwd: source,
1043 branch: `${remoteName}/${featureBranch}`,
1044 });
1045
1046 expect(checkoutResult.branch).toBe("upstream/feature");
1047 expect(yield* git(source, ["branch", "--show-current"])).toBe("upstream/feature");
1048 const realGitCore = yield* GitCore;
1049 let fetchArgs: readonly string[] | null = null;
1050 const core = yield* makeIsolatedGitCore((input) => {
1051 if (input.args[0] === "--git-dir" && input.args[2] === "fetch") {
1052 fetchArgs = [...input.args];
1053 return Effect.succeed({
1054 code: 0,
1055 stdout: "",
1056 stderr: "",
1057 stdoutTruncated: false,
1058 stderrTruncated: false,
1059 });
1060 }
1061 return realGitCore.execute(input);
1062 });
1063
1064 const status = yield* core.statusDetails(source);
1065 expect(status.branch).toBe("upstream/feature");
1066 expect(status.upstreamRef).toBe(`${remoteName}/${featureBranch}`);
1067 expect(fetchArgs).toEqual([
1068 "--git-dir",
1069 path.join(source, ".git"),
1070 "fetch",
1071 "--quiet",
1072 "--no-tags",
1073 remoteName,
1074 ]);
1075 }),
1076 );
1077
1078 it.effect(
1079 "falls back to detached checkout when --track would conflict with an existing local branch",
1080 () =>
1081 Effect.gen(function* () {
1082 const remote = yield* makeTmpDir();
1083 const source = yield* makeTmpDir();
1084 yield* git(remote, ["init", "--bare"]);
1085
1086 yield* initRepoWithCommit(source);
1087 const defaultBranch = (yield* (yield* GitCore).listBranches({
1088 cwd: source,
1089 })).branches.find((branch) => branch.current)!.name;
1090 yield* git(source, ["remote", "add", "origin", remote]);
1091 yield* git(source, ["push", "-u", "origin", defaultBranch]);
1092
1093 // Keep local branch but remove tracking so `--track origin/<branch>`
1094 // would attempt to create an already-existing local branch.
1095 yield* git(source, ["branch", "--unset-upstream"]);
1096
1097 yield* (yield* GitCore).checkoutBranch({
1098 cwd: source,
1099 branch: `origin/${defaultBranch}`,
1100 });
1101
1102 const core = yield* GitCore;
1103 const status = yield* core.statusDetails(source);
1104 expect(status.branch).toBeNull();
1105 }),
1106 );
1107
1108 it.effect("throws when checkout would overwrite uncommitted changes", () =>
1109 Effect.gen(function* () {
1110 const tmp = yield* makeTmpDir();
1111 yield* initRepoWithCommit(tmp);
1112 yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "other" });
1113
1114 // Create a conflicting change: modify README on current branch
1115 yield* writeTextFile(path.join(tmp, "README.md"), "modified\n");
1116 yield* git(tmp, ["add", "README.md"]);
1117
1118 // First, checkout other branch cleanly
1119 yield* git(tmp, ["stash"]);
1120 yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "other" });
1121 yield* writeTextFile(path.join(tmp, "README.md"), "other content\n");
1122 yield* git(tmp, ["add", "."]);
1123 yield* git(tmp, ["commit", "-m", "other change"]);
1124
1125 // Go back to default branch
1126 const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find(
1127 (b) => !b.current,
1128 )!.name;
1129 yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: defaultBranch });
1130
1131 // Make uncommitted changes to the same file
1132 yield* writeTextFile(path.join(tmp, "README.md"), "conflicting local\n");
1133
1134 // Checkout should fail due to uncommitted changes
1135 const result = yield* Effect.result(
1136 (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "other" }),
1137 );
1138 expect(result._tag).toBe("Failure");
1139 }),
1140 );
1141 });
1142
1143 // ── createGitBranch ──
1144
1145 describe("createGitBranch", () => {
1146 it.effect("creates a new branch visible in listGitBranches", () =>
1147 Effect.gen(function* () {
1148 const tmp = yield* makeTmpDir();
1149 yield* initRepoWithCommit(tmp);
1150 yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "new-feature" });
1151
1152 const result = yield* (yield* GitCore).listBranches({ cwd: tmp });
1153 expect(result.branches.some((b) => b.name === "new-feature")).toBe(true);
1154 }),
1155 );
1156
1157 it.effect("throws when branch already exists", () =>
1158 Effect.gen(function* () {
1159 const tmp = yield* makeTmpDir();
1160 yield* initRepoWithCommit(tmp);
1161 yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "dupe" });
1162 const result = yield* Effect.result(
1163 (yield* GitCore).createBranch({ cwd: tmp, branch: "dupe" }),
1164 );
1165 expect(result._tag).toBe("Failure");
1166 }),
1167 );
1168 });
1169
1170 // ── renameGitBranch ──
1171
1172 describe("renameGitBranch", () => {
1173 it.effect("renames the current branch", () =>
1174 Effect.gen(function* () {
1175 const tmp = yield* makeTmpDir();
1176 yield* initRepoWithCommit(tmp);
1177 yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature/old-name" });
1178 yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "feature/old-name" });
1179
1180 const renamed = yield* (yield* GitCore).renameBranch({
1181 cwd: tmp,
1182 oldBranch: "feature/old-name",
1183 newBranch: "feature/new-name",
1184 });
1185
1186 expect(renamed.branch).toBe("feature/new-name");
1187
1188 const branches = yield* (yield* GitCore).listBranches({ cwd: tmp });
1189 expect(branches.branches.some((branch) => branch.name === "feature/old-name")).toBe(false);
1190 const current = branches.branches.find((branch) => branch.current);
1191 expect(current?.name).toBe("feature/new-name");
1192 }),
1193 );
1194
1195 it.effect("returns success without git invocation when old/new names match", () =>
1196 Effect.gen(function* () {
1197 const tmp = yield* makeTmpDir();
1198 yield* initRepoWithCommit(tmp);
1199 const current = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find(
1200 (b) => b.current,
1201 )!;
1202
1203 const renamed = yield* (yield* GitCore).renameBranch({
1204 cwd: tmp,
1205 oldBranch: current.name,
1206 newBranch: current.name,
1207 });
1208
1209 expect(renamed.branch).toBe(current.name);
1210 }),
1211 );
1212
1213 it.effect("appends numeric suffix when target branch already exists", () =>
1214 Effect.gen(function* () {
1215 const tmp = yield* makeTmpDir();
1216 yield* initRepoWithCommit(tmp);
1217 yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "t3code/feat/session" });
1218 yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "t3code/tmp-working" });
1219 yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "t3code/tmp-working" });
1220
1221 const renamed = yield* (yield* GitCore).renameBranch({
1222 cwd: tmp,
1223 oldBranch: "t3code/tmp-working",
1224 newBranch: "t3code/feat/session",
1225 });
1226
1227 expect(renamed.branch).toBe("t3code/feat/session-1");
1228 const branches = yield* (yield* GitCore).listBranches({ cwd: tmp });
1229 expect(branches.branches.some((branch) => branch.name === "t3code/feat/session")).toBe(
1230 true,
1231 );
1232 expect(branches.branches.some((branch) => branch.name === "t3code/feat/session-1")).toBe(
1233 true,
1234 );
1235 const current = branches.branches.find((branch) => branch.current);
1236 expect(current?.name).toBe("t3code/feat/session-1");
1237 }),
1238 );
1239
1240 it.effect("increments suffix until it finds an available branch name", () =>
1241 Effect.gen(function* () {
1242 const tmp = yield* makeTmpDir();
1243 yield* initRepoWithCommit(tmp);
1244 yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "t3code/feat/session" });
1245 yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "t3code/feat/session-1" });
1246 yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "t3code/tmp-working" });
1247 yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "t3code/tmp-working" });
1248
1249 const renamed = yield* (yield* GitCore).renameBranch({
1250 cwd: tmp,
1251 oldBranch: "t3code/tmp-working",
1252 newBranch: "t3code/feat/session",
1253 });
1254
1255 expect(renamed.branch).toBe("t3code/feat/session-2");
1256 }),
1257 );
1258
1259 it.effect("uses '--' separator for branch rename arguments", () =>
1260 Effect.gen(function* () {
1261 const tmp = yield* makeTmpDir();
1262 yield* initRepoWithCommit(tmp);
1263 yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature/old-name" });
1264 yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "feature/old-name" });
1265
1266 const realGitCore = yield* GitCore;
1267 let renameArgs: ReadonlyArray<string> | null = null;
1268 const core = yield* makeIsolatedGitCore((input) => {
1269 if (input.args[0] === "branch" && input.args[1] === "-m") {
1270 renameArgs = [...input.args];
1271 }
1272 return realGitCore.execute(input);
1273 });
1274
1275 const renamed = yield* core.renameBranch({
1276 cwd: tmp,
1277 oldBranch: "feature/old-name",
1278 newBranch: "feature/new-name",
1279 });
1280
1281 expect(renamed.branch).toBe("feature/new-name");
1282 expect(renameArgs).toEqual(["branch", "-m", "--", "feature/old-name", "feature/new-name"]);
1283 }),
1284 );
1285 });
1286
1287 // ── createGitWorktree + removeGitWorktree ──
1288
1289 describe("createGitWorktree", () => {
1290 it.effect("creates a worktree with a new branch from the base branch", () =>
1291 Effect.gen(function* () {
1292 const tmp = yield* makeTmpDir();
1293 yield* initRepoWithCommit(tmp);
1294
1295 const wtPath = path.join(tmp, "worktree-out");
1296 const currentBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find(
1297 (b) => b.current,
1298 )!.name;
1299
1300 const result = yield* (yield* GitCore).createWorktree({
1301 cwd: tmp,
1302 branch: currentBranch,
1303 newBranch: "wt-branch",
1304 path: wtPath,
1305 });
1306
1307 expect(result.worktree.path).toBe(wtPath);
1308 expect(result.worktree.branch).toBe("wt-branch");
1309 expect(existsSync(wtPath)).toBe(true);
1310 expect(existsSync(path.join(wtPath, "README.md"))).toBe(true);
1311
1312 // Clean up worktree before tmp dir disposal
1313 yield* (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath });
1314 }),
1315 );
1316
1317 it.effect("worktree has the new branch checked out", () =>
1318 Effect.gen(function* () {
1319 const tmp = yield* makeTmpDir();
1320 yield* initRepoWithCommit(tmp);
1321
1322 const wtPath = path.join(tmp, "wt-check-dir");
1323 const currentBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find(
1324 (b) => b.current,
1325 )!.name;
1326
1327 yield* (yield* GitCore).createWorktree({
1328 cwd: tmp,
1329 branch: currentBranch,
1330 newBranch: "wt-check",
1331 path: wtPath,
1332 });
1333
1334 // Verify the worktree is on the new branch
1335 const branchOutput = yield* git(wtPath, ["branch", "--show-current"]);
1336 expect(branchOutput).toBe("wt-check");
1337
1338 yield* (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath });
1339 }),
1340 );
1341
1342 it.effect("creates a worktree for an existing branch when newBranch is omitted", () =>
1343 Effect.gen(function* () {
1344 const tmp = yield* makeTmpDir();
1345 yield* initRepoWithCommit(tmp);
1346 yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature/existing-worktree" });
1347
1348 const wtPath = path.join(tmp, "wt-existing");
1349 const result = yield* (yield* GitCore).createWorktree({
1350 cwd: tmp,
1351 branch: "feature/existing-worktree",
1352 path: wtPath,
1353 });
1354
1355 expect(result.worktree.path).toBe(wtPath);
1356 expect(result.worktree.branch).toBe("feature/existing-worktree");
1357 const branchOutput = yield* git(wtPath, ["branch", "--show-current"]);
1358 expect(branchOutput).toBe("feature/existing-worktree");
1359
1360 yield* (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath });
1361 }),
1362 );
1363
1364 it.effect("throws when new branch name already exists", () =>
1365 Effect.gen(function* () {
1366 const tmp = yield* makeTmpDir();
1367 yield* initRepoWithCommit(tmp);
1368 yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "existing" });
1369
1370 const wtPath = path.join(tmp, "wt-conflict");
1371 const currentBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find(
1372 (b) => b.current,
1373 )!.name;
1374
1375 const result = yield* Effect.result(
1376 (yield* GitCore).createWorktree({
1377 cwd: tmp,
1378 branch: currentBranch,
1379 newBranch: "existing",
1380 path: wtPath,
1381 }),
1382 );
1383 expect(result._tag).toBe("Failure");
1384 }),
1385 );
1386
1387 it.effect("listGitBranches from worktree cwd reports worktree branch as current", () =>
1388 Effect.gen(function* () {
1389 const tmp = yield* makeTmpDir();
1390 yield* initRepoWithCommit(tmp);
1391
1392 const wtPath = path.join(tmp, "wt-list-dir");
1393 const mainBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find(
1394 (b) => b.current,
1395 )!.name;
1396
1397 yield* (yield* GitCore).createWorktree({
1398 cwd: tmp,
1399 branch: mainBranch,
1400 newBranch: "wt-list",
1401 path: wtPath,
1402 });
1403
1404 // listGitBranches from the worktree should show wt-list as current
1405 const wtBranches = yield* (yield* GitCore).listBranches({ cwd: wtPath });
1406 expect(wtBranches.isRepo).toBe(true);
1407 const wtCurrent = wtBranches.branches.find((b) => b.current);
1408 expect(wtCurrent!.name).toBe("wt-list");
1409
1410 // Main repo should still show the original branch as current
1411 const mainBranches = yield* (yield* GitCore).listBranches({ cwd: tmp });
1412 const mainCurrent = mainBranches.branches.find((b) => b.current);
1413 expect(mainCurrent!.name).toBe(mainBranch);
1414
1415 yield* (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath });
1416 }),
1417 );
1418
1419 it.effect("removeGitWorktree cleans up the worktree", () =>
1420 Effect.gen(function* () {
1421 const tmp = yield* makeTmpDir();
1422 yield* initRepoWithCommit(tmp);
1423
1424 const wtPath = path.join(tmp, "wt-remove-dir");
1425 const currentBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find(
1426 (b) => b.current,
1427 )!.name;
1428
1429 yield* (yield* GitCore).createWorktree({
1430 cwd: tmp,
1431 branch: currentBranch,
1432 newBranch: "wt-remove",
1433 path: wtPath,
1434 });
1435 expect(existsSync(wtPath)).toBe(true);
1436
1437 yield* (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath });
1438 expect(existsSync(wtPath)).toBe(false);
1439 }),
1440 );
1441
1442 it.effect("removeGitWorktree force removes a dirty worktree", () =>
1443 Effect.gen(function* () {
1444 const tmp = yield* makeTmpDir();
1445 yield* initRepoWithCommit(tmp);
1446
1447 const wtPath = path.join(tmp, "wt-dirty-dir");
1448 const currentBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find(
1449 (b) => b.current,
1450 )!.name;
1451
1452 yield* (yield* GitCore).createWorktree({
1453 cwd: tmp,
1454 branch: currentBranch,
1455 newBranch: "wt-dirty",
1456 path: wtPath,
1457 });
1458 expect(existsSync(wtPath)).toBe(true);
1459
1460 yield* writeTextFile(path.join(wtPath, "README.md"), "dirty change\n");
1461
1462 const failedRemove = yield* Effect.result(
1463 (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath }),
1464 );
1465 expect(failedRemove._tag).toBe("Failure");
1466 expect(existsSync(wtPath)).toBe(true);
1467
1468 yield* (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath, force: true });
1469 expect(existsSync(wtPath)).toBe(false);
1470 }),
1471 );
1472 });
1473
1474 // ── Full flow: local branch checkout ──
1475
1476 describe("full flow: local branch checkout", () => {
1477 it.effect("init → commit → create branch → checkout → verify current", () =>
1478 Effect.gen(function* () {
1479 const tmp = yield* makeTmpDir();
1480 yield* initRepoWithCommit(tmp);
1481 yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature-login" });
1482 yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "feature-login" });
1483
1484 const result = yield* (yield* GitCore).listBranches({ cwd: tmp });
1485 const current = result.branches.find((b) => b.current);
1486 expect(current!.name).toBe("feature-login");
1487 }),
1488 );
1489 });
1490
1491 // ── Full flow: worktree creation from base branch ──
1492
1493 describe("full flow: worktree creation", () => {
1494 it.effect("creates worktree with new branch from current branch", () =>
1495 Effect.gen(function* () {
1496 const tmp = yield* makeTmpDir();
1497 yield* initRepoWithCommit(tmp);
1498
1499 const currentBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find(
1500 (b) => b.current,
1501 )!.name;
1502
1503 const wtPath = path.join(tmp, "my-worktree");
1504 const result = yield* (yield* GitCore).createWorktree({
1505 cwd: tmp,
1506 branch: currentBranch,
1507 newBranch: "feature-wt",
1508 path: wtPath,
1509 });
1510
1511 // Worktree exists
1512 expect(existsSync(result.worktree.path)).toBe(true);
1513
1514 // Main repo still on original branch
1515 const mainBranches = yield* (yield* GitCore).listBranches({ cwd: tmp });
1516 const mainCurrent = mainBranches.branches.find((b) => b.current);
1517 expect(mainCurrent!.name).toBe(currentBranch);
1518
1519 // Worktree is on the new branch
1520 const wtBranch = yield* git(wtPath, ["branch", "--show-current"]);
1521 expect(wtBranch).toBe("feature-wt");
1522
1523 yield* (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath });
1524 }),
1525 );
1526 });
1527
1528 describe("fetchPullRequestBranch", () => {
1529 it.effect("fetches a GitHub pull request ref into a local branch without checkout", () =>
1530 Effect.gen(function* () {
1531 const tmp = yield* makeTmpDir();
1532 const { initialBranch } = yield* initRepoWithCommit(tmp);
1533 const remoteDir = yield* makeTmpDir("git-remote-");
1534 yield* git(remoteDir, ["init", "--bare"]);
1535 yield* git(tmp, ["remote", "add", "origin", remoteDir]);
1536 yield* git(tmp, ["push", "-u", "origin", initialBranch]);
1537 yield* git(tmp, ["checkout", "-b", "feature/pr-fetch"]);
1538 yield* writeTextFile(path.join(tmp, "pr-fetch.txt"), "fetch me\n");
1539 yield* git(tmp, ["add", "pr-fetch.txt"]);
1540 yield* git(tmp, ["commit", "-m", "Add PR fetch branch"]);
1541 yield* git(tmp, ["push", "-u", "origin", "feature/pr-fetch"]);
1542 yield* git(tmp, ["push", "origin", "HEAD:refs/pull/55/head"]);
1543 yield* git(tmp, ["checkout", initialBranch]);
1544
1545 yield* (yield* GitCore).fetchPullRequestBranch({
1546 cwd: tmp,
1547 prNumber: 55,
1548 branch: "feature/pr-fetch",
1549 });
1550
1551 const localBranches = yield* git(tmp, ["branch", "--list", "feature/pr-fetch"]);
1552 expect(localBranches).toContain("feature/pr-fetch");
1553 const currentBranch = yield* git(tmp, ["branch", "--show-current"]);
1554 expect(currentBranch).toBe(initialBranch);
1555 }),
1556 );
1557 });
1558
1559 // ── Full flow: thread switching simulation ──
1560
1561 describe("full flow: thread switching (checkout toggling)", () => {
1562 it.effect("checkout a → checkout b → checkout a → current matches", () =>
1563 Effect.gen(function* () {
1564 const tmp = yield* makeTmpDir();
1565 yield* initRepoWithCommit(tmp);
1566 yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "branch-a" });
1567 yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "branch-b" });
1568
1569 // Simulate switching to thread A's branch
1570 yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "branch-a" });
1571 let branches = yield* (yield* GitCore).listBranches({ cwd: tmp });
1572 expect(branches.branches.find((b) => b.current)!.name).toBe("branch-a");
1573
1574 // Simulate switching to thread B's branch
1575 yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "branch-b" });
1576 branches = yield* (yield* GitCore).listBranches({ cwd: tmp });
1577 expect(branches.branches.find((b) => b.current)!.name).toBe("branch-b");
1578
1579 // Switch back to thread A
1580 yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "branch-a" });
1581 branches = yield* (yield* GitCore).listBranches({ cwd: tmp });
1582 expect(branches.branches.find((b) => b.current)!.name).toBe("branch-a");
1583 }),
1584 );
1585 });
1586
1587 // ── Full flow: checkout conflict ──
1588
1589 describe("full flow: checkout conflict", () => {
1590 it.effect("uncommitted changes prevent checkout to a diverged branch", () =>
1591 Effect.gen(function* () {
1592 const tmp = yield* makeTmpDir();
1593 yield* initRepoWithCommit(tmp);
1594 yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "diverged" });
1595
1596 // Make diverged branch have different file content
1597 yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "diverged" });
1598 yield* writeTextFile(path.join(tmp, "README.md"), "diverged content\n");
1599 yield* git(tmp, ["add", "."]);
1600 yield* git(tmp, ["commit", "-m", "diverge"]);
1601
1602 // Actually, let's just get back to the initial branch explicitly
1603 const allBranches = yield* (yield* GitCore).listBranches({ cwd: tmp });
1604 const initialBranch = allBranches.branches.find((b) => b.name !== "diverged")!.name;
1605 yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: initialBranch });
1606
1607 // Make local uncommitted changes to the same file
1608 yield* writeTextFile(path.join(tmp, "README.md"), "local uncommitted\n");
1609
1610 // Attempt checkout should fail
1611 const failedCheckout = yield* Effect.result(
1612 (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "diverged" }),
1613 );
1614 expect(failedCheckout._tag).toBe("Failure");
1615
1616 // Current branch should still be the initial one
1617 const result = yield* (yield* GitCore).listBranches({ cwd: tmp });
1618 expect(result.branches.find((b) => b.current)!.name).toBe(initialBranch);
1619 }),
1620 );
1621 });
1622
1623 describe("GitCore", () => {
1624 it.effect("supports branch lifecycle operations through the service API", () =>
1625 Effect.gen(function* () {
1626 const tmp = yield* makeTmpDir();
1627 const core = yield* GitCore;
1628
1629 yield* core.initRepo({ cwd: tmp });
1630 yield* git(tmp, ["config", "user.email", "test@test.com"]);
1631 yield* git(tmp, ["config", "user.name", "Test"]);
1632 yield* writeTextFile(path.join(tmp, "README.md"), "# test\n");
1633 yield* git(tmp, ["add", "."]);
1634 yield* git(tmp, ["commit", "-m", "initial commit"]);
1635
1636 yield* core.createBranch({ cwd: tmp, branch: "feature/service-api" });
1637 yield* core.checkoutBranch({ cwd: tmp, branch: "feature/service-api" });
1638 const branches = yield* core.listBranches({ cwd: tmp });
1639
1640 expect(branches.isRepo).toBe(true);
1641 expect(
1642 branches.branches.find((branch: { current: boolean; name: string }) => branch.current)
1643 ?.name,
1644 ).toBe("feature/service-api");
1645 }),
1646 );
1647
1648 it.effect(
1649 "reuses an existing remote when the target URL only differs by a trailing slash after .git",
1650 () =>
1651 Effect.gen(function* () {
1652 const tmp = yield* makeTmpDir();
1653 yield* initRepoWithCommit(tmp);
1654 const core = yield* GitCore;
1655
1656 yield* git(tmp, ["remote", "add", "origin", "git@github.com:pingdotgg/t3code.git"]);
1657
1658 const remoteName = yield* core.ensureRemote({
1659 cwd: tmp,
1660 preferredName: "origin",
1661 url: "git@github.com:pingdotgg/t3code.git/",
1662 });
1663
1664 expect(remoteName).toBe("origin");
1665 expect((yield* git(tmp, ["remote"])).split("\n").filter(Boolean)).toEqual(["origin"]);
1666 }),
1667 );
1668
1669 it.effect("reports status details and dirty state", () =>
1670 Effect.gen(function* () {
1671 const tmp = yield* makeTmpDir();
1672 yield* initRepoWithCommit(tmp);
1673 const core = yield* GitCore;
1674
1675 const clean = yield* core.status({ cwd: tmp });
1676 expect(clean.hasWorkingTreeChanges).toBe(false);
1677 expect(clean.branch).toBeTruthy();
1678
1679 yield* writeTextFile(path.join(tmp, "README.md"), "updated\n");
1680 const dirty = yield* core.statusDetails(tmp);
1681 expect(dirty.hasWorkingTreeChanges).toBe(true);
1682 }),
1683 );
1684
1685 it.effect("returns a non-repo status for deleted directories", () =>
1686 Effect.gen(function* () {
1687 const tmp = yield* makeTmpDir();
1688 const deletedDir = path.join(tmp, "deleted-repo");
1689 yield* makeDirectory(deletedDir);
1690 yield* removePath(deletedDir);
1691 const core = yield* GitCore;
1692
1693 const status = yield* core.statusDetails(deletedDir);
1694 const localStatus = yield* core.statusDetailsLocal(deletedDir);
1695
1696 expect(status).toEqual({
1697 isRepo: false,
1698 hasOriginRemote: false,
1699 isDefaultBranch: false,
1700 branch: null,
1701 upstreamRef: null,
1702 hasWorkingTreeChanges: false,
1703 workingTree: {
1704 files: [],
1705 insertions: 0,
1706 deletions: 0,
1707 },
1708 hasUpstream: false,
1709 aheadCount: 0,
1710 behindCount: 0,
1711 });
1712 expect(localStatus).toEqual(status);
1713 }),
1714 );
1715
1716 it.effect("computes ahead count against base branch when no upstream is configured", () =>
1717 Effect.gen(function* () {
1718 const tmp = yield* makeTmpDir();
1719 yield* initRepoWithCommit(tmp);
1720 const core = yield* GitCore;
1721
1722 yield* core.createBranch({ cwd: tmp, branch: "feature/no-upstream-ahead" });
1723 yield* core.checkoutBranch({ cwd: tmp, branch: "feature/no-upstream-ahead" });
1724 yield* writeTextFile(path.join(tmp, "feature.txt"), "ahead of base\n");
1725 yield* git(tmp, ["add", "feature.txt"]);
1726 yield* git(tmp, ["commit", "-m", "feature commit"]);
1727
1728 const details = yield* core.statusDetails(tmp);
1729 expect(details.branch).toBe("feature/no-upstream-ahead");
1730 expect(details.hasUpstream).toBe(false);
1731 expect(details.aheadCount).toBe(1);
1732 expect(details.behindCount).toBe(0);
1733 }),
1734 );
1735
1736 it.effect(
1737 "computes ahead count against origin/default when local default branch is missing",
1738 () =>
1739 Effect.gen(function* () {
1740 const remote = yield* makeTmpDir();
1741 const source = yield* makeTmpDir();
1742 yield* git(remote, ["init", "--bare"]);
1743
1744 yield* initRepoWithCommit(source);
1745 const initialBranch = (yield* (yield* GitCore).listBranches({
1746 cwd: source,
1747 })).branches.find((branch) => branch.current)!.name;
1748 yield* git(source, ["remote", "add", "origin", remote]);
1749 yield* git(source, ["push", "-u", "origin", initialBranch]);
1750 yield* git(source, ["checkout", "-b", "feature/remote-base-only"]);
1751 yield* writeTextFile(
1752 path.join(source, "feature.txt"),
1753 `ahead of origin/${initialBranch}\n`,
1754 );
1755 yield* git(source, ["add", "feature.txt"]);
1756 yield* git(source, ["commit", "-m", "feature commit"]);
1757 yield* git(source, ["branch", "-D", initialBranch]);
1758
1759 const core = yield* GitCore;
1760 const details = yield* core.statusDetails(source);
1761 expect(details.branch).toBe("feature/remote-base-only");
1762 expect(details.hasUpstream).toBe(false);
1763 expect(details.aheadCount).toBe(1);
1764 expect(details.behindCount).toBe(0);
1765 }),
1766 );
1767
1768 it.effect(
1769 "computes ahead count against a non-origin remote-prefixed gh-merge-base candidate",
1770 () =>
1771 Effect.gen(function* () {
1772 const remote = yield* makeTmpDir();
1773 const source = yield* makeTmpDir();
1774 const remoteName = "fork-seed";
1775 yield* git(remote, ["init", "--bare"]);
1776
1777 yield* initRepoWithCommit(source);
1778 const initialBranch = (yield* (yield* GitCore).listBranches({
1779 cwd: source,
1780 })).branches.find((branch) => branch.current)!.name;
1781 yield* git(source, ["remote", "add", remoteName, remote]);
1782 yield* git(source, ["push", "-u", remoteName, initialBranch]);
1783 yield* git(source, ["checkout", "-b", "feature/non-origin-merge-base"]);
1784 yield* git(source, [
1785 "config",
1786 "branch.feature/non-origin-merge-base.gh-merge-base",
1787 `${remoteName}/${initialBranch}`,
1788 ]);
1789 yield* writeTextFile(
1790 path.join(source, "feature.txt"),
1791 `ahead of ${remoteName}/${initialBranch}\n`,
1792 );
1793 yield* git(source, ["add", "feature.txt"]);
1794 yield* git(source, ["commit", "-m", "feature commit"]);
1795 yield* git(source, ["branch", "-D", initialBranch]);
1796
1797 const core = yield* GitCore;
1798 const details = yield* core.statusDetails(source);
1799 expect(details.branch).toBe("feature/non-origin-merge-base");
1800 expect(details.hasUpstream).toBe(false);
1801 expect(details.aheadCount).toBe(1);
1802 expect(details.behindCount).toBe(0);
1803 }),
1804 );
1805
1806 it.effect("skips push when no upstream is configured and branch is not ahead of base", () =>
1807 Effect.gen(function* () {
1808 const tmp = yield* makeTmpDir();
1809 yield* initRepoWithCommit(tmp);
1810 const core = yield* GitCore;
1811
1812 yield* core.createBranch({ cwd: tmp, branch: "feature/no-upstream-no-ahead" });
1813 yield* core.checkoutBranch({ cwd: tmp, branch: "feature/no-upstream-no-ahead" });
1814
1815 const pushed = yield* core.pushCurrentBranch(tmp, null);
1816 expect(pushed.status).toBe("skipped_up_to_date");
1817 expect(pushed.branch).toBe("feature/no-upstream-no-ahead");
1818 expect(pushed.setUpstream).toBeUndefined();
1819 }),
1820 );
1821
1822 it.effect("pushes with upstream setup when no comparable base branch exists", () =>
1823 Effect.gen(function* () {
1824 const tmp = yield* makeTmpDir();
1825 const remote = yield* makeTmpDir();
1826 yield* git(tmp, ["init", "--initial-branch=trunk"]);
1827 yield* git(tmp, ["config", "user.email", "test@test.com"]);
1828 yield* git(tmp, ["config", "user.name", "Test"]);
1829 yield* writeTextFile(path.join(tmp, "README.md"), "hello\n");
1830 yield* git(tmp, ["add", "README.md"]);
1831 yield* git(tmp, ["commit", "-m", "initial"]);
1832 yield* git(remote, ["init", "--bare"]);
1833 yield* git(tmp, ["remote", "add", "origin", remote]);
1834 yield* git(tmp, ["checkout", "-b", "feature/no-base"]);
1835
1836 const core = yield* GitCore;
1837 const pushed = yield* core.pushCurrentBranch(tmp, null);
1838 expect(pushed.status).toBe("pushed");
1839 expect(pushed.setUpstream).toBe(true);
1840 expect(pushed.upstreamBranch).toBe("origin/feature/no-base");
1841 expect(yield* git(tmp, ["rev-parse", "--abbrev-ref", "@{upstream}"])).toBe(
1842 "origin/feature/no-base",
1843 );
1844 }),
1845 );
1846
1847 it.effect("pushes with upstream setup to the only configured non-origin remote", () =>
1848 Effect.gen(function* () {
1849 const tmp = yield* makeTmpDir();
1850 const remote = yield* makeTmpDir();
1851 yield* git(tmp, ["init", "--initial-branch=main"]);
1852 yield* git(tmp, ["config", "user.email", "test@test.com"]);
1853 yield* git(tmp, ["config", "user.name", "Test"]);
1854 yield* writeTextFile(path.join(tmp, "README.md"), "hello\n");
1855 yield* git(tmp, ["add", "README.md"]);
1856 yield* git(tmp, ["commit", "-m", "initial"]);
1857 yield* git(remote, ["init", "--bare"]);
1858 yield* git(tmp, ["remote", "add", "fork", remote]);
1859 yield* git(tmp, ["checkout", "-b", "feature/fork-only"]);
1860
1861 const core = yield* GitCore;
1862 const pushed = yield* core.pushCurrentBranch(tmp, null);
1863 expect(pushed.status).toBe("pushed");
1864 expect(pushed.setUpstream).toBe(true);
1865 expect(pushed.upstreamBranch).toBe("fork/feature/fork-only");
1866 expect(yield* git(tmp, ["rev-parse", "--abbrev-ref", "@{upstream}"])).toBe(
1867 "fork/feature/fork-only",
1868 );
1869 }),
1870 );
1871
1872 it.effect(
1873 "pushes with upstream setup when comparable base exists but remote branch is missing",
1874 () =>
1875 Effect.gen(function* () {
1876 const tmp = yield* makeTmpDir();
1877 const remote = yield* makeTmpDir();
1878 yield* git(remote, ["init", "--bare"]);
1879
1880 yield* initRepoWithCommit(tmp);
1881 const initialBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find(
1882 (branch) => branch.current,
1883 )!.name;
1884 yield* git(tmp, ["remote", "add", "origin", remote]);
1885 yield* git(tmp, ["push", "-u", "origin", initialBranch]);
1886
1887 yield* writeTextFile(path.join(tmp, "default-ahead.txt"), "ahead on default\n");
1888 yield* git(tmp, ["add", "default-ahead.txt"]);
1889 yield* git(tmp, ["commit", "-m", "default ahead"]);
1890
1891 const featureBranch = "feature/publish-no-upstream";
1892 yield* git(tmp, ["checkout", "-b", featureBranch]);
1893
1894 const core = yield* GitCore;
1895 const pushed = yield* core.pushCurrentBranch(tmp, null);
1896 expect(pushed.status).toBe("pushed");
1897 expect(pushed.setUpstream).toBe(true);
1898 expect(pushed.upstreamBranch).toBe(`origin/${featureBranch}`);
1899 expect(yield* git(tmp, ["rev-parse", "--abbrev-ref", "@{upstream}"])).toBe(
1900 `origin/${featureBranch}`,
1901 );
1902 expect(yield* git(tmp, ["ls-remote", "--heads", "origin", featureBranch])).toContain(
1903 featureBranch,
1904 );
1905 }),
1906 );
1907
1908 it.effect("prefers branch pushRemote over origin when setting upstream", () =>
1909 Effect.gen(function* () {
1910 const tmp = yield* makeTmpDir();
1911 const origin = yield* makeTmpDir();
1912 const fork = yield* makeTmpDir();
1913 yield* git(origin, ["init", "--bare"]);
1914 yield* git(fork, ["init", "--bare"]);
1915
1916 yield* initRepoWithCommit(tmp);
1917 const initialBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find(
1918 (branch) => branch.current,
1919 )!.name;
1920 yield* git(tmp, ["remote", "add", "origin", origin]);
1921 yield* git(tmp, ["remote", "add", "fork", fork]);
1922 yield* git(tmp, ["push", "-u", "origin", initialBranch]);
1923
1924 const featureBranch = "feature/push-remote";
1925 yield* git(tmp, ["checkout", "-b", featureBranch]);
1926 yield* git(tmp, ["config", `branch.${featureBranch}.pushRemote`, "fork"]);
1927 yield* writeTextFile(path.join(tmp, "feature.txt"), "push to fork\n");
1928 yield* git(tmp, ["add", "feature.txt"]);
1929 yield* git(tmp, ["commit", "-m", "feature commit"]);
1930
1931 const core = yield* GitCore;
1932 const pushed = yield* core.pushCurrentBranch(tmp, null);
1933 expect(pushed.status).toBe("pushed");
1934 expect(pushed.setUpstream).toBe(true);
1935 expect(pushed.upstreamBranch).toBe(`fork/${featureBranch}`);
1936 expect(yield* git(tmp, ["rev-parse", "--abbrev-ref", "@{upstream}"])).toBe(
1937 `fork/${featureBranch}`,
1938 );
1939 expect(yield* git(tmp, ["ls-remote", "--heads", "fork", featureBranch])).toContain(
1940 featureBranch,
1941 );
1942 }),
1943 );
1944
1945 it.effect(
1946 "pushes renamed PR worktree branches to their tracked upstream branch even when push.default is current",
1947 () =>
1948 Effect.gen(function* () {
1949 const tmp = yield* makeTmpDir();
1950 const fork = yield* makeTmpDir();
1951 yield* git(fork, ["init", "--bare"]);
1952
1953 const { initialBranch } = yield* initRepoWithCommit(tmp);
1954 yield* git(tmp, ["remote", "add", "jasonLaster", fork]);
1955 yield* git(tmp, ["checkout", "-b", "statemachine"]);
1956 yield* writeTextFile(path.join(tmp, "fork.txt"), "fork branch\n");
1957 yield* git(tmp, ["add", "fork.txt"]);
1958 yield* git(tmp, ["commit", "-m", "fork branch"]);
1959 yield* git(tmp, ["push", "-u", "jasonLaster", "statemachine"]);
1960 yield* git(tmp, ["checkout", initialBranch]);
1961 yield* git(tmp, ["branch", "-D", "statemachine"]);
1962 yield* git(tmp, [
1963 "checkout",
1964 "-b",
1965 "t3code/pr-488/statemachine",
1966 "--track",
1967 "jasonLaster/statemachine",
1968 ]);
1969 yield* git(tmp, ["config", "push.default", "current"]);
1970 yield* writeTextFile(path.join(tmp, "fork.txt"), "updated fork branch\n");
1971 yield* git(tmp, ["add", "fork.txt"]);
1972 yield* git(tmp, ["commit", "-m", "update reviewed PR branch"]);
1973
1974 const core = yield* GitCore;
1975 const pushed = yield* core.pushCurrentBranch(tmp, null);
1976
1977 expect(pushed.status).toBe("pushed");
1978 expect(pushed.setUpstream).toBe(false);
1979 expect(pushed.upstreamBranch).toBe("jasonLaster/statemachine");
1980 expect(yield* git(tmp, ["rev-parse", "--abbrev-ref", "@{upstream}"])).toBe(
1981 "jasonLaster/statemachine",
1982 );
1983 expect(
1984 yield* git(tmp, ["ls-remote", "--heads", "jasonLaster", "statemachine"]),
1985 ).toContain("statemachine");
1986 expect(
1987 yield* git(tmp, ["ls-remote", "--heads", "jasonLaster", "t3code/pr-488/statemachine"]),
1988 ).toBe("");
1989 }),
1990 );
1991
1992 it.effect("pushes to the tracked upstream when the remote name contains slashes", () =>
1993 Effect.gen(function* () {
1994 const tmp = yield* makeTmpDir();
1995 const remote = yield* makeTmpDir();
1996 const prefixRemote = yield* makeTmpDir();
1997 const prefixFetchNamespace = "prefix-my-org";
1998 const prefixRemoteName = "my-org";
1999 const remoteName = "my-org/upstream";
2000 const featureBranch = "feature/slash-remote-push";
2001 yield* git(remote, ["init", "--bare"]);
2002 yield* git(prefixRemote, ["init", "--bare"]);
2003
2004 const { initialBranch } = yield* initRepoWithCommit(tmp);
2005 yield* configureRemote(tmp, prefixRemoteName, prefixRemote, prefixFetchNamespace);
2006 yield* configureRemote(tmp, remoteName, remote, remoteName);
2007 yield* git(tmp, ["push", "-u", remoteName, initialBranch]);
2008
2009 yield* git(tmp, ["checkout", "-b", featureBranch]);
2010 yield* writeTextFile(path.join(tmp, "feature.txt"), "first revision\n");
2011 yield* git(tmp, ["add", "feature.txt"]);
2012 yield* git(tmp, ["commit", "-m", "feature base"]);
2013 yield* git(tmp, ["push", "-u", remoteName, featureBranch]);
2014
2015 yield* writeTextFile(path.join(tmp, "feature.txt"), "second revision\n");
2016 yield* git(tmp, ["add", "feature.txt"]);
2017 yield* git(tmp, ["commit", "-m", "feature update"]);
2018
2019 const core = yield* GitCore;
2020 const pushed = yield* core.pushCurrentBranch(tmp, null);
2021 expect(pushed.status).toBe("pushed");
2022 expect(pushed.setUpstream).toBe(false);
2023 expect(pushed.upstreamBranch).toBe(`${remoteName}/${featureBranch}`);
2024 expect(yield* git(tmp, ["rev-parse", "--abbrev-ref", "@{upstream}"])).toBe(
2025 `${remoteName}/${featureBranch}`,
2026 );
2027 expect(yield* git(tmp, ["ls-remote", "--heads", remoteName, featureBranch])).toContain(
2028 featureBranch,
2029 );
2030 }),
2031 );
2032
2033 it.effect("includes command context when worktree removal fails", () =>
2034 Effect.gen(function* () {
2035 const tmp = yield* makeTmpDir();
2036 yield* initRepoWithCommit(tmp);
2037 const core = yield* GitCore;
2038 const missingWorktreePath = path.join(tmp, "missing-worktree");
2039
2040 const removeResult = yield* Effect.result(
2041 core.removeWorktree({ cwd: tmp, path: missingWorktreePath }),
2042 );
2043 expect(removeResult._tag).toBe("Failure");
2044 if (removeResult._tag !== "Failure") {
2045 return;
2046 }
2047 const message = removeResult.failure.message;
2048 expect(message).toContain("git worktree remove");
2049 expect(message).toContain(`cwd: ${tmp}`);
2050 expect(message).toContain(missingWorktreePath);
2051 }),
2052 );
2053
2054 it.effect(
2055 "refreshes upstream before statusDetails so behind count reflects remote updates",
2056 () =>
2057 Effect.gen(function* () {
2058 const remote = yield* makeTmpDir();
2059 const source = yield* makeTmpDir();
2060 const clone = yield* makeTmpDir();
2061 yield* git(remote, ["init", "--bare"]);
2062
2063 yield* initRepoWithCommit(source);
2064 const initialBranch = (yield* (yield* GitCore).listBranches({
2065 cwd: source,
2066 })).branches.find((branch) => branch.current)!.name;
2067 yield* git(source, ["remote", "add", "origin", remote]);
2068 yield* git(source, ["push", "-u", "origin", initialBranch]);
2069
2070 yield* git(clone, ["clone", remote, "."]);
2071 yield* git(clone, ["config", "user.email", "test@test.com"]);
2072 yield* git(clone, ["config", "user.name", "Test"]);
2073 yield* git(clone, [
2074 "checkout",
2075 "-B",
2076 initialBranch,
2077 "--track",
2078 `origin/${initialBranch}`,
2079 ]);
2080 yield* writeTextFile(path.join(clone, "CHANGELOG.md"), "remote change\n");
2081 yield* git(clone, ["add", "CHANGELOG.md"]);
2082 yield* git(clone, ["commit", "-m", "remote update"]);
2083 yield* git(clone, ["push", "origin", initialBranch]);
2084
2085 const core = yield* GitCore;
2086 const details = yield* core.statusDetails(source);
2087 expect(details.branch).toBe(initialBranch);
2088 expect(details.aheadCount).toBe(0);
2089 expect(details.behindCount).toBe(1);
2090 }),
2091 );
2092
2093 it.effect("prepares commit context by auto-staging and creates commit", () =>
2094 Effect.gen(function* () {
2095 const tmp = yield* makeTmpDir();
2096 yield* initRepoWithCommit(tmp);
2097 const core = yield* GitCore;
2098
2099 yield* writeTextFile(path.join(tmp, "README.md"), "new content\n");
2100 const context = yield* core.prepareCommitContext(tmp);
2101 expect(context).not.toBeNull();
2102 expect(context!.stagedSummary.length).toBeGreaterThan(0);
2103 expect(context!.stagedPatch.length).toBeGreaterThan(0);
2104
2105 const created = yield* core.commit(tmp, "Add README update", "- include updated content");
2106 expect(created.commitSha.length).toBeGreaterThan(0);
2107 expect(yield* git(tmp, ["log", "-1", "--pretty=%s"])).toBe("Add README update");
2108 }),
2109 );
2110
2111 it.effect("prepareCommitContext stages only selected files when filePaths provided", () =>
2112 Effect.gen(function* () {
2113 const tmp = yield* makeTmpDir();
2114 yield* initRepoWithCommit(tmp);
2115 const core = yield* GitCore;
2116
2117 yield* writeTextFile(path.join(tmp, "a.txt"), "file a\n");
2118 yield* writeTextFile(path.join(tmp, "b.txt"), "file b\n");
2119
2120 const context = yield* core.prepareCommitContext(tmp, ["a.txt"]);
2121 expect(context).not.toBeNull();
2122 expect(context!.stagedSummary).toContain("a.txt");
2123 expect(context!.stagedSummary).not.toContain("b.txt");
2124
2125 yield* core.commit(tmp, "Add only a.txt", "");
2126
2127 // b.txt should still be untracked after commit
2128 const statusAfter = yield* git(tmp, ["status", "--porcelain"]);
2129 expect(statusAfter).toContain("b.txt");
2130 expect(statusAfter).not.toContain("a.txt");
2131 }),
2132 );
2133
2134 it.effect("prepareCommitContext stages everything when filePaths is undefined", () =>
2135 Effect.gen(function* () {
2136 const tmp = yield* makeTmpDir();
2137 yield* initRepoWithCommit(tmp);
2138 const core = yield* GitCore;
2139
2140 yield* writeTextFile(path.join(tmp, "a.txt"), "file a\n");
2141 yield* writeTextFile(path.join(tmp, "b.txt"), "file b\n");
2142
2143 const context = yield* core.prepareCommitContext(tmp);
2144 expect(context).not.toBeNull();
2145 expect(context!.stagedSummary).toContain("a.txt");
2146 expect(context!.stagedSummary).toContain("b.txt");
2147 }),
2148 );
2149
2150 it.effect("prepareCommitContext truncates oversized staged patches instead of failing", () =>
2151 Effect.gen(function* () {
2152 const tmp = yield* makeTmpDir();
2153 yield* initRepoWithCommit(tmp);
2154 const core = yield* GitCore;
2155
2156 yield* writeTextFile(path.join(tmp, "README.md"), buildLargeText());
2157
2158 const context = yield* core.prepareCommitContext(tmp);
2159 expect(context).not.toBeNull();
2160 expect(context!.stagedSummary).toContain("README.md");
2161 expect(context!.stagedPatch).toContain("[truncated]");
2162 }),
2163 );
2164
2165 it.effect("readRangeContext truncates oversized diff patches instead of failing", () =>
2166 Effect.gen(function* () {
2167 const tmp = yield* makeTmpDir();
2168 const { initialBranch } = yield* initRepoWithCommit(tmp);
2169 const core = yield* GitCore;
2170
2171 yield* core.createBranch({ cwd: tmp, branch: "feature/large-range-context" });
2172 yield* core.checkoutBranch({ cwd: tmp, branch: "feature/large-range-context" });
2173 yield* writeTextFile(path.join(tmp, "large.txt"), buildLargeText());
2174 yield* git(tmp, ["add", "large.txt"]);
2175 yield* git(tmp, ["commit", "-m", "Add large range context"]);
2176
2177 const rangeContext = yield* core.readRangeContext(tmp, initialBranch);
2178 expect(rangeContext.commitSummary).toContain("Add large range context");
2179 expect(rangeContext.diffSummary).toContain("large.txt");
2180 expect(rangeContext.diffPatch).toContain("[truncated]");
2181 }),
2182 );
2183
2184 it.effect("pushes with upstream setup and then skips when up to date", () =>
2185 Effect.gen(function* () {
2186 const tmp = yield* makeTmpDir();
2187 const remote = yield* makeTmpDir();
2188 yield* initRepoWithCommit(tmp);
2189 yield* git(remote, ["init", "--bare"]);
2190 yield* git(tmp, ["remote", "add", "origin", remote]);
2191 yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature/core-push" });
2192 yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "feature/core-push" });
2193
2194 yield* writeTextFile(path.join(tmp, "feature.txt"), "push me\n");
2195 const core = yield* GitCore;
2196 const context = yield* core.prepareCommitContext(tmp);
2197 expect(context).not.toBeNull();
2198 yield* core.commit(tmp, "Add feature file", "");
2199
2200 const pushed = yield* core.pushCurrentBranch(tmp, null);
2201 expect(pushed.status).toBe("pushed");
2202 expect(pushed.setUpstream).toBe(true);
2203 expect(yield* git(tmp, ["rev-parse", "--abbrev-ref", "@{upstream}"])).toBe(
2204 "origin/feature/core-push",
2205 );
2206
2207 const skipped = yield* core.pushCurrentBranch(tmp, null);
2208 expect(skipped.status).toBe("skipped_up_to_date");
2209 }),
2210 );
2211
2212 it.effect("pulls behind branch and then reports up-to-date", () =>
2213 Effect.gen(function* () {
2214 const remote = yield* makeTmpDir();
2215 const source = yield* makeTmpDir();
2216 const clone = yield* makeTmpDir();
2217 yield* git(remote, ["init", "--bare"]);
2218
2219 yield* initRepoWithCommit(source);
2220 const initialBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find(
2221 (branch) => branch.current,
2222 )!.name;
2223 yield* git(source, ["remote", "add", "origin", remote]);
2224 yield* git(source, ["push", "-u", "origin", initialBranch]);
2225
2226 yield* git(clone, ["clone", remote, "."]);
2227 yield* git(clone, ["config", "user.email", "test@test.com"]);
2228 yield* git(clone, ["config", "user.name", "Test"]);
2229 yield* writeTextFile(path.join(clone, "CHANGELOG.md"), "remote change\n");
2230 yield* git(clone, ["add", "CHANGELOG.md"]);
2231 yield* git(clone, ["commit", "-m", "remote update"]);
2232 yield* git(clone, ["push", "origin", initialBranch]);
2233
2234 const core = yield* GitCore;
2235 const pulled = yield* core.pullCurrentBranch(source);
2236 expect(pulled.status).toBe("pulled");
2237 expect((yield* core.statusDetails(source)).behindCount).toBe(0);
2238
2239 const skipped = yield* core.pullCurrentBranch(source);
2240 expect(skipped.status).toBe("skipped_up_to_date");
2241 }),
2242 );
2243
2244 it.effect("top-level pullGitBranch rejects when no upstream exists", () =>
2245 Effect.gen(function* () {
2246 const tmp = yield* makeTmpDir();
2247 yield* initRepoWithCommit(tmp);
2248 const result = yield* Effect.result((yield* GitCore).pullCurrentBranch(tmp));
2249 expect(result._tag).toBe("Failure");
2250 if (result._tag === "Failure") {
2251 expect(result.failure.message.toLowerCase()).toContain("no upstream");
2252 }
2253 }),
2254 );
2255
2256 it.effect("lists branches when recency lookup fails", () =>
2257 Effect.gen(function* () {
2258 const tmp = yield* makeTmpDir();
2259 yield* initRepoWithCommit(tmp);
2260 const realGitCore = yield* GitCore;
2261 let didFailRecency = false;
2262 const core = yield* makeIsolatedGitCore((input) => {
2263 if (!didFailRecency && input.args[0] === "for-each-ref") {
2264 didFailRecency = true;
2265 return Effect.fail(
2266 new GitCommandError({
2267 operation: "git.test.listBranchesRecency",
2268 command: `git ${input.args.join(" ")}`,
2269 cwd: input.cwd,
2270 detail: "timeout",
2271 }),
2272 );
2273 }
2274 return realGitCore.execute(input);
2275 });
2276
2277 const result = yield* core.listBranches({ cwd: tmp });
2278
2279 expect(result.isRepo).toBe(true);
2280 expect(result.branches.length).toBeGreaterThan(0);
2281 expect(result.branches[0]?.current).toBe(true);
2282 expect(didFailRecency).toBe(true);
2283 }),
2284 );
2285
2286 it.effect("falls back to empty remote branch data when remote lookups fail", () =>
2287 Effect.gen(function* () {
2288 const tmp = yield* makeTmpDir();
2289 const remote = yield* makeTmpDir();
2290 yield* initRepoWithCommit(tmp);
2291 yield* git(remote, ["init", "--bare"]);
2292 yield* git(tmp, ["remote", "add", "origin", remote]);
2293
2294 const realGitCore = yield* GitCore;
2295 let didFailRemoteBranches = false;
2296 let didFailRemoteNames = false;
2297 const core = yield* makeIsolatedGitCore((input) => {
2298 if (input.args.join(" ") === "branch --no-color --no-column --remotes") {
2299 didFailRemoteBranches = true;
2300 return Effect.fail(
2301 new GitCommandError({
2302 operation: "git.test.listBranchesRemoteBranches",
2303 command: `git ${input.args.join(" ")}`,
2304 cwd: input.cwd,
2305 detail: "remote unavailable",
2306 }),
2307 );
2308 }
2309 if (input.args.join(" ") === "remote") {
2310 didFailRemoteNames = true;
2311 return Effect.fail(
2312 new GitCommandError({
2313 operation: "git.test.listBranchesRemoteNames",
2314 command: `git ${input.args.join(" ")}`,
2315 cwd: input.cwd,
2316 detail: "remote unavailable",
2317 }),
2318 );
2319 }
2320 return realGitCore.execute(input);
2321 });
2322
2323 const result = yield* core.listBranches({ cwd: tmp });
2324
2325 expect(result.isRepo).toBe(true);
2326 expect(result.branches.length).toBeGreaterThan(0);
2327 expect(result.branches.every((branch) => !branch.isRemote)).toBe(true);
2328 expect(didFailRemoteBranches).toBe(true);
2329 expect(didFailRemoteNames).toBe(true);
2330 }),
2331 );
2332 });
2333});
apps/server/src/git/Layers/GitCore.ts deleted-2206
......@@ -1,2206 +0,0 @@
1import {
2 Cache,
3 Data,
4 Duration,
5 Effect,
6 Exit,
7 FileSystem,
8 Layer,
9 Option,
10 Path,
11 PlatformError,
12 Ref,
13 Result,
14 Schema,
15 Scope,
16 Semaphore,
17 Stream,
18} from "effect";
19import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
20
21import { GitCommandError, type GitBranch } from "@t3tools/contracts";
22import { dedupeRemoteBranchesWithLocalMatches } from "@t3tools/shared/git";
23import { compactTraceAttributes } from "../../observability/Attributes.ts";
24import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../../observability/Metrics.ts";
25import {
26 GitCore,
27 type ExecuteGitProgress,
28 type GitCommitOptions,
29 type GitCoreShape,
30 type GitStatusDetails,
31 type ExecuteGitInput,
32 type ExecuteGitResult,
33} from "../Services/GitCore.ts";
34import {
35 parseRemoteNames,
36 parseRemoteNamesInGitOrder,
37 parseRemoteRefWithRemoteNames,
38} from "../remoteRefs.ts";
39import { ServerConfig } from "../../config.ts";
40import { decodeJsonResult } from "@t3tools/shared/schemaJson";
41
42const DEFAULT_TIMEOUT_MS = 30_000;
43const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000;
44const OUTPUT_TRUNCATED_MARKER = "\n\n[truncated]";
45const PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES = 49_000;
46const RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES = 19_000;
47const RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES = 19_000;
48const RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES = 59_000;
49const WORKSPACE_FILES_MAX_OUTPUT_BYTES = 16 * 1024 * 1024;
50const GIT_CHECK_IGNORE_MAX_STDIN_BYTES = 256 * 1024;
51const WORKSPACE_GIT_HARDENED_CONFIG_ARGS = [
52 "-c",
53 "core.fsmonitor=false",
54 "-c",
55 "core.untrackedCache=false",
56] as const;
57const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15);
58const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5);
59const STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN = Duration.seconds(5);
60const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048;
61const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const;
62const GIT_LIST_BRANCHES_DEFAULT_LIMIT = 100;
63const NON_REPOSITORY_STATUS_DETAILS = Object.freeze<GitStatusDetails>({
64 isRepo: false,
65 hasOriginRemote: false,
66 isDefaultBranch: false,
67 branch: null,
68 upstreamRef: null,
69 hasWorkingTreeChanges: false,
70 workingTree: { files: [], insertions: 0, deletions: 0 },
71 hasUpstream: false,
72 aheadCount: 0,
73 behindCount: 0,
74});
75
76type TraceTailState = {
77 processedChars: number;
78 remainder: string;
79};
80
81class StatusRemoteRefreshCacheKey extends Data.Class<{
82 gitCommonDir: string;
83 remoteName: string;
84}> {}
85
86interface ExecuteGitOptions {
87 stdin?: string | undefined;
88 timeoutMs?: number | undefined;
89 allowNonZeroExit?: boolean | undefined;
90 fallbackErrorMessage?: string | undefined;
91 maxOutputBytes?: number | undefined;
92 truncateOutputAtMaxBytes?: boolean | undefined;
93 progress?: ExecuteGitProgress | undefined;
94}
95
96function parseBranchAb(value: string): { ahead: number; behind: number } {
97 const match = value.match(/^\+(\d+)\s+-(\d+)$/);
98 if (!match) return { ahead: 0, behind: 0 };
99 return {
100 ahead: Number(match[1] ?? "0"),
101 behind: Number(match[2] ?? "0"),
102 };
103}
104
105function parseNumstatEntries(
106 stdout: string,
107): Array<{ path: string; insertions: number; deletions: number }> {
108 const entries: Array<{ path: string; insertions: number; deletions: number }> = [];
109 for (const line of stdout.split(/\r?\n/g)) {
110 if (line.trim().length === 0) continue;
111 const [addedRaw, deletedRaw, ...pathParts] = line.split("\t");
112 const rawPath =
113 pathParts.length > 1 ? (pathParts.at(-1) ?? "").trim() : pathParts.join("\t").trim();
114 if (rawPath.length === 0) continue;
115 const added = Number.parseInt(addedRaw ?? "0", 10);
116 const deleted = Number.parseInt(deletedRaw ?? "0", 10);
117 const renameArrowIndex = rawPath.indexOf(" => ");
118 const normalizedPath =
119 renameArrowIndex >= 0 ? rawPath.slice(renameArrowIndex + " => ".length).trim() : rawPath;
120 entries.push({
121 path: normalizedPath.length > 0 ? normalizedPath : rawPath,
122 insertions: Number.isFinite(added) ? added : 0,
123 deletions: Number.isFinite(deleted) ? deleted : 0,
124 });
125 }
126 return entries;
127}
128
129function splitNullSeparatedPaths(input: string, truncated: boolean): string[] {
130 const parts = input.split("\0");
131 if (parts.length === 0) return [];
132
133 if (truncated && parts[parts.length - 1]?.length) {
134 parts.pop();
135 }
136
137 return parts.filter((value) => value.length > 0);
138}
139
140function chunkPathsForGitCheckIgnore(relativePaths: readonly string[]): string[][] {
141 const chunks: string[][] = [];
142 let chunk: string[] = [];
143 let chunkBytes = 0;
144
145 for (const relativePath of relativePaths) {
146 const relativePathBytes = Buffer.byteLength(relativePath) + 1;
147 if (chunk.length > 0 && chunkBytes + relativePathBytes > GIT_CHECK_IGNORE_MAX_STDIN_BYTES) {
148 chunks.push(chunk);
149 chunk = [];
150 chunkBytes = 0;
151 }
152
153 chunk.push(relativePath);
154 chunkBytes += relativePathBytes;
155
156 if (chunkBytes >= GIT_CHECK_IGNORE_MAX_STDIN_BYTES) {
157 chunks.push(chunk);
158 chunk = [];
159 chunkBytes = 0;
160 }
161 }
162
163 if (chunk.length > 0) {
164 chunks.push(chunk);
165 }
166
167 return chunks;
168}
169
170function parsePorcelainPath(line: string): string | null {
171 if (line.startsWith("? ") || line.startsWith("! ")) {
172 const simple = line.slice(2).trim();
173 return simple.length > 0 ? simple : null;
174 }
175
176 if (!(line.startsWith("1 ") || line.startsWith("2 ") || line.startsWith("u "))) {
177 return null;
178 }
179
180 const tabIndex = line.indexOf("\t");
181 if (tabIndex >= 0) {
182 const fromTab = line.slice(tabIndex + 1);
183 const [filePath] = fromTab.split("\t");
184 return filePath?.trim().length ? filePath.trim() : null;
185 }
186
187 const parts = line.trim().split(/\s+/g);
188 const filePath = parts.at(-1) ?? "";
189 return filePath.length > 0 ? filePath : null;
190}
191
192function parseBranchLine(line: string): { name: string; current: boolean } | null {
193 const trimmed = line.trim();
194 if (trimmed.length === 0) return null;
195
196 const name = trimmed.replace(/^[*+]\s+/, "");
197 // Exclude symbolic refs like: "origin/HEAD -> origin/main".
198 // Exclude detached HEAD pseudo-refs like: "(HEAD detached at origin/main)".
199 if (name.includes(" -> ") || name.startsWith("(")) return null;
200
201 return {
202 name,
203 current: trimmed.startsWith("* "),
204 };
205}
206
207function filterBranchesForListQuery(
208 branches: ReadonlyArray<GitBranch>,
209 query?: string,
210): ReadonlyArray<GitBranch> {
211 if (!query) {
212 return branches;
213 }
214
215 const normalizedQuery = query.toLowerCase();
216 return branches.filter((branch) => branch.name.toLowerCase().includes(normalizedQuery));
217}
218
219function paginateBranches(input: {
220 branches: ReadonlyArray<GitBranch>;
221 cursor?: number | undefined;
222 limit?: number | undefined;
223}): {
224 branches: ReadonlyArray<GitBranch>;
225 nextCursor: number | null;
226 totalCount: number;
227} {
228 const cursor = input.cursor ?? 0;
229 const limit = input.limit ?? GIT_LIST_BRANCHES_DEFAULT_LIMIT;
230 const totalCount = input.branches.length;
231 const branches = input.branches.slice(cursor, cursor + limit);
232 const nextCursor = cursor + branches.length < totalCount ? cursor + branches.length : null;
233
234 return {
235 branches,
236 nextCursor,
237 totalCount,
238 };
239}
240
241function sanitizeRemoteName(value: string): string {
242 const sanitized = value
243 .trim()
244 .replace(/[^A-Za-z0-9._-]+/g, "-")
245 .replace(/^-+|-+$/g, "");
246 return sanitized.length > 0 ? sanitized : "fork";
247}
248
249function normalizeRemoteUrl(value: string): string {
250 return value
251 .trim()
252 .replace(/\/+$/g, "")
253 .replace(/\.git$/i, "")
254 .toLowerCase();
255}
256
257function parseRemoteFetchUrls(stdout: string): Map<string, string> {
258 const remotes = new Map<string, string>();
259 for (const line of stdout.split("\n")) {
260 const trimmed = line.trim();
261 if (trimmed.length === 0) continue;
262 const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)$/.exec(trimmed);
263 if (!match) continue;
264 const [, remoteName = "", remoteUrl = "", direction = ""] = match;
265 if (direction !== "fetch" || remoteName.length === 0 || remoteUrl.length === 0) {
266 continue;
267 }
268 remotes.set(remoteName, remoteUrl);
269 }
270 return remotes;
271}
272
273function parseUpstreamRefWithRemoteNames(
274 upstreamRef: string,
275 remoteNames: ReadonlyArray<string>,
276): { upstreamRef: string; remoteName: string; upstreamBranch: string } | null {
277 const parsed = parseRemoteRefWithRemoteNames(upstreamRef, remoteNames);
278 if (!parsed) {
279 return null;
280 }
281
282 return {
283 upstreamRef,
284 remoteName: parsed.remoteName,
285 upstreamBranch: parsed.branchName,
286 };
287}
288
289function parseUpstreamRefByFirstSeparator(
290 upstreamRef: string,
291): { upstreamRef: string; remoteName: string; upstreamBranch: string } | null {
292 const separatorIndex = upstreamRef.indexOf("/");
293 if (separatorIndex <= 0 || separatorIndex === upstreamRef.length - 1) {
294 return null;
295 }
296
297 const remoteName = upstreamRef.slice(0, separatorIndex).trim();
298 const upstreamBranch = upstreamRef.slice(separatorIndex + 1).trim();
299 if (remoteName.length === 0 || upstreamBranch.length === 0) {
300 return null;
301 }
302
303 return {
304 upstreamRef,
305 remoteName,
306 upstreamBranch,
307 };
308}
309
310function parseTrackingBranchByUpstreamRef(stdout: string, upstreamRef: string): string | null {
311 for (const line of stdout.split("\n")) {
312 const trimmedLine = line.trim();
313 if (trimmedLine.length === 0) {
314 continue;
315 }
316 const [branchNameRaw, upstreamBranchRaw = ""] = trimmedLine.split("\t");
317 const branchName = branchNameRaw?.trim() ?? "";
318 const upstreamBranch = upstreamBranchRaw.trim();
319 if (branchName.length === 0 || upstreamBranch.length === 0) {
320 continue;
321 }
322 if (upstreamBranch === upstreamRef) {
323 return branchName;
324 }
325 }
326
327 return null;
328}
329
330function deriveLocalBranchNameFromRemoteRef(branchName: string): string | null {
331 const separatorIndex = branchName.indexOf("/");
332 if (separatorIndex <= 0 || separatorIndex === branchName.length - 1) {
333 return null;
334 }
335 const localBranch = branchName.slice(separatorIndex + 1).trim();
336 return localBranch.length > 0 ? localBranch : null;
337}
338
339function commandLabel(args: readonly string[]): string {
340 return `git ${args.join(" ")}`;
341}
342
343function parseDefaultBranchFromRemoteHeadRef(value: string, remoteName: string): string | null {
344 const trimmed = value.trim();
345 const prefix = `refs/remotes/${remoteName}/`;
346 if (!trimmed.startsWith(prefix)) {
347 return null;
348 }
349 const branch = trimmed.slice(prefix.length).trim();
350 return branch.length > 0 ? branch : null;
351}
352
353function createGitCommandError(
354 operation: string,
355 cwd: string,
356 args: readonly string[],
357 detail: string,
358 cause?: unknown,
359): GitCommandError {
360 return new GitCommandError({
361 operation,
362 command: commandLabel(args),
363 cwd,
364 detail,
365 ...(cause !== undefined ? { cause } : {}),
366 });
367}
368
369function quoteGitCommand(args: ReadonlyArray<string>): string {
370 return `git ${args.join(" ")}`;
371}
372
373function isMissingGitCwdError(error: GitCommandError): boolean {
374 const normalized = `${error.detail}\n${error.message}`.toLowerCase();
375 return (
376 normalized.includes("no such file or directory") ||
377 normalized.includes("notfound: filesystem.access") ||
378 normalized.includes("enoent") ||
379 normalized.includes("not a directory")
380 );
381}
382
383function toGitCommandError(
384 input: Pick<ExecuteGitInput, "operation" | "cwd" | "args">,
385 detail: string,
386) {
387 return (cause: unknown) =>
388 Schema.is(GitCommandError)(cause)
389 ? cause
390 : new GitCommandError({
391 operation: input.operation,
392 command: quoteGitCommand(input.args),
393 cwd: input.cwd,
394 detail: `${cause instanceof Error && cause.message.length > 0 ? cause.message : "Unknown error"} - ${detail}`,
395 ...(cause !== undefined ? { cause } : {}),
396 });
397}
398
399interface Trace2Monitor {
400 readonly env: NodeJS.ProcessEnv;
401 readonly flush: Effect.Effect<void, never>;
402}
403
404const nowUnixNano = (): bigint => BigInt(Date.now()) * 1_000_000n;
405
406const addCurrentSpanEvent = (name: string, attributes: Record<string, unknown>) =>
407 Effect.currentSpan.pipe(
408 Effect.tap((span) =>
409 Effect.sync(() => {
410 span.event(name, nowUnixNano(), compactTraceAttributes(attributes));
411 }),
412 ),
413 Effect.catch(() => Effect.void),
414 );
415
416function trace2ChildKey(record: Record<string, unknown>): string | null {
417 const childId = record.child_id;
418 if (typeof childId === "number" || typeof childId === "string") {
419 return String(childId);
420 }
421 const hookName = record.hook_name;
422 return typeof hookName === "string" && hookName.trim().length > 0 ? hookName.trim() : null;
423}
424
425const Trace2Record = Schema.Record(Schema.String, Schema.Unknown);
426
427const createTrace2Monitor = Effect.fn("createTrace2Monitor")(function* (
428 input: Pick<ExecuteGitInput, "operation" | "cwd" | "args">,
429 progress: ExecuteGitProgress | undefined,
430): Effect.fn.Return<
431 Trace2Monitor,
432 PlatformError.PlatformError,
433 Scope.Scope | FileSystem.FileSystem | Path.Path
434> {
435 if (!progress?.onHookStarted && !progress?.onHookFinished) {
436 return {
437 env: {},
438 flush: Effect.void,
439 };
440 }
441
442 const fs = yield* FileSystem.FileSystem;
443 const path = yield* Path.Path;
444 const traceFilePath = yield* fs.makeTempFileScoped({
445 prefix: `t3code-git-trace2-${process.pid}-`,
446 suffix: ".json",
447 });
448 const hookStartByChildKey = new Map<string, { hookName: string; startedAtMs: number }>();
449 const traceTailState = yield* Ref.make<TraceTailState>({
450 processedChars: 0,
451 remainder: "",
452 });
453
454 const handleTraceLine = Effect.fn("handleTraceLine")(function* (line: string) {
455 const trimmedLine = line.trim();
456 if (trimmedLine.length === 0) {
457 return;
458 }
459
460 const traceRecord = decodeJsonResult(Trace2Record)(trimmedLine);
461 if (Result.isFailure(traceRecord)) {
462 yield* Effect.logDebug(
463 `GitCore.trace2: failed to parse trace line for ${quoteGitCommand(input.args)} in ${input.cwd}`,
464 traceRecord.failure,
465 );
466 return;
467 }
468
469 if (traceRecord.success.child_class !== "hook") {
470 return;
471 }
472
473 const event = traceRecord.success.event;
474 const childKey = trace2ChildKey(traceRecord.success);
475 if (childKey === null) {
476 return;
477 }
478 const started = hookStartByChildKey.get(childKey);
479 const hookNameFromEvent =
480 typeof traceRecord.success.hook_name === "string" ? traceRecord.success.hook_name.trim() : "";
481 const hookName = hookNameFromEvent.length > 0 ? hookNameFromEvent : (started?.hookName ?? "");
482 if (hookName.length === 0) {
483 return;
484 }
485
486 if (event === "child_start") {
487 hookStartByChildKey.set(childKey, { hookName, startedAtMs: Date.now() });
488 yield* addCurrentSpanEvent("git.hook.started", {
489 hookName,
490 });
491 if (progress.onHookStarted) {
492 yield* progress.onHookStarted(hookName);
493 }
494 return;
495 }
496
497 if (event === "child_exit") {
498 hookStartByChildKey.delete(childKey);
499 const code = traceRecord.success.code;
500 const exitCode = typeof code === "number" && Number.isInteger(code) ? code : null;
501 const durationMs = started ? Math.max(0, Date.now() - started.startedAtMs) : null;
502 yield* addCurrentSpanEvent("git.hook.finished", {
503 hookName: started?.hookName ?? hookName,
504 exitCode,
505 durationMs,
506 });
507 if (progress.onHookFinished) {
508 yield* progress.onHookFinished({
509 hookName: started?.hookName ?? hookName,
510 exitCode,
511 durationMs,
512 });
513 }
514 }
515 });
516
517 const deltaMutex = yield* Semaphore.make(1);
518 const readTraceDelta = deltaMutex.withPermit(
519 fs.readFileString(traceFilePath).pipe(
520 Effect.flatMap((contents) =>
521 Effect.uninterruptible(
522 Ref.modify(traceTailState, ({ processedChars, remainder }) => {
523 if (contents.length <= processedChars) {
524 return [[], { processedChars, remainder }];
525 }
526
527 const appended = contents.slice(processedChars);
528 const combined = remainder + appended;
529 const lines = combined.split("\n");
530 const nextRemainder = lines.pop() ?? "";
531
532 return [
533 lines.map((line) => line.replace(/\r$/, "")),
534 {
535 processedChars: contents.length,
536 remainder: nextRemainder,
537 },
538 ];
539 }).pipe(
540 Effect.flatMap((lines) => Effect.forEach(lines, handleTraceLine, { discard: true })),
541 ),
542 ),
543 ),
544 Effect.ignore({ log: true }),
545 ),
546 );
547 const traceFileName = path.basename(traceFilePath);
548 yield* Stream.runForEach(fs.watch(traceFilePath), (event) => {
549 const eventPath = event.path;
550 const isTargetTraceEvent =
551 eventPath === traceFilePath ||
552 eventPath === traceFileName ||
553 path.basename(eventPath) === traceFileName;
554 if (!isTargetTraceEvent) return Effect.void;
555 return readTraceDelta;
556 }).pipe(Effect.ignoreCause({ log: true }), Effect.forkScoped);
557
558 const finalizeTrace2Monitor = Effect.fn("finalizeTrace2Monitor")(function* () {
559 yield* readTraceDelta;
560 const finalLine = yield* Ref.modify(traceTailState, ({ processedChars, remainder }) => [
561 remainder.trim(),
562 {
563 processedChars,
564 remainder: "",
565 },
566 ]);
567 if (finalLine.length > 0) {
568 yield* handleTraceLine(finalLine);
569 }
570 });
571
572 yield* Effect.addFinalizer(finalizeTrace2Monitor);
573
574 return {
575 env: {
576 GIT_TRACE2_EVENT: traceFilePath,
577 },
578 flush: readTraceDelta,
579 };
580});
581
582const collectOutput = Effect.fn("collectOutput")(function* <E>(
583 input: Pick<ExecuteGitInput, "operation" | "cwd" | "args">,
584 stream: Stream.Stream<Uint8Array, E>,
585 maxOutputBytes: number,
586 truncateOutputAtMaxBytes: boolean,
587 onLine: ((line: string) => Effect.Effect<void, never>) | undefined,
588): Effect.fn.Return<{ readonly text: string; readonly truncated: boolean }, GitCommandError> {
589 const decoder = new TextDecoder();
590 let bytes = 0;
591 let text = "";
592 let lineBuffer = "";
593 let truncated = false;
594
595 const emitCompleteLines = Effect.fn("emitCompleteLines")(function* (flush: boolean) {
596 let newlineIndex = lineBuffer.indexOf("\n");
597 while (newlineIndex >= 0) {
598 const line = lineBuffer.slice(0, newlineIndex).replace(/\r$/, "");
599 lineBuffer = lineBuffer.slice(newlineIndex + 1);
600 if (line.length > 0 && onLine) {
601 yield* onLine(line);
602 }
603 newlineIndex = lineBuffer.indexOf("\n");
604 }
605
606 if (flush) {
607 const trailing = lineBuffer.replace(/\r$/, "");
608 lineBuffer = "";
609 if (trailing.length > 0 && onLine) {
610 yield* onLine(trailing);
611 }
612 }
613 });
614
615 const processChunk = Effect.fn("processChunk")(function* (chunk: Uint8Array) {
616 if (truncateOutputAtMaxBytes && truncated) {
617 return;
618 }
619 const nextBytes = bytes + chunk.byteLength;
620 if (!truncateOutputAtMaxBytes && nextBytes > maxOutputBytes) {
621 return yield* new GitCommandError({
622 operation: input.operation,
623 command: quoteGitCommand(input.args),
624 cwd: input.cwd,
625 detail: `${quoteGitCommand(input.args)} output exceeded ${maxOutputBytes} bytes and was truncated.`,
626 });
627 }
628
629 const chunkToDecode =
630 truncateOutputAtMaxBytes && nextBytes > maxOutputBytes
631 ? chunk.subarray(0, Math.max(0, maxOutputBytes - bytes))
632 : chunk;
633 bytes += chunkToDecode.byteLength;
634 truncated = truncateOutputAtMaxBytes && nextBytes > maxOutputBytes;
635
636 const decoded = decoder.decode(chunkToDecode, { stream: !truncated });
637 text += decoded;
638 lineBuffer += decoded;
639 yield* emitCompleteLines(false);
640 });
641
642 yield* Stream.runForEach(stream, processChunk).pipe(
643 Effect.mapError(toGitCommandError(input, "output stream failed.")),
644 );
645
646 const remainder = truncated ? "" : decoder.decode();
647 text += remainder;
648 lineBuffer += remainder;
649 yield* emitCompleteLines(true);
650 return {
651 text,
652 truncated,
653 };
654});
655
656export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
657 executeOverride?: GitCoreShape["execute"];
658}) {
659 const fileSystem = yield* FileSystem.FileSystem;
660 const path = yield* Path.Path;
661 const { worktreesDir } = yield* ServerConfig;
662
663 let executeRaw: GitCoreShape["execute"];
664
665 if (options?.executeOverride) {
666 executeRaw = options.executeOverride;
667 } else {
668 const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
669 executeRaw = Effect.fnUntraced(function* (input) {
670 const commandInput = {
671 ...input,
672 args: [...input.args],
673 } as const;
674 const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
675 const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
676 const truncateOutputAtMaxBytes = input.truncateOutputAtMaxBytes ?? false;
677
678 const runGitCommand = Effect.fn("runGitCommand")(function* () {
679 const trace2Monitor = yield* createTrace2Monitor(commandInput, input.progress).pipe(
680 Effect.provideService(Path.Path, path),
681 Effect.provideService(FileSystem.FileSystem, fileSystem),
682 Effect.mapError(toGitCommandError(commandInput, "failed to create trace2 monitor.")),
683 );
684 const child = yield* commandSpawner
685 .spawn(
686 ChildProcess.make("git", commandInput.args, {
687 cwd: commandInput.cwd,
688 env: {
689 ...process.env,
690 ...input.env,
691 ...trace2Monitor.env,
692 },
693 }),
694 )
695 .pipe(Effect.mapError(toGitCommandError(commandInput, "failed to spawn.")));
696
697 const [stdout, stderr, exitCode] = yield* Effect.all(
698 [
699 collectOutput(
700 commandInput,
701 child.stdout,
702 maxOutputBytes,
703 truncateOutputAtMaxBytes,
704 input.progress?.onStdoutLine,
705 ),
706 collectOutput(
707 commandInput,
708 child.stderr,
709 maxOutputBytes,
710 truncateOutputAtMaxBytes,
711 input.progress?.onStderrLine,
712 ),
713 child.exitCode.pipe(
714 Effect.map((value) => Number(value)),
715 Effect.mapError(toGitCommandError(commandInput, "failed to report exit code.")),
716 ),
717 input.stdin === undefined
718 ? Effect.void
719 : Stream.run(Stream.encodeText(Stream.make(input.stdin)), child.stdin).pipe(
720 Effect.mapError(toGitCommandError(commandInput, "failed to write stdin.")),
721 ),
722 ],
723 { concurrency: "unbounded" },
724 ).pipe(Effect.map(([stdout, stderr, exitCode]) => [stdout, stderr, exitCode] as const));
725 yield* trace2Monitor.flush;
726
727 if (!input.allowNonZeroExit && exitCode !== 0) {
728 const trimmedStderr = stderr.text.trim();
729 return yield* new GitCommandError({
730 operation: commandInput.operation,
731 command: quoteGitCommand(commandInput.args),
732 cwd: commandInput.cwd,
733 detail:
734 trimmedStderr.length > 0
735 ? `${quoteGitCommand(commandInput.args)} failed: ${trimmedStderr}`
736 : `${quoteGitCommand(commandInput.args)} failed with code ${exitCode}.`,
737 });
738 }
739
740 return {
741 code: exitCode,
742 stdout: stdout.text,
743 stderr: stderr.text,
744 stdoutTruncated: stdout.truncated,
745 stderrTruncated: stderr.truncated,
746 } satisfies ExecuteGitResult;
747 });
748
749 return yield* runGitCommand().pipe(
750 Effect.scoped,
751 Effect.timeoutOption(timeoutMs),
752 Effect.flatMap((result) =>
753 Option.match(result, {
754 onNone: () =>
755 Effect.fail(
756 new GitCommandError({
757 operation: commandInput.operation,
758 command: quoteGitCommand(commandInput.args),
759 cwd: commandInput.cwd,
760 detail: `${quoteGitCommand(commandInput.args)} timed out.`,
761 }),
762 ),
763 onSome: Effect.succeed,
764 }),
765 ),
766 );
767 });
768 }
769
770 const execute: GitCoreShape["execute"] = (input) =>
771 executeRaw(input).pipe(
772 withMetrics({
773 counter: gitCommandsTotal,
774 timer: gitCommandDuration,
775 attributes: {
776 operation: input.operation,
777 },
778 }),
779 Effect.withSpan(input.operation, {
780 kind: "client",
781 attributes: {
782 "git.operation": input.operation,
783 "git.cwd": input.cwd,
784 "git.args_count": input.args.length,
785 },
786 }),
787 );
788
789 const executeGit = (
790 operation: string,
791 cwd: string,
792 args: readonly string[],
793 options: ExecuteGitOptions = {},
794 ): Effect.Effect<ExecuteGitResult, GitCommandError> =>
795 execute({
796 operation,
797 cwd,
798 args,
799 ...(options.stdin !== undefined ? { stdin: options.stdin } : {}),
800 allowNonZeroExit: true,
801 ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
802 ...(options.maxOutputBytes !== undefined ? { maxOutputBytes: options.maxOutputBytes } : {}),
803 ...(options.truncateOutputAtMaxBytes !== undefined
804 ? { truncateOutputAtMaxBytes: options.truncateOutputAtMaxBytes }
805 : {}),
806 ...(options.progress ? { progress: options.progress } : {}),
807 }).pipe(
808 Effect.flatMap((result) => {
809 if (options.allowNonZeroExit || result.code === 0) {
810 return Effect.succeed(result);
811 }
812 const stderr = result.stderr.trim();
813 if (stderr.length > 0) {
814 return Effect.fail(createGitCommandError(operation, cwd, args, stderr));
815 }
816 if (options.fallbackErrorMessage) {
817 return Effect.fail(
818 createGitCommandError(operation, cwd, args, options.fallbackErrorMessage),
819 );
820 }
821 return Effect.fail(
822 createGitCommandError(
823 operation,
824 cwd,
825 args,
826 `${commandLabel(args)} failed: code=${result.code ?? "null"}`,
827 ),
828 );
829 }),
830 );
831
832 const runGit = (
833 operation: string,
834 cwd: string,
835 args: readonly string[],
836 allowNonZeroExit = false,
837 ): Effect.Effect<void, GitCommandError> =>
838 executeGit(operation, cwd, args, { allowNonZeroExit }).pipe(Effect.asVoid);
839
840 const runGitStdout = (
841 operation: string,
842 cwd: string,
843 args: readonly string[],
844 allowNonZeroExit = false,
845 ): Effect.Effect<string, GitCommandError> =>
846 executeGit(operation, cwd, args, { allowNonZeroExit }).pipe(
847 Effect.map((result) => result.stdout),
848 );
849
850 const runGitStdoutWithOptions = (
851 operation: string,
852 cwd: string,
853 args: readonly string[],
854 options: ExecuteGitOptions = {},
855 ): Effect.Effect<string, GitCommandError> =>
856 executeGit(operation, cwd, args, options).pipe(
857 Effect.map((result) =>
858 result.stdoutTruncated ? `${result.stdout}${OUTPUT_TRUNCATED_MARKER}` : result.stdout,
859 ),
860 );
861
862 const branchExists = (cwd: string, branch: string): Effect.Effect<boolean, GitCommandError> =>
863 executeGit(
864 "GitCore.branchExists",
865 cwd,
866 ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`],
867 {
868 allowNonZeroExit: true,
869 timeoutMs: 5_000,
870 },
871 ).pipe(Effect.map((result) => result.code === 0));
872
873 const resolveAvailableBranchName = Effect.fn("resolveAvailableBranchName")(function* (
874 cwd: string,
875 desiredBranch: string,
876 ) {
877 const isDesiredTaken = yield* branchExists(cwd, desiredBranch);
878 if (!isDesiredTaken) {
879 return desiredBranch;
880 }
881
882 for (let suffix = 1; suffix <= 100; suffix += 1) {
883 const candidate = `${desiredBranch}-${suffix}`;
884 const isCandidateTaken = yield* branchExists(cwd, candidate);
885 if (!isCandidateTaken) {
886 return candidate;
887 }
888 }
889
890 return yield* createGitCommandError(
891 "GitCore.renameBranch",
892 cwd,
893 ["branch", "-m", "--", desiredBranch],
894 `Could not find an available branch name for '${desiredBranch}'.`,
895 );
896 });
897
898 const resolveCurrentUpstream = Effect.fn("resolveCurrentUpstream")(function* (cwd: string) {
899 const upstreamRef = yield* runGitStdout(
900 "GitCore.resolveCurrentUpstream",
901 cwd,
902 ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"],
903 true,
904 ).pipe(Effect.map((stdout) => stdout.trim()));
905
906 if (upstreamRef.length === 0 || upstreamRef === "@{upstream}") {
907 return null;
908 }
909
910 const remoteNames = yield* runGitStdout("GitCore.listRemoteNames", cwd, ["remote"]).pipe(
911 Effect.map(parseRemoteNames),
912 Effect.catch(() => Effect.succeed<ReadonlyArray<string>>([])),
913 );
914 return (
915 parseUpstreamRefWithRemoteNames(upstreamRef, remoteNames) ??
916 parseUpstreamRefByFirstSeparator(upstreamRef)
917 );
918 });
919
920 const fetchRemoteForStatus = (
921 gitCommonDir: string,
922 remoteName: string,
923 ): Effect.Effect<void, GitCommandError> => {
924 const fetchCwd =
925 path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir;
926 return executeGit(
927 "GitCore.fetchRemoteForStatus",
928 fetchCwd,
929 ["--git-dir", gitCommonDir, "fetch", "--quiet", "--no-tags", remoteName],
930 {
931 allowNonZeroExit: true,
932 timeoutMs: Duration.toMillis(STATUS_UPSTREAM_REFRESH_TIMEOUT),
933 },
934 ).pipe(Effect.asVoid);
935 };
936
937 const resolveGitCommonDir = Effect.fn("resolveGitCommonDir")(function* (cwd: string) {
938 const gitCommonDir = yield* runGitStdout("GitCore.resolveGitCommonDir", cwd, [
939 "rev-parse",
940 "--git-common-dir",
941 ]).pipe(Effect.map((stdout) => stdout.trim()));
942 return path.isAbsolute(gitCommonDir) ? gitCommonDir : path.resolve(cwd, gitCommonDir);
943 });
944
945 const refreshStatusRemoteCacheEntry = Effect.fn("refreshStatusRemoteCacheEntry")(function* (
946 cacheKey: StatusRemoteRefreshCacheKey,
947 ) {
948 yield* fetchRemoteForStatus(cacheKey.gitCommonDir, cacheKey.remoteName);
949 return true as const;
950 });
951
952 const statusRemoteRefreshCache = yield* Cache.makeWith(refreshStatusRemoteCacheEntry, {
953 capacity: STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY,
954 // Keep successful refreshes warm and briefly back off failed refreshes to avoid retry storms.
955 timeToLive: (exit) =>
956 Exit.isSuccess(exit)
957 ? STATUS_UPSTREAM_REFRESH_INTERVAL
958 : STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN,
959 });
960
961 const refreshStatusUpstreamIfStale = Effect.fn("refreshStatusUpstreamIfStale")(function* (
962 cwd: string,
963 ) {
964 const upstream = yield* resolveCurrentUpstream(cwd);
965 if (!upstream) return;
966 const gitCommonDir = yield* resolveGitCommonDir(cwd);
967 yield* Cache.get(
968 statusRemoteRefreshCache,
969 new StatusRemoteRefreshCacheKey({
970 gitCommonDir,
971 remoteName: upstream.remoteName,
972 }),
973 );
974 });
975
976 const resolveDefaultBranchName = (
977 cwd: string,
978 remoteName: string,
979 ): Effect.Effect<string | null, GitCommandError> =>
980 executeGit(
981 "GitCore.resolveDefaultBranchName",
982 cwd,
983 ["symbolic-ref", `refs/remotes/${remoteName}/HEAD`],
984 { allowNonZeroExit: true },
985 ).pipe(
986 Effect.map((result) => {
987 if (result.code !== 0) {
988 return null;
989 }
990 return parseDefaultBranchFromRemoteHeadRef(result.stdout, remoteName);
991 }),
992 );
993
994 const remoteBranchExists = (
995 cwd: string,
996 remoteName: string,
997 branch: string,
998 ): Effect.Effect<boolean, GitCommandError> =>
999 executeGit(
1000 "GitCore.remoteBranchExists",
1001 cwd,
1002 ["show-ref", "--verify", "--quiet", `refs/remotes/${remoteName}/${branch}`],
1003 {
1004 allowNonZeroExit: true,
1005 },
1006 ).pipe(Effect.map((result) => result.code === 0));
1007
1008 const originRemoteExists = (cwd: string): Effect.Effect<boolean, GitCommandError> =>
1009 executeGit("GitCore.originRemoteExists", cwd, ["remote", "get-url", "origin"], {
1010 allowNonZeroExit: true,
1011 }).pipe(Effect.map((result) => result.code === 0));
1012
1013 const listRemoteNames = (cwd: string): Effect.Effect<ReadonlyArray<string>, GitCommandError> =>
1014 runGitStdout("GitCore.listRemoteNames", cwd, ["remote"]).pipe(
1015 Effect.map(parseRemoteNamesInGitOrder),
1016 );
1017
1018 const resolvePrimaryRemoteName = Effect.fn("resolvePrimaryRemoteName")(function* (cwd: string) {
1019 if (yield* originRemoteExists(cwd)) {
1020 return "origin";
1021 }
1022 const remotes = yield* listRemoteNames(cwd);
1023 const [firstRemote] = remotes;
1024 if (firstRemote) {
1025 return firstRemote;
1026 }
1027 return yield* createGitCommandError(
1028 "GitCore.resolvePrimaryRemoteName",
1029 cwd,
1030 ["remote"],
1031 "No git remote is configured for this repository.",
1032 );
1033 });
1034
1035 const resolvePushRemoteName = Effect.fn("resolvePushRemoteName")(function* (
1036 cwd: string,
1037 branch: string,
1038 ) {
1039 const branchPushRemote = yield* runGitStdout(
1040 "GitCore.resolvePushRemoteName.branchPushRemote",
1041 cwd,
1042 ["config", "--get", `branch.${branch}.pushRemote`],
1043 true,
1044 ).pipe(Effect.map((stdout) => stdout.trim()));
1045 if (branchPushRemote.length > 0) {
1046 return branchPushRemote;
1047 }
1048
1049 const pushDefaultRemote = yield* runGitStdout(
1050 "GitCore.resolvePushRemoteName.remotePushDefault",
1051 cwd,
1052 ["config", "--get", "remote.pushDefault"],
1053 true,
1054 ).pipe(Effect.map((stdout) => stdout.trim()));
1055 if (pushDefaultRemote.length > 0) {
1056 return pushDefaultRemote;
1057 }
1058
1059 return yield* resolvePrimaryRemoteName(cwd).pipe(Effect.catch(() => Effect.succeed(null)));
1060 });
1061
1062 const ensureRemote: GitCoreShape["ensureRemote"] = Effect.fn("ensureRemote")(function* (input) {
1063 const preferredName = sanitizeRemoteName(input.preferredName);
1064 const normalizedTargetUrl = normalizeRemoteUrl(input.url);
1065 const remoteFetchUrls = yield* runGitStdout("GitCore.ensureRemote.listRemoteUrls", input.cwd, [
1066 "remote",
1067 "-v",
1068 ]).pipe(Effect.map((stdout) => parseRemoteFetchUrls(stdout)));
1069
1070 for (const [remoteName, remoteUrl] of remoteFetchUrls.entries()) {
1071 if (normalizeRemoteUrl(remoteUrl) === normalizedTargetUrl) {
1072 return remoteName;
1073 }
1074 }
1075
1076 let remoteName = preferredName;
1077 let suffix = 1;
1078 while (remoteFetchUrls.has(remoteName)) {
1079 remoteName = `${preferredName}-${suffix}`;
1080 suffix += 1;
1081 }
1082
1083 yield* runGit("GitCore.ensureRemote.add", input.cwd, ["remote", "add", remoteName, input.url]);
1084 return remoteName;
1085 });
1086
1087 const resolveBaseBranchForNoUpstream = Effect.fn("resolveBaseBranchForNoUpstream")(function* (
1088 cwd: string,
1089 branch: string,
1090 ) {
1091 const configuredBaseBranch = yield* runGitStdout(
1092 "GitCore.resolveBaseBranchForNoUpstream.config",
1093 cwd,
1094 ["config", "--get", `branch.${branch}.gh-merge-base`],
1095 true,
1096 ).pipe(Effect.map((stdout) => stdout.trim()));
1097
1098 const primaryRemoteName = yield* resolvePrimaryRemoteName(cwd).pipe(
1099 Effect.catch(() => Effect.succeed(null)),
1100 );
1101 const defaultBranch =
1102 primaryRemoteName === null ? null : yield* resolveDefaultBranchName(cwd, primaryRemoteName);
1103 const candidates = [
1104 configuredBaseBranch.length > 0 ? configuredBaseBranch : null,
1105 defaultBranch,
1106 ...DEFAULT_BASE_BRANCH_CANDIDATES,
1107 ];
1108
1109 for (const candidate of candidates) {
1110 if (!candidate) {
1111 continue;
1112 }
1113
1114 const remotePrefix =
1115 primaryRemoteName && primaryRemoteName !== "origin" ? `${primaryRemoteName}/` : null;
1116 const normalizedCandidate = candidate.startsWith("origin/")
1117 ? candidate.slice("origin/".length)
1118 : remotePrefix && candidate.startsWith(remotePrefix)
1119 ? candidate.slice(remotePrefix.length)
1120 : candidate;
1121 if (normalizedCandidate.length === 0 || normalizedCandidate === branch) {
1122 continue;
1123 }
1124
1125 if (yield* branchExists(cwd, normalizedCandidate)) {
1126 return normalizedCandidate;
1127 }
1128
1129 if (
1130 primaryRemoteName &&
1131 (yield* remoteBranchExists(cwd, primaryRemoteName, normalizedCandidate))
1132 ) {
1133 return `${primaryRemoteName}/${normalizedCandidate}`;
1134 }
1135 }
1136
1137 return null;
1138 });
1139
1140 const computeAheadCountAgainstBase = Effect.fn("computeAheadCountAgainstBase")(function* (
1141 cwd: string,
1142 branch: string,
1143 ) {
1144 const baseBranch = yield* resolveBaseBranchForNoUpstream(cwd, branch);
1145 if (!baseBranch) {
1146 return 0;
1147 }
1148
1149 const result = yield* executeGit(
1150 "GitCore.computeAheadCountAgainstBase",
1151 cwd,
1152 ["rev-list", "--count", `${baseBranch}..HEAD`],
1153 { allowNonZeroExit: true },
1154 );
1155 if (result.code !== 0) {
1156 return 0;
1157 }
1158
1159 const parsed = Number.parseInt(result.stdout.trim(), 10);
1160 return Number.isFinite(parsed) ? Math.max(0, parsed) : 0;
1161 });
1162
1163 const readBranchRecency = Effect.fn("readBranchRecency")(function* (cwd: string) {
1164 const branchRecency = yield* executeGit(
1165 "GitCore.readBranchRecency",
1166 cwd,
1167 [
1168 "for-each-ref",
1169 "--format=%(refname:short)%09%(committerdate:unix)",
1170 "refs/heads",
1171 "refs/remotes",
1172 ],
1173 {
1174 timeoutMs: 15_000,
1175 allowNonZeroExit: true,
1176 },
1177 );
1178
1179 const branchLastCommit = new Map<string, number>();
1180 if (branchRecency.code !== 0) {
1181 return branchLastCommit;
1182 }
1183
1184 for (const line of branchRecency.stdout.split("\n")) {
1185 if (line.length === 0) {
1186 continue;
1187 }
1188 const [name, lastCommitRaw] = line.split("\t");
1189 if (!name) {
1190 continue;
1191 }
1192 const lastCommit = Number.parseInt(lastCommitRaw ?? "0", 10);
1193 branchLastCommit.set(name, Number.isFinite(lastCommit) ? lastCommit : 0);
1194 }
1195
1196 return branchLastCommit;
1197 });
1198
1199 const readStatusDetailsLocal = Effect.fn("readStatusDetailsLocal")(function* (cwd: string) {
1200 const statusResult = yield* executeGit(
1201 "GitCore.statusDetails.status",
1202 cwd,
1203 ["status", "--porcelain=2", "--branch"],
1204 {
1205 allowNonZeroExit: true,
1206 },
1207 ).pipe(Effect.catchIf(isMissingGitCwdError, () => Effect.succeed(null)));
1208
1209 if (statusResult === null) {
1210 return NON_REPOSITORY_STATUS_DETAILS;
1211 }
1212
1213 if (statusResult.code !== 0) {
1214 const stderr = statusResult.stderr.trim();
1215 return yield* createGitCommandError(
1216 "GitCore.statusDetails.status",
1217 cwd,
1218 ["status", "--porcelain=2", "--branch"],
1219 stderr || "git status failed",
1220 );
1221 }
1222
1223 const [unstagedNumstatStdout, stagedNumstatStdout, defaultRefResult, hasOriginRemote] =
1224 yield* Effect.all(
1225 [
1226 runGitStdout("GitCore.statusDetails.unstagedNumstat", cwd, ["diff", "--numstat"]),
1227 runGitStdout("GitCore.statusDetails.stagedNumstat", cwd, [
1228 "diff",
1229 "--cached",
1230 "--numstat",
1231 ]),
1232 executeGit(
1233 "GitCore.statusDetails.defaultRef",
1234 cwd,
1235 ["symbolic-ref", "refs/remotes/origin/HEAD"],
1236 {
1237 allowNonZeroExit: true,
1238 },
1239 ),
1240 originRemoteExists(cwd).pipe(Effect.catch(() => Effect.succeed(false))),
1241 ],
1242 { concurrency: "unbounded" },
1243 );
1244 const statusStdout = statusResult.stdout;
1245 const defaultBranch =
1246 defaultRefResult.code === 0
1247 ? defaultRefResult.stdout.trim().replace(/^refs\/remotes\/origin\//, "")
1248 : null;
1249
1250 let branch: string | null = null;
1251 let upstreamRef: string | null = null;
1252 let aheadCount = 0;
1253 let behindCount = 0;
1254 let hasWorkingTreeChanges = false;
1255 const changedFilesWithoutNumstat = new Set<string>();
1256
1257 for (const line of statusStdout.split(/\r?\n/g)) {
1258 if (line.startsWith("# branch.head ")) {
1259 const value = line.slice("# branch.head ".length).trim();
1260 branch = value.startsWith("(") ? null : value;
1261 continue;
1262 }
1263 if (line.startsWith("# branch.upstream ")) {
1264 const value = line.slice("# branch.upstream ".length).trim();
1265 upstreamRef = value.length > 0 ? value : null;
1266 continue;
1267 }
1268 if (line.startsWith("# branch.ab ")) {
1269 const value = line.slice("# branch.ab ".length).trim();
1270 const parsed = parseBranchAb(value);
1271 aheadCount = parsed.ahead;
1272 behindCount = parsed.behind;
1273 continue;
1274 }
1275 if (line.trim().length > 0 && !line.startsWith("#")) {
1276 hasWorkingTreeChanges = true;
1277 const pathValue = parsePorcelainPath(line);
1278 if (pathValue) changedFilesWithoutNumstat.add(pathValue);
1279 }
1280 }
1281
1282 if (!upstreamRef && branch) {
1283 aheadCount = yield* computeAheadCountAgainstBase(cwd, branch).pipe(
1284 Effect.catch(() => Effect.succeed(0)),
1285 );
1286 behindCount = 0;
1287 }
1288
1289 const stagedEntries = parseNumstatEntries(stagedNumstatStdout);
1290 const unstagedEntries = parseNumstatEntries(unstagedNumstatStdout);
1291 const fileStatMap = new Map<string, { insertions: number; deletions: number }>();
1292 for (const entry of [...stagedEntries, ...unstagedEntries]) {
1293 const existing = fileStatMap.get(entry.path) ?? { insertions: 0, deletions: 0 };
1294 existing.insertions += entry.insertions;
1295 existing.deletions += entry.deletions;
1296 fileStatMap.set(entry.path, existing);
1297 }
1298
1299 let insertions = 0;
1300 let deletions = 0;
1301 const files = Array.from(fileStatMap.entries())
1302 .map(([filePath, stat]) => {
1303 insertions += stat.insertions;
1304 deletions += stat.deletions;
1305 return { path: filePath, insertions: stat.insertions, deletions: stat.deletions };
1306 })
1307 .toSorted((a, b) => a.path.localeCompare(b.path));
1308
1309 for (const filePath of changedFilesWithoutNumstat) {
1310 if (fileStatMap.has(filePath)) continue;
1311 files.push({ path: filePath, insertions: 0, deletions: 0 });
1312 }
1313 files.sort((a, b) => a.path.localeCompare(b.path));
1314
1315 return {
1316 isRepo: true,
1317 hasOriginRemote,
1318 isDefaultBranch:
1319 branch !== null &&
1320 (branch === defaultBranch ||
1321 (defaultBranch === null && (branch === "main" || branch === "master"))),
1322 branch,
1323 upstreamRef,
1324 hasWorkingTreeChanges,
1325 workingTree: {
1326 files,
1327 insertions,
1328 deletions,
1329 },
1330 hasUpstream: upstreamRef !== null,
1331 aheadCount,
1332 behindCount,
1333 };
1334 });
1335
1336 const statusDetailsLocal: GitCoreShape["statusDetailsLocal"] = Effect.fn("statusDetailsLocal")(
1337 function* (cwd) {
1338 return yield* readStatusDetailsLocal(cwd);
1339 },
1340 );
1341
1342 const statusDetails: GitCoreShape["statusDetails"] = Effect.fn("statusDetails")(function* (cwd) {
1343 yield* refreshStatusUpstreamIfStale(cwd).pipe(
1344 Effect.catchIf(isMissingGitCwdError, () => Effect.void),
1345 Effect.ignoreCause({ log: true }),
1346 );
1347 return yield* readStatusDetailsLocal(cwd);
1348 });
1349
1350 const status: GitCoreShape["status"] = (input) =>
1351 statusDetails(input.cwd).pipe(
1352 Effect.map((details) => ({
1353 isRepo: details.isRepo,
1354 hasOriginRemote: details.hasOriginRemote,
1355 isDefaultBranch: details.isDefaultBranch,
1356 branch: details.branch,
1357 hasWorkingTreeChanges: details.hasWorkingTreeChanges,
1358 workingTree: details.workingTree,
1359 hasUpstream: details.hasUpstream,
1360 aheadCount: details.aheadCount,
1361 behindCount: details.behindCount,
1362 pr: null,
1363 })),
1364 );
1365
1366 const prepareCommitContext: GitCoreShape["prepareCommitContext"] = Effect.fn(
1367 "prepareCommitContext",
1368 )(function* (cwd, filePaths) {
1369 if (filePaths && filePaths.length > 0) {
1370 yield* runGit("GitCore.prepareCommitContext.reset", cwd, ["reset"]).pipe(
1371 Effect.catch(() => Effect.void),
1372 );
1373 yield* runGit("GitCore.prepareCommitContext.addSelected", cwd, [
1374 "add",
1375 "-A",
1376 "--",
1377 ...filePaths,
1378 ]);
1379 } else {
1380 yield* runGit("GitCore.prepareCommitContext.addAll", cwd, ["add", "-A"]);
1381 }
1382
1383 const stagedSummary = yield* runGitStdout("GitCore.prepareCommitContext.stagedSummary", cwd, [
1384 "diff",
1385 "--cached",
1386 "--name-status",
1387 ]).pipe(Effect.map((stdout) => stdout.trim()));
1388 if (stagedSummary.length === 0) {
1389 return null;
1390 }
1391
1392 const stagedPatch = yield* runGitStdoutWithOptions(
1393 "GitCore.prepareCommitContext.stagedPatch",
1394 cwd,
1395 ["diff", "--cached", "--patch", "--minimal"],
1396 {
1397 maxOutputBytes: PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES,
1398 truncateOutputAtMaxBytes: true,
1399 },
1400 );
1401
1402 return {
1403 stagedSummary,
1404 stagedPatch,
1405 };
1406 });
1407
1408 const commit: GitCoreShape["commit"] = Effect.fn("commit")(function* (
1409 cwd,
1410 subject,
1411 body,
1412 options?: GitCommitOptions,
1413 ) {
1414 const args = ["commit", "-m", subject];
1415 const trimmedBody = body.trim();
1416 if (trimmedBody.length > 0) {
1417 args.push("-m", trimmedBody);
1418 }
1419 const progress =
1420 options?.progress?.onOutputLine === undefined
1421 ? options?.progress
1422 : {
1423 ...options.progress,
1424 onStdoutLine: (line: string) =>
1425 options.progress?.onOutputLine?.({ stream: "stdout", text: line }) ?? Effect.void,
1426 onStderrLine: (line: string) =>
1427 options.progress?.onOutputLine?.({ stream: "stderr", text: line }) ?? Effect.void,
1428 };
1429 yield* executeGit("GitCore.commit.commit", cwd, args, {
1430 ...(options?.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
1431 ...(progress ? { progress } : {}),
1432 }).pipe(Effect.asVoid);
1433 const commitSha = yield* runGitStdout("GitCore.commit.revParseHead", cwd, [
1434 "rev-parse",
1435 "HEAD",
1436 ]).pipe(Effect.map((stdout) => stdout.trim()));
1437
1438 return { commitSha };
1439 });
1440
1441 const pushCurrentBranch: GitCoreShape["pushCurrentBranch"] = Effect.fn("pushCurrentBranch")(
1442 function* (cwd, fallbackBranch) {
1443 const details = yield* statusDetails(cwd);
1444 const branch = details.branch ?? fallbackBranch;
1445 if (!branch) {
1446 return yield* createGitCommandError(
1447 "GitCore.pushCurrentBranch",
1448 cwd,
1449 ["push"],
1450 "Cannot push from detached HEAD.",
1451 );
1452 }
1453
1454 const hasNoLocalDelta = details.aheadCount === 0 && details.behindCount === 0;
1455 if (hasNoLocalDelta) {
1456 if (details.hasUpstream) {
1457 return {
1458 status: "skipped_up_to_date" as const,
1459 branch,
1460 ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}),
1461 };
1462 }
1463
1464 const comparableBaseBranch = yield* resolveBaseBranchForNoUpstream(cwd, branch).pipe(
1465 Effect.catch(() => Effect.succeed(null)),
1466 );
1467 if (comparableBaseBranch) {
1468 const publishRemoteName = yield* resolvePushRemoteName(cwd, branch).pipe(
1469 Effect.catch(() => Effect.succeed(null)),
1470 );
1471 if (!publishRemoteName) {
1472 return {
1473 status: "skipped_up_to_date" as const,
1474 branch,
1475 };
1476 }
1477
1478 const hasRemoteBranch = yield* remoteBranchExists(cwd, publishRemoteName, branch).pipe(
1479 Effect.catch(() => Effect.succeed(false)),
1480 );
1481 if (hasRemoteBranch) {
1482 return {
1483 status: "skipped_up_to_date" as const,
1484 branch,
1485 };
1486 }
1487 }
1488 }
1489
1490 if (!details.hasUpstream) {
1491 const publishRemoteName = yield* resolvePushRemoteName(cwd, branch);
1492 if (!publishRemoteName) {
1493 return yield* createGitCommandError(
1494 "GitCore.pushCurrentBranch",
1495 cwd,
1496 ["push"],
1497 "Cannot push because no git remote is configured for this repository.",
1498 );
1499 }
1500 yield* runGit("GitCore.pushCurrentBranch.pushWithUpstream", cwd, [
1501 "push",
1502 "-u",
1503 publishRemoteName,
1504 `HEAD:refs/heads/${branch}`,
1505 ]);
1506 return {
1507 status: "pushed" as const,
1508 branch,
1509 upstreamBranch: `${publishRemoteName}/${branch}`,
1510 setUpstream: true,
1511 };
1512 }
1513
1514 const currentUpstream = yield* resolveCurrentUpstream(cwd).pipe(
1515 Effect.catch(() => Effect.succeed(null)),
1516 );
1517 if (currentUpstream) {
1518 yield* runGit("GitCore.pushCurrentBranch.pushUpstream", cwd, [
1519 "push",
1520 currentUpstream.remoteName,
1521 `HEAD:${currentUpstream.upstreamBranch}`,
1522 ]);
1523 return {
1524 status: "pushed" as const,
1525 branch,
1526 upstreamBranch: currentUpstream.upstreamRef,
1527 setUpstream: false,
1528 };
1529 }
1530
1531 yield* runGit("GitCore.pushCurrentBranch.push", cwd, ["push"]);
1532 return {
1533 status: "pushed" as const,
1534 branch,
1535 ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}),
1536 setUpstream: false,
1537 };
1538 },
1539 );
1540
1541 const pullCurrentBranch: GitCoreShape["pullCurrentBranch"] = Effect.fn("pullCurrentBranch")(
1542 function* (cwd) {
1543 const details = yield* statusDetails(cwd);
1544 const branch = details.branch;
1545 if (!branch) {
1546 return yield* createGitCommandError(
1547 "GitCore.pullCurrentBranch",
1548 cwd,
1549 ["pull", "--ff-only"],
1550 "Cannot pull from detached HEAD.",
1551 );
1552 }
1553 if (!details.hasUpstream) {
1554 return yield* createGitCommandError(
1555 "GitCore.pullCurrentBranch",
1556 cwd,
1557 ["pull", "--ff-only"],
1558 "Current branch has no upstream configured. Push with upstream first.",
1559 );
1560 }
1561 const beforeSha = yield* runGitStdout(
1562 "GitCore.pullCurrentBranch.beforeSha",
1563 cwd,
1564 ["rev-parse", "HEAD"],
1565 true,
1566 ).pipe(Effect.map((stdout) => stdout.trim()));
1567 yield* executeGit("GitCore.pullCurrentBranch.pull", cwd, ["pull", "--ff-only"], {
1568 timeoutMs: 30_000,
1569 fallbackErrorMessage: "git pull failed",
1570 });
1571 const afterSha = yield* runGitStdout(
1572 "GitCore.pullCurrentBranch.afterSha",
1573 cwd,
1574 ["rev-parse", "HEAD"],
1575 true,
1576 ).pipe(Effect.map((stdout) => stdout.trim()));
1577
1578 const refreshed = yield* statusDetails(cwd);
1579 return {
1580 status: beforeSha.length > 0 && beforeSha === afterSha ? "skipped_up_to_date" : "pulled",
1581 branch,
1582 upstreamBranch: refreshed.upstreamRef,
1583 };
1584 },
1585 );
1586
1587 const readRangeContext: GitCoreShape["readRangeContext"] = Effect.fn("readRangeContext")(
1588 function* (cwd, baseBranch) {
1589 const range = `${baseBranch}..HEAD`;
1590 const [commitSummary, diffSummary, diffPatch] = yield* Effect.all(
1591 [
1592 runGitStdoutWithOptions(
1593 "GitCore.readRangeContext.log",
1594 cwd,
1595 ["log", "--oneline", range],
1596 {
1597 maxOutputBytes: RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES,
1598 truncateOutputAtMaxBytes: true,
1599 },
1600 ),
1601 runGitStdoutWithOptions(
1602 "GitCore.readRangeContext.diffStat",
1603 cwd,
1604 ["diff", "--stat", range],
1605 {
1606 maxOutputBytes: RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES,
1607 truncateOutputAtMaxBytes: true,
1608 },
1609 ),
1610 runGitStdoutWithOptions(
1611 "GitCore.readRangeContext.diffPatch",
1612 cwd,
1613 ["diff", "--patch", "--minimal", range],
1614 {
1615 maxOutputBytes: RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES,
1616 truncateOutputAtMaxBytes: true,
1617 },
1618 ),
1619 ],
1620 { concurrency: "unbounded" },
1621 );
1622
1623 return {
1624 commitSummary,
1625 diffSummary,
1626 diffPatch,
1627 };
1628 },
1629 );
1630
1631 const readConfigValue: GitCoreShape["readConfigValue"] = (cwd, key) =>
1632 runGitStdout("GitCore.readConfigValue", cwd, ["config", "--get", key], true).pipe(
1633 Effect.map((stdout) => stdout.trim()),
1634 Effect.map((trimmed) => (trimmed.length > 0 ? trimmed : null)),
1635 );
1636
1637 const isInsideWorkTree: GitCoreShape["isInsideWorkTree"] = (cwd) =>
1638 executeGit("GitCore.isInsideWorkTree", cwd, ["rev-parse", "--is-inside-work-tree"], {
1639 allowNonZeroExit: true,
1640 timeoutMs: 5_000,
1641 maxOutputBytes: 4_096,
1642 }).pipe(Effect.map((result) => result.code === 0 && result.stdout.trim() === "true"));
1643
1644 const listWorkspaceFiles: GitCoreShape["listWorkspaceFiles"] = (cwd) =>
1645 executeGit(
1646 "GitCore.listWorkspaceFiles",
1647 cwd,
1648 [
1649 ...WORKSPACE_GIT_HARDENED_CONFIG_ARGS,
1650 "ls-files",
1651 "--cached",
1652 "--others",
1653 "--exclude-standard",
1654 "-z",
1655 ],
1656 {
1657 allowNonZeroExit: true,
1658 timeoutMs: 20_000,
1659 maxOutputBytes: WORKSPACE_FILES_MAX_OUTPUT_BYTES,
1660 truncateOutputAtMaxBytes: true,
1661 },
1662 ).pipe(
1663 Effect.flatMap((result) =>
1664 result.code === 0
1665 ? Effect.succeed({
1666 paths: splitNullSeparatedPaths(result.stdout, result.stdoutTruncated),
1667 truncated: result.stdoutTruncated,
1668 })
1669 : Effect.fail(
1670 createGitCommandError(
1671 "GitCore.listWorkspaceFiles",
1672 cwd,
1673 [
1674 ...WORKSPACE_GIT_HARDENED_CONFIG_ARGS,
1675 "ls-files",
1676 "--cached",
1677 "--others",
1678 "--exclude-standard",
1679 "-z",
1680 ],
1681 result.stderr.trim().length > 0 ? result.stderr.trim() : "git ls-files failed",
1682 ),
1683 ),
1684 ),
1685 );
1686
1687 const filterIgnoredPaths: GitCoreShape["filterIgnoredPaths"] = (cwd, relativePaths) =>
1688 Effect.gen(function* () {
1689 if (relativePaths.length === 0) {
1690 return relativePaths;
1691 }
1692
1693 const ignoredPaths = new Set<string>();
1694 const chunks = chunkPathsForGitCheckIgnore(relativePaths);
1695
1696 for (const chunk of chunks) {
1697 const result = yield* executeGit(
1698 "GitCore.filterIgnoredPaths",
1699 cwd,
1700 [...WORKSPACE_GIT_HARDENED_CONFIG_ARGS, "check-ignore", "--no-index", "-z", "--stdin"],
1701 {
1702 stdin: `${chunk.join("\0")}\0`,
1703 allowNonZeroExit: true,
1704 timeoutMs: 20_000,
1705 maxOutputBytes: WORKSPACE_FILES_MAX_OUTPUT_BYTES,
1706 truncateOutputAtMaxBytes: true,
1707 },
1708 );
1709
1710 if (result.code !== 0 && result.code !== 1) {
1711 return yield* createGitCommandError(
1712 "GitCore.filterIgnoredPaths",
1713 cwd,
1714 [...WORKSPACE_GIT_HARDENED_CONFIG_ARGS, "check-ignore", "--no-index", "-z", "--stdin"],
1715 result.stderr.trim().length > 0 ? result.stderr.trim() : "git check-ignore failed",
1716 );
1717 }
1718
1719 for (const ignoredPath of splitNullSeparatedPaths(result.stdout, result.stdoutTruncated)) {
1720 ignoredPaths.add(ignoredPath);
1721 }
1722 }
1723
1724 if (ignoredPaths.size === 0) {
1725 return relativePaths;
1726 }
1727
1728 return relativePaths.filter((relativePath) => !ignoredPaths.has(relativePath));
1729 });
1730
1731 const listBranches: GitCoreShape["listBranches"] = Effect.fn("listBranches")(function* (input) {
1732 const branchRecencyPromise = readBranchRecency(input.cwd).pipe(
1733 Effect.catch(() => Effect.succeed(new Map<string, number>())),
1734 );
1735 const localBranchResult = yield* executeGit(
1736 "GitCore.listBranches.branchNoColor",
1737 input.cwd,
1738 ["branch", "--no-color", "--no-column"],
1739 {
1740 timeoutMs: 10_000,
1741 allowNonZeroExit: true,
1742 },
1743 ).pipe(
1744 Effect.catchIf(isMissingGitCwdError, () =>
1745 Effect.succeed({
1746 code: 128,
1747 stdout: "",
1748 stderr: "fatal: not a git repository",
1749 stdoutTruncated: false,
1750 stderrTruncated: false,
1751 }),
1752 ),
1753 );
1754
1755 if (localBranchResult.code !== 0) {
1756 const stderr = localBranchResult.stderr.trim();
1757 if (stderr.toLowerCase().includes("not a git repository")) {
1758 return {
1759 branches: [],
1760 isRepo: false,
1761 hasOriginRemote: false,
1762 nextCursor: null,
1763 totalCount: 0,
1764 };
1765 }
1766 return yield* createGitCommandError(
1767 "GitCore.listBranches",
1768 input.cwd,
1769 ["branch", "--no-color", "--no-column"],
1770 stderr || "git branch failed",
1771 );
1772 }
1773
1774 const remoteBranchResultEffect = executeGit(
1775 "GitCore.listBranches.remoteBranches",
1776 input.cwd,
1777 ["branch", "--no-color", "--no-column", "--remotes"],
1778 {
1779 timeoutMs: 10_000,
1780 allowNonZeroExit: true,
1781 },
1782 ).pipe(
1783 Effect.catch((error) =>
1784 Effect.logWarning(
1785 `GitCore.listBranches: remote branch lookup failed for ${input.cwd}: ${error.message}. Falling back to an empty remote branch list.`,
1786 ).pipe(Effect.as({ code: 1, stdout: "", stderr: "" })),
1787 ),
1788 );
1789
1790 const remoteNamesResultEffect = executeGit(
1791 "GitCore.listBranches.remoteNames",
1792 input.cwd,
1793 ["remote"],
1794 {
1795 timeoutMs: 5_000,
1796 allowNonZeroExit: true,
1797 },
1798 ).pipe(
1799 Effect.catch((error) =>
1800 Effect.logWarning(
1801 `GitCore.listBranches: remote name lookup failed for ${input.cwd}: ${error.message}. Falling back to an empty remote name list.`,
1802 ).pipe(Effect.as({ code: 1, stdout: "", stderr: "" })),
1803 ),
1804 );
1805
1806 const [defaultRef, worktreeList, remoteBranchResult, remoteNamesResult, branchLastCommit] =
1807 yield* Effect.all(
1808 [
1809 executeGit(
1810 "GitCore.listBranches.defaultRef",
1811 input.cwd,
1812 ["symbolic-ref", "refs/remotes/origin/HEAD"],
1813 {
1814 timeoutMs: 5_000,
1815 allowNonZeroExit: true,
1816 },
1817 ),
1818 executeGit(
1819 "GitCore.listBranches.worktreeList",
1820 input.cwd,
1821 ["worktree", "list", "--porcelain"],
1822 {
1823 timeoutMs: 5_000,
1824 allowNonZeroExit: true,
1825 },
1826 ),
1827 remoteBranchResultEffect,
1828 remoteNamesResultEffect,
1829 branchRecencyPromise,
1830 ],
1831 { concurrency: "unbounded" },
1832 );
1833
1834 const remoteNames =
1835 remoteNamesResult.code === 0 ? parseRemoteNames(remoteNamesResult.stdout) : [];
1836 if (remoteBranchResult.code !== 0 && remoteBranchResult.stderr.trim().length > 0) {
1837 yield* Effect.logWarning(
1838 `GitCore.listBranches: remote branch lookup returned code ${remoteBranchResult.code} for ${input.cwd}: ${remoteBranchResult.stderr.trim()}. Falling back to an empty remote branch list.`,
1839 );
1840 }
1841 if (remoteNamesResult.code !== 0 && remoteNamesResult.stderr.trim().length > 0) {
1842 yield* Effect.logWarning(
1843 `GitCore.listBranches: remote name lookup returned code ${remoteNamesResult.code} for ${input.cwd}: ${remoteNamesResult.stderr.trim()}. Falling back to an empty remote name list.`,
1844 );
1845 }
1846
1847 const defaultBranch =
1848 defaultRef.code === 0
1849 ? defaultRef.stdout.trim().replace(/^refs\/remotes\/origin\//, "")
1850 : null;
1851
1852 const worktreeMap = new Map<string, string>();
1853 if (worktreeList.code === 0) {
1854 let currentPath: string | null = null;
1855 for (const line of worktreeList.stdout.split("\n")) {
1856 if (line.startsWith("worktree ")) {
1857 const candidatePath = line.slice("worktree ".length);
1858 const exists = yield* fileSystem.stat(candidatePath).pipe(
1859 Effect.map(() => true),
1860 Effect.catch(() => Effect.succeed(false)),
1861 );
1862 currentPath = exists ? candidatePath : null;
1863 } else if (line.startsWith("branch refs/heads/") && currentPath) {
1864 worktreeMap.set(line.slice("branch refs/heads/".length), currentPath);
1865 } else if (line === "") {
1866 currentPath = null;
1867 }
1868 }
1869 }
1870
1871 const localBranches = localBranchResult.stdout
1872 .split("\n")
1873 .map(parseBranchLine)
1874 .filter((branch): branch is { name: string; current: boolean } => branch !== null)
1875 .map((branch) => ({
1876 name: branch.name,
1877 current: branch.current,
1878 isRemote: false,
1879 isDefault: branch.name === defaultBranch,
1880 worktreePath: worktreeMap.get(branch.name) ?? null,
1881 }))
1882 .toSorted((a, b) => {
1883 const aPriority = a.current ? 0 : a.isDefault ? 1 : 2;
1884 const bPriority = b.current ? 0 : b.isDefault ? 1 : 2;
1885 if (aPriority !== bPriority) return aPriority - bPriority;
1886
1887 const aLastCommit = branchLastCommit.get(a.name) ?? 0;
1888 const bLastCommit = branchLastCommit.get(b.name) ?? 0;
1889 if (aLastCommit !== bLastCommit) return bLastCommit - aLastCommit;
1890 return a.name.localeCompare(b.name);
1891 });
1892
1893 const remoteBranches =
1894 remoteBranchResult.code === 0
1895 ? remoteBranchResult.stdout
1896 .split("\n")
1897 .map(parseBranchLine)
1898 .filter((branch): branch is { name: string; current: boolean } => branch !== null)
1899 .map((branch) => {
1900 const parsedRemoteRef = parseRemoteRefWithRemoteNames(branch.name, remoteNames);
1901 const remoteBranch: {
1902 name: string;
1903 current: boolean;
1904 isRemote: boolean;
1905 remoteName?: string;
1906 isDefault: boolean;
1907 worktreePath: string | null;
1908 } = {
1909 name: branch.name,
1910 current: false,
1911 isRemote: true,
1912 isDefault: false,
1913 worktreePath: null,
1914 };
1915 if (parsedRemoteRef) {
1916 remoteBranch.remoteName = parsedRemoteRef.remoteName;
1917 }
1918 return remoteBranch;
1919 })
1920 .toSorted((a, b) => {
1921 const aLastCommit = branchLastCommit.get(a.name) ?? 0;
1922 const bLastCommit = branchLastCommit.get(b.name) ?? 0;
1923 if (aLastCommit !== bLastCommit) return bLastCommit - aLastCommit;
1924 return a.name.localeCompare(b.name);
1925 })
1926 : [];
1927
1928 const branches = paginateBranches({
1929 branches: filterBranchesForListQuery(
1930 dedupeRemoteBranchesWithLocalMatches([...localBranches, ...remoteBranches]),
1931 input.query,
1932 ),
1933 cursor: input.cursor,
1934 limit: input.limit,
1935 });
1936
1937 return {
1938 branches: [...branches.branches],
1939 isRepo: true,
1940 hasOriginRemote: remoteNames.includes("origin"),
1941 nextCursor: branches.nextCursor,
1942 totalCount: branches.totalCount,
1943 };
1944 });
1945
1946 const createWorktree: GitCoreShape["createWorktree"] = Effect.fn("createWorktree")(
1947 function* (input) {
1948 const targetBranch = input.newBranch ?? input.branch;
1949 const sanitizedBranch = targetBranch.replace(/\//g, "-");
1950 const repoName = path.basename(input.cwd);
1951 const worktreePath = input.path ?? path.join(worktreesDir, repoName, sanitizedBranch);
1952 const args = input.newBranch
1953 ? ["worktree", "add", "-b", input.newBranch, worktreePath, input.branch]
1954 : ["worktree", "add", worktreePath, input.branch];
1955
1956 yield* executeGit("GitCore.createWorktree", input.cwd, args, {
1957 fallbackErrorMessage: "git worktree add failed",
1958 });
1959
1960 return {
1961 worktree: {
1962 path: worktreePath,
1963 branch: targetBranch,
1964 },
1965 };
1966 },
1967 );
1968
1969 const fetchPullRequestBranch: GitCoreShape["fetchPullRequestBranch"] = Effect.fn(
1970 "fetchPullRequestBranch",
1971 )(function* (input) {
1972 const remoteName = yield* resolvePrimaryRemoteName(input.cwd);
1973 yield* executeGit(
1974 "GitCore.fetchPullRequestBranch",
1975 input.cwd,
1976 [
1977 "fetch",
1978 "--quiet",
1979 "--no-tags",
1980 remoteName,
1981 `+refs/pull/${input.prNumber}/head:refs/heads/${input.branch}`,
1982 ],
1983 {
1984 fallbackErrorMessage: "git fetch pull request branch failed",
1985 },
1986 );
1987 });
1988
1989 const fetchRemoteBranch: GitCoreShape["fetchRemoteBranch"] = Effect.fn("fetchRemoteBranch")(
1990 function* (input) {
1991 yield* runGit("GitCore.fetchRemoteBranch.fetch", input.cwd, [
1992 "fetch",
1993 "--quiet",
1994 "--no-tags",
1995 input.remoteName,
1996 `+refs/heads/${input.remoteBranch}:refs/remotes/${input.remoteName}/${input.remoteBranch}`,
1997 ]);
1998
1999 const localBranchAlreadyExists = yield* branchExists(input.cwd, input.localBranch);
2000 const targetRef = `${input.remoteName}/${input.remoteBranch}`;
2001 yield* runGit(
2002 "GitCore.fetchRemoteBranch.materialize",
2003 input.cwd,
2004 localBranchAlreadyExists
2005 ? ["branch", "--force", input.localBranch, targetRef]
2006 : ["branch", input.localBranch, targetRef],
2007 );
2008 },
2009 );
2010
2011 const setBranchUpstream: GitCoreShape["setBranchUpstream"] = (input) =>
2012 runGit("GitCore.setBranchUpstream", input.cwd, [
2013 "branch",
2014 "--set-upstream-to",
2015 `${input.remoteName}/${input.remoteBranch}`,
2016 input.branch,
2017 ]);
2018
2019 const removeWorktree: GitCoreShape["removeWorktree"] = Effect.fn("removeWorktree")(
2020 function* (input) {
2021 const args = ["worktree", "remove"];
2022 if (input.force) {
2023 args.push("--force");
2024 }
2025 args.push(input.path);
2026 yield* executeGit("GitCore.removeWorktree", input.cwd, args, {
2027 timeoutMs: 15_000,
2028 fallbackErrorMessage: "git worktree remove failed",
2029 }).pipe(
2030 Effect.mapError((error) =>
2031 createGitCommandError(
2032 "GitCore.removeWorktree",
2033 input.cwd,
2034 args,
2035 `${commandLabel(args)} failed (cwd: ${input.cwd}): ${error.message}`,
2036 error,
2037 ),
2038 ),
2039 );
2040 },
2041 );
2042
2043 const renameBranch: GitCoreShape["renameBranch"] = Effect.fn("renameBranch")(function* (input) {
2044 if (input.oldBranch === input.newBranch) {
2045 return { branch: input.newBranch };
2046 }
2047 const targetBranch = yield* resolveAvailableBranchName(input.cwd, input.newBranch);
2048
2049 yield* executeGit(
2050 "GitCore.renameBranch",
2051 input.cwd,
2052 ["branch", "-m", "--", input.oldBranch, targetBranch],
2053 {
2054 timeoutMs: 10_000,
2055 fallbackErrorMessage: "git branch rename failed",
2056 },
2057 );
2058
2059 return { branch: targetBranch };
2060 });
2061
2062 const checkoutBranch: GitCoreShape["checkoutBranch"] = Effect.fn("checkoutBranch")(
2063 function* (input) {
2064 const [localInputExists, remoteExists] = yield* Effect.all(
2065 [
2066 executeGit(
2067 "GitCore.checkoutBranch.localInputExists",
2068 input.cwd,
2069 ["show-ref", "--verify", "--quiet", `refs/heads/${input.branch}`],
2070 {
2071 timeoutMs: 5_000,
2072 allowNonZeroExit: true,
2073 },
2074 ).pipe(Effect.map((result) => result.code === 0)),
2075 executeGit(
2076 "GitCore.checkoutBranch.remoteExists",
2077 input.cwd,
2078 ["show-ref", "--verify", "--quiet", `refs/remotes/${input.branch}`],
2079 {
2080 timeoutMs: 5_000,
2081 allowNonZeroExit: true,
2082 },
2083 ).pipe(Effect.map((result) => result.code === 0)),
2084 ],
2085 { concurrency: "unbounded" },
2086 );
2087
2088 const localTrackingBranch = remoteExists
2089 ? yield* executeGit(
2090 "GitCore.checkoutBranch.localTrackingBranch",
2091 input.cwd,
2092 ["for-each-ref", "--format=%(refname:short)\t%(upstream:short)", "refs/heads"],
2093 {
2094 timeoutMs: 5_000,
2095 allowNonZeroExit: true,
2096 },
2097 ).pipe(
2098 Effect.map((result) =>
2099 result.code === 0
2100 ? parseTrackingBranchByUpstreamRef(result.stdout, input.branch)
2101 : null,
2102 ),
2103 )
2104 : null;
2105
2106 const localTrackedBranchCandidate = deriveLocalBranchNameFromRemoteRef(input.branch);
2107 const localTrackedBranchTargetExists =
2108 remoteExists && localTrackedBranchCandidate
2109 ? yield* executeGit(
2110 "GitCore.checkoutBranch.localTrackedBranchTargetExists",
2111 input.cwd,
2112 ["show-ref", "--verify", "--quiet", `refs/heads/${localTrackedBranchCandidate}`],
2113 {
2114 timeoutMs: 5_000,
2115 allowNonZeroExit: true,
2116 },
2117 ).pipe(Effect.map((result) => result.code === 0))
2118 : false;
2119
2120 const checkoutArgs = localInputExists
2121 ? ["checkout", input.branch]
2122 : remoteExists && !localTrackingBranch && localTrackedBranchTargetExists
2123 ? ["checkout", input.branch]
2124 : remoteExists && !localTrackingBranch
2125 ? ["checkout", "--track", input.branch]
2126 : remoteExists && localTrackingBranch
2127 ? ["checkout", localTrackingBranch]
2128 : ["checkout", input.branch];
2129
2130 yield* executeGit("GitCore.checkoutBranch.checkout", input.cwd, checkoutArgs, {
2131 timeoutMs: 10_000,
2132 fallbackErrorMessage: "git checkout failed",
2133 });
2134
2135 const branch = yield* runGitStdout("GitCore.checkoutBranch.currentBranch", input.cwd, [
2136 "branch",
2137 "--show-current",
2138 ]).pipe(Effect.map((stdout) => stdout.trim() || null));
2139
2140 return { branch };
2141 },
2142 );
2143
2144 const createBranch: GitCoreShape["createBranch"] = Effect.fn("createBranch")(function* (input) {
2145 yield* executeGit("GitCore.createBranch", input.cwd, ["branch", input.branch], {
2146 timeoutMs: 10_000,
2147 fallbackErrorMessage: "git branch create failed",
2148 });
2149 if (input.checkout) {
2150 yield* checkoutBranch({ cwd: input.cwd, branch: input.branch });
2151 }
2152
2153 return { branch: input.branch };
2154 });
2155
2156 const initRepo: GitCoreShape["initRepo"] = (input) =>
2157 executeGit("GitCore.initRepo", input.cwd, ["init"], {
2158 timeoutMs: 10_000,
2159 fallbackErrorMessage: "git init failed",
2160 }).pipe(Effect.asVoid);
2161
2162 const listLocalBranchNames: GitCoreShape["listLocalBranchNames"] = (cwd) =>
2163 runGitStdout("GitCore.listLocalBranchNames", cwd, [
2164 "branch",
2165 "--list",
2166 "--no-column",
2167 "--format=%(refname:short)",
2168 ]).pipe(
2169 Effect.map((stdout) =>
2170 stdout
2171 .split("\n")
2172 .map((line) => line.trim())
2173 .filter((line) => line.length > 0),
2174 ),
2175 );
2176
2177 return {
2178 execute,
2179 status,
2180 statusDetails,
2181 statusDetailsLocal,
2182 prepareCommitContext,
2183 commit,
2184 pushCurrentBranch,
2185 pullCurrentBranch,
2186 readRangeContext,
2187 readConfigValue,
2188 isInsideWorkTree,
2189 listWorkspaceFiles,
2190 filterIgnoredPaths,
2191 listBranches,
2192 createWorktree,
2193 fetchPullRequestBranch,
2194 ensureRemote,
2195 fetchRemoteBranch,
2196 setBranchUpstream,
2197 removeWorktree,
2198 renameBranch,
2199 createBranch,
2200 checkoutBranch,
2201 initRepo,
2202 listLocalBranchNames,
2203 } satisfies GitCoreShape;
2204});
2205
2206export const GitCoreLive = Layer.effect(GitCore, makeGitCore());
apps/server/src/git/Layers/GitHubCli.test.ts deleted-227
......@@ -1,227 +0,0 @@
1import { assert, it } from "@effect/vitest";
2import { Effect } from "effect";
3import { afterEach, expect, vi } from "vitest";
4
5vi.mock("../../processRunner", () => ({
6 runProcess: vi.fn(),
7}));
8
9import { runProcess } from "../../processRunner.ts";
10import { GitHubCli } from "../Services/GitHubCli.ts";
11import { GitHubCliLive } from "./GitHubCli.ts";
12
13const mockedRunProcess = vi.mocked(runProcess);
14const layer = it.layer(GitHubCliLive);
15
16afterEach(() => {
17 mockedRunProcess.mockReset();
18});
19
20layer("GitHubCliLive", (it) => {
21 it.effect("parses pull request view output", () =>
22 Effect.gen(function* () {
23 mockedRunProcess.mockResolvedValueOnce({
24 stdout: JSON.stringify({
25 number: 42,
26 title: "Add PR thread creation",
27 url: "https://github.com/pingdotgg/codething-mvp/pull/42",
28 baseRefName: "main",
29 headRefName: "feature/pr-threads",
30 state: "OPEN",
31 mergedAt: null,
32 isCrossRepository: true,
33 headRepository: {
34 nameWithOwner: "octocat/codething-mvp",
35 },
36 headRepositoryOwner: {
37 login: "octocat",
38 },
39 }),
40 stderr: "",
41 code: 0,
42 signal: null,
43 timedOut: false,
44 });
45
46 const result = yield* Effect.gen(function* () {
47 const gh = yield* GitHubCli;
48 return yield* gh.getPullRequest({
49 cwd: "/repo",
50 reference: "#42",
51 });
52 });
53
54 assert.deepStrictEqual(result, {
55 number: 42,
56 title: "Add PR thread creation",
57 url: "https://github.com/pingdotgg/codething-mvp/pull/42",
58 baseRefName: "main",
59 headRefName: "feature/pr-threads",
60 state: "open",
61 isCrossRepository: true,
62 headRepositoryNameWithOwner: "octocat/codething-mvp",
63 headRepositoryOwnerLogin: "octocat",
64 });
65 expect(mockedRunProcess).toHaveBeenCalledWith(
66 "gh",
67 [
68 "pr",
69 "view",
70 "#42",
71 "--json",
72 "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner",
73 ],
74 expect.objectContaining({ cwd: "/repo" }),
75 );
76 }),
77 );
78
79 it.effect("trims pull request fields decoded from gh json", () =>
80 Effect.gen(function* () {
81 mockedRunProcess.mockResolvedValueOnce({
82 stdout: JSON.stringify({
83 number: 42,
84 title: " Add PR thread creation \n",
85 url: " https://github.com/pingdotgg/codething-mvp/pull/42 ",
86 baseRefName: " main ",
87 headRefName: "\tfeature/pr-threads\t",
88 state: "OPEN",
89 mergedAt: null,
90 isCrossRepository: true,
91 headRepository: {
92 nameWithOwner: " octocat/codething-mvp ",
93 },
94 headRepositoryOwner: {
95 login: " octocat ",
96 },
97 }),
98 stderr: "",
99 code: 0,
100 signal: null,
101 timedOut: false,
102 });
103
104 const result = yield* Effect.gen(function* () {
105 const gh = yield* GitHubCli;
106 return yield* gh.getPullRequest({
107 cwd: "/repo",
108 reference: "#42",
109 });
110 });
111
112 assert.deepStrictEqual(result, {
113 number: 42,
114 title: "Add PR thread creation",
115 url: "https://github.com/pingdotgg/codething-mvp/pull/42",
116 baseRefName: "main",
117 headRefName: "feature/pr-threads",
118 state: "open",
119 isCrossRepository: true,
120 headRepositoryNameWithOwner: "octocat/codething-mvp",
121 headRepositoryOwnerLogin: "octocat",
122 });
123 }),
124 );
125
126 it.effect("skips invalid entries when parsing pr lists", () =>
127 Effect.gen(function* () {
128 mockedRunProcess.mockResolvedValueOnce({
129 stdout: JSON.stringify([
130 {
131 number: 0,
132 title: "invalid",
133 url: "https://github.com/pingdotgg/codething-mvp/pull/0",
134 baseRefName: "main",
135 headRefName: "feature/invalid",
136 },
137 {
138 number: 43,
139 title: " Valid PR ",
140 url: " https://github.com/pingdotgg/codething-mvp/pull/43 ",
141 baseRefName: " main ",
142 headRefName: " feature/pr-list ",
143 headRepository: {
144 nameWithOwner: " ",
145 },
146 headRepositoryOwner: {
147 login: " ",
148 },
149 },
150 ]),
151 stderr: "",
152 code: 0,
153 signal: null,
154 timedOut: false,
155 });
156
157 const result = yield* Effect.gen(function* () {
158 const gh = yield* GitHubCli;
159 return yield* gh.listOpenPullRequests({
160 cwd: "/repo",
161 headSelector: "feature/pr-list",
162 });
163 });
164
165 assert.deepStrictEqual(result, [
166 {
167 number: 43,
168 title: "Valid PR",
169 url: "https://github.com/pingdotgg/codething-mvp/pull/43",
170 baseRefName: "main",
171 headRefName: "feature/pr-list",
172 state: "open",
173 },
174 ]);
175 }),
176 );
177
178 it.effect("reads repository clone URLs", () =>
179 Effect.gen(function* () {
180 mockedRunProcess.mockResolvedValueOnce({
181 stdout: JSON.stringify({
182 nameWithOwner: "octocat/codething-mvp",
183 url: "https://github.com/octocat/codething-mvp",
184 sshUrl: "git@github.com:octocat/codething-mvp.git",
185 }),
186 stderr: "",
187 code: 0,
188 signal: null,
189 timedOut: false,
190 });
191
192 const result = yield* Effect.gen(function* () {
193 const gh = yield* GitHubCli;
194 return yield* gh.getRepositoryCloneUrls({
195 cwd: "/repo",
196 repository: "octocat/codething-mvp",
197 });
198 });
199
200 assert.deepStrictEqual(result, {
201 nameWithOwner: "octocat/codething-mvp",
202 url: "https://github.com/octocat/codething-mvp",
203 sshUrl: "git@github.com:octocat/codething-mvp.git",
204 });
205 }),
206 );
207
208 it.effect("surfaces a friendly error when the pull request is not found", () =>
209 Effect.gen(function* () {
210 mockedRunProcess.mockRejectedValueOnce(
211 new Error(
212 "GraphQL: Could not resolve to a PullRequest with the number of 4888. (repository.pullRequest)",
213 ),
214 );
215
216 const error = yield* Effect.gen(function* () {
217 const gh = yield* GitHubCli;
218 return yield* gh.getPullRequest({
219 cwd: "/repo",
220 reference: "4888",
221 });
222 }).pipe(Effect.flip);
223
224 assert.equal(error.message.includes("Pull request not found"), true);
225 }),
226 );
227});
apps/server/src/git/Layers/GitHubCli.ts deleted-240
......@@ -1,240 +0,0 @@
1import { Effect, Layer, Result, Schema, SchemaIssue } from "effect";
2import { TrimmedNonEmptyString } from "@t3tools/contracts";
3
4import { runProcess } from "../../processRunner.ts";
5import { GitHubCliError } from "@t3tools/contracts";
6import {
7 GitHubCli,
8 type GitHubRepositoryCloneUrls,
9 type GitHubCliShape,
10} from "../Services/GitHubCli.ts";
11import {
12 decodeGitHubPullRequestJson,
13 decodeGitHubPullRequestListJson,
14 formatGitHubJsonDecodeError,
15} from "../githubPullRequests.ts";
16
17const DEFAULT_TIMEOUT_MS = 30_000;
18
19function normalizeGitHubCliError(operation: "execute" | "stdout", error: unknown): GitHubCliError {
20 if (error instanceof Error) {
21 if (error.message.includes("Command not found: gh")) {
22 return new GitHubCliError({
23 operation,
24 detail: "GitHub CLI (`gh`) is required but not available on PATH.",
25 cause: error,
26 });
27 }
28
29 const lower = error.message.toLowerCase();
30 if (
31 lower.includes("authentication failed") ||
32 lower.includes("not logged in") ||
33 lower.includes("gh auth login") ||
34 lower.includes("no oauth token")
35 ) {
36 return new GitHubCliError({
37 operation,
38 detail: "GitHub CLI is not authenticated. Run `gh auth login` and retry.",
39 cause: error,
40 });
41 }
42
43 if (
44 lower.includes("could not resolve to a pullrequest") ||
45 lower.includes("repository.pullrequest") ||
46 lower.includes("no pull requests found for branch") ||
47 lower.includes("pull request not found")
48 ) {
49 return new GitHubCliError({
50 operation,
51 detail: "Pull request not found. Check the PR number or URL and try again.",
52 cause: error,
53 });
54 }
55
56 return new GitHubCliError({
57 operation,
58 detail: `GitHub CLI command failed: ${error.message}`,
59 cause: error,
60 });
61 }
62
63 return new GitHubCliError({
64 operation,
65 detail: "GitHub CLI command failed.",
66 cause: error,
67 });
68}
69
70const RawGitHubRepositoryCloneUrlsSchema = Schema.Struct({
71 nameWithOwner: TrimmedNonEmptyString,
72 url: TrimmedNonEmptyString,
73 sshUrl: TrimmedNonEmptyString,
74});
75
76function normalizeRepositoryCloneUrls(
77 raw: Schema.Schema.Type<typeof RawGitHubRepositoryCloneUrlsSchema>,
78): GitHubRepositoryCloneUrls {
79 return {
80 nameWithOwner: raw.nameWithOwner,
81 url: raw.url,
82 sshUrl: raw.sshUrl,
83 };
84}
85
86function decodeGitHubJson<S extends Schema.Top>(
87 raw: string,
88 schema: S,
89 operation: "listOpenPullRequests" | "getPullRequest" | "getRepositoryCloneUrls",
90 invalidDetail: string,
91): Effect.Effect<S["Type"], GitHubCliError, S["DecodingServices"]> {
92 return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe(
93 Effect.mapError(
94 (error) =>
95 new GitHubCliError({
96 operation,
97 detail: `${invalidDetail}: ${SchemaIssue.makeFormatterDefault()(error.issue)}`,
98 cause: error,
99 }),
100 ),
101 );
102}
103
104const makeGitHubCli = Effect.sync(() => {
105 const execute: GitHubCliShape["execute"] = (input) =>
106 Effect.tryPromise({
107 try: () =>
108 runProcess("gh", input.args, {
109 cwd: input.cwd,
110 timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS,
111 }),
112 catch: (error) => normalizeGitHubCliError("execute", error),
113 });
114
115 const service = {
116 execute,
117 listOpenPullRequests: (input) =>
118 execute({
119 cwd: input.cwd,
120 args: [
121 "pr",
122 "list",
123 "--head",
124 input.headSelector,
125 "--state",
126 "open",
127 "--limit",
128 String(input.limit ?? 1),
129 "--json",
130 "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner",
131 ],
132 }).pipe(
133 Effect.map((result) => result.stdout.trim()),
134 Effect.flatMap((raw) =>
135 raw.length === 0
136 ? Effect.succeed([])
137 : Effect.sync(() => decodeGitHubPullRequestListJson(raw)).pipe(
138 Effect.flatMap((decoded) => {
139 if (!Result.isSuccess(decoded)) {
140 return Effect.fail(
141 new GitHubCliError({
142 operation: "listOpenPullRequests",
143 detail: `GitHub CLI returned invalid PR list JSON: ${formatGitHubJsonDecodeError(decoded.failure)}`,
144 cause: decoded.failure,
145 }),
146 );
147 }
148
149 return Effect.succeed(
150 decoded.success.map(({ updatedAt: _updatedAt, ...summary }) => summary),
151 );
152 }),
153 ),
154 ),
155 ),
156 getPullRequest: (input) =>
157 execute({
158 cwd: input.cwd,
159 args: [
160 "pr",
161 "view",
162 input.reference,
163 "--json",
164 "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner",
165 ],
166 }).pipe(
167 Effect.map((result) => result.stdout.trim()),
168 Effect.flatMap((raw) =>
169 Effect.sync(() => decodeGitHubPullRequestJson(raw)).pipe(
170 Effect.flatMap((decoded) => {
171 if (!Result.isSuccess(decoded)) {
172 return Effect.fail(
173 new GitHubCliError({
174 operation: "getPullRequest",
175 detail: `GitHub CLI returned invalid pull request JSON: ${formatGitHubJsonDecodeError(decoded.failure)}`,
176 cause: decoded.failure,
177 }),
178 );
179 }
180
181 return Effect.succeed(
182 (({ updatedAt: _updatedAt, ...summary }) => summary)(decoded.success),
183 );
184 }),
185 ),
186 ),
187 ),
188 getRepositoryCloneUrls: (input) =>
189 execute({
190 cwd: input.cwd,
191 args: ["repo", "view", input.repository, "--json", "nameWithOwner,url,sshUrl"],
192 }).pipe(
193 Effect.map((result) => result.stdout.trim()),
194 Effect.flatMap((raw) =>
195 decodeGitHubJson(
196 raw,
197 RawGitHubRepositoryCloneUrlsSchema,
198 "getRepositoryCloneUrls",
199 "GitHub CLI returned invalid repository JSON.",
200 ),
201 ),
202 Effect.map(normalizeRepositoryCloneUrls),
203 ),
204 createPullRequest: (input) =>
205 execute({
206 cwd: input.cwd,
207 args: [
208 "pr",
209 "create",
210 "--base",
211 input.baseBranch,
212 "--head",
213 input.headSelector,
214 "--title",
215 input.title,
216 "--body-file",
217 input.bodyFile,
218 ],
219 }).pipe(Effect.asVoid),
220 getDefaultBranch: (input) =>
221 execute({
222 cwd: input.cwd,
223 args: ["repo", "view", "--json", "defaultBranchRef", "--jq", ".defaultBranchRef.name"],
224 }).pipe(
225 Effect.map((value) => {
226 const trimmed = value.stdout.trim();
227 return trimmed.length > 0 ? trimmed : null;
228 }),
229 ),
230 checkoutPullRequest: (input) =>
231 execute({
232 cwd: input.cwd,
233 args: ["pr", "checkout", input.reference, ...(input.force ? ["--force"] : [])],
234 }).pipe(Effect.asVoid),
235 } satisfies GitHubCliShape;
236
237 return service;
238});
239
240export const GitHubCliLive = Layer.effect(GitHubCli, makeGitHubCli);
apps/server/src/git/Layers/GitManager.test.ts deleted-3127
......@@ -1,3127 +0,0 @@
1import fs from "node:fs";
2import path from "node:path";
3import { spawnSync } from "node:child_process";
4
5import * as NodeServices from "@effect/platform-node/NodeServices";
6import { it } from "@effect/vitest";
7import { Effect, FileSystem, Layer, PlatformError, Scope } from "effect";
8import { expect } from "vitest";
9import type {
10 GitActionProgressEvent,
11 GitPreparePullRequestThreadInput,
12 ModelSelection,
13 ThreadId,
14} from "@t3tools/contracts";
15
16import { GitCommandError, GitHubCliError, TextGenerationError } from "@t3tools/contracts";
17import { type GitManagerShape } from "../Services/GitManager.ts";
18import {
19 type GitHubCliShape,
20 type GitHubPullRequestSummary,
21 GitHubCli,
22} from "../Services/GitHubCli.ts";
23import { type TextGenerationShape, TextGeneration } from "../Services/TextGeneration.ts";
24import { GitCoreLive } from "./GitCore.ts";
25import { GitCore } from "../Services/GitCore.ts";
26import { makeGitManager } from "./GitManager.ts";
27import { ServerConfig } from "../../config.ts";
28import { ServerSettingsService } from "../../serverSettings.ts";
29import {
30 ProjectSetupScriptRunner,
31 type ProjectSetupScriptRunnerInput,
32 type ProjectSetupScriptRunnerShape,
33} from "../../project/Services/ProjectSetupScriptRunner.ts";
34
35interface FakeGhScenario {
36 prListSequence?: string[];
37 prListByHeadSelector?: Record<string, string>;
38 prListSequenceByHeadSelector?: Record<string, string[]>;
39 createdPrUrl?: string;
40 defaultBranch?: string;
41 pullRequest?: {
42 number: number;
43 title: string;
44 url: string;
45 baseRefName: string;
46 headRefName: string;
47 state?: "open" | "closed" | "merged";
48 isCrossRepository?: boolean;
49 headRepositoryNameWithOwner?: string | null;
50 headRepositoryOwnerLogin?: string | null;
51 };
52 repositoryCloneUrls?: Record<string, { url: string; sshUrl: string }>;
53 failWith?: GitHubCliError;
54}
55
56interface FakeGitTextGeneration {
57 generateCommitMessage: (input: {
58 cwd: string;
59 branch: string | null;
60 stagedSummary: string;
61 stagedPatch: string;
62 includeBranch?: boolean;
63 modelSelection: ModelSelection;
64 }) => Effect.Effect<
65 { subject: string; body: string; branch?: string | undefined },
66 TextGenerationError
67 >;
68 generatePrContent: (input: {
69 cwd: string;
70 baseBranch: string;
71 headBranch: string;
72 commitSummary: string;
73 diffSummary: string;
74 diffPatch: string;
75 modelSelection: ModelSelection;
76 }) => Effect.Effect<{ title: string; body: string }, TextGenerationError>;
77 generateBranchName: (input: {
78 cwd: string;
79 message: string;
80 modelSelection: ModelSelection;
81 }) => Effect.Effect<{ branch: string }, TextGenerationError>;
82 generateThreadTitle: (input: {
83 cwd: string;
84 message: string;
85 modelSelection: ModelSelection;
86 }) => Effect.Effect<{ title: string }, TextGenerationError>;
87}
88
89type FakePullRequest = NonNullable<FakeGhScenario["pullRequest"]>;
90
91function normalizeFakePullRequestSummary(raw: unknown): GitHubPullRequestSummary | null {
92 if (!raw || typeof raw !== "object") {
93 return null;
94 }
95
96 const record = raw as Record<string, unknown>;
97 const number = record.number;
98 const title = record.title;
99 const url = record.url;
100 const baseRefName = record.baseRefName;
101 const headRefName = record.headRefName;
102 const headRepository =
103 typeof record.headRepository === "object" && record.headRepository !== null
104 ? (record.headRepository as Record<string, unknown>)
105 : null;
106 const headRepositoryOwner =
107 typeof record.headRepositoryOwner === "object" && record.headRepositoryOwner !== null
108 ? (record.headRepositoryOwner as Record<string, unknown>)
109 : null;
110
111 if (
112 typeof number !== "number" ||
113 typeof title !== "string" ||
114 typeof url !== "string" ||
115 typeof baseRefName !== "string" ||
116 typeof headRefName !== "string"
117 ) {
118 return null;
119 }
120
121 const state =
122 typeof record.state === "string"
123 ? record.state === "OPEN" || record.state === "open"
124 ? "open"
125 : record.state === "CLOSED" || record.state === "closed"
126 ? "closed"
127 : "merged"
128 : undefined;
129 const isCrossRepository =
130 typeof record.isCrossRepository === "boolean" ? record.isCrossRepository : undefined;
131 const headRepositoryNameWithOwner =
132 typeof record.headRepositoryNameWithOwner === "string"
133 ? record.headRepositoryNameWithOwner
134 : typeof headRepository?.nameWithOwner === "string"
135 ? headRepository.nameWithOwner
136 : undefined;
137 const headRepositoryOwnerLogin =
138 typeof record.headRepositoryOwnerLogin === "string"
139 ? record.headRepositoryOwnerLogin
140 : typeof headRepositoryOwner?.login === "string"
141 ? headRepositoryOwner.login
142 : undefined;
143
144 return {
145 number,
146 title,
147 url,
148 baseRefName,
149 headRefName,
150 ...(state ? { state } : {}),
151 ...(isCrossRepository !== undefined ? { isCrossRepository } : {}),
152 ...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}),
153 ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}),
154 };
155}
156
157function runGitSyncForFakeGh(cwd: string, args: readonly string[]): void {
158 const result = spawnSync("git", args, {
159 cwd,
160 encoding: "utf8",
161 });
162 if (result.status === 0) {
163 return;
164 }
165 throw new GitHubCliError({
166 operation: "execute",
167 detail: `Failed to simulate gh checkout with git ${args.join(" ")}: ${result.stderr?.trim() || "unknown error"}`,
168 });
169}
170
171function isGitHubCliError(error: unknown): error is GitHubCliError {
172 return (
173 typeof error === "object" &&
174 error !== null &&
175 "_tag" in error &&
176 (error as { _tag?: unknown })._tag === "GitHubCliError"
177 );
178}
179
180function makeTempDir(
181 prefix: string,
182): Effect.Effect<string, PlatformError.PlatformError, FileSystem.FileSystem | Scope.Scope> {
183 return Effect.gen(function* () {
184 const fileSystem = yield* FileSystem.FileSystem;
185 return yield* fileSystem.makeTempDirectoryScoped({ prefix });
186 });
187}
188
189function removePath(
190 targetPath: string,
191): Effect.Effect<void, PlatformError.PlatformError, FileSystem.FileSystem> {
192 return Effect.gen(function* () {
193 const fileSystem = yield* FileSystem.FileSystem;
194 yield* fileSystem.remove(targetPath, { recursive: true, force: true });
195 });
196}
197
198function makeDirectory(
199 dirPath: string,
200): Effect.Effect<void, PlatformError.PlatformError, FileSystem.FileSystem> {
201 return Effect.gen(function* () {
202 const fileSystem = yield* FileSystem.FileSystem;
203 yield* fileSystem.makeDirectory(dirPath, { recursive: true });
204 });
205}
206
207function runGit(
208 cwd: string,
209 args: readonly string[],
210 allowNonZeroExit = false,
211): Effect.Effect<
212 { readonly code: number; readonly stdout: string; readonly stderr: string },
213 GitCommandError,
214 GitCore
215> {
216 return Effect.gen(function* () {
217 const gitCore = yield* GitCore;
218 return yield* gitCore.execute({
219 operation: "GitManager.test.runGit",
220 cwd,
221 args,
222 allowNonZeroExit,
223 });
224 });
225}
226
227function initRepo(
228 cwd: string,
229): Effect.Effect<
230 void,
231 PlatformError.PlatformError | GitCommandError,
232 FileSystem.FileSystem | Scope.Scope | GitCore
233> {
234 return Effect.gen(function* () {
235 const fs = yield* FileSystem.FileSystem;
236 yield* runGit(cwd, ["init", "--initial-branch=main"]);
237 yield* runGit(cwd, ["config", "user.email", "test@example.com"]);
238 yield* runGit(cwd, ["config", "user.name", "Test User"]);
239 yield* fs.writeFileString(path.join(cwd, "README.md"), "hello\n");
240 yield* runGit(cwd, ["add", "README.md"]);
241 yield* runGit(cwd, ["commit", "-m", "Initial commit"]);
242 });
243}
244
245function createBareRemote(): Effect.Effect<
246 string,
247 PlatformError.PlatformError | GitCommandError,
248 FileSystem.FileSystem | Scope.Scope | GitCore
249> {
250 return Effect.gen(function* () {
251 const remoteDir = yield* makeTempDir("t3code-git-remote-");
252 yield* runGit(remoteDir, ["init", "--bare"]);
253 return remoteDir;
254 });
255}
256
257function configureRemote(
258 cwd: string,
259 remoteName: string,
260 remotePath: string,
261 fetchNamespace: string,
262): Effect.Effect<void, GitCommandError, GitCore> {
263 return Effect.gen(function* () {
264 yield* runGit(cwd, ["config", `remote.${remoteName}.url`, remotePath]);
265 yield* runGit(cwd, [
266 "config",
267 "--replace-all",
268 `remote.${remoteName}.fetch`,
269 `+refs/heads/*:refs/remotes/${fetchNamespace}/*`,
270 ]);
271 });
272}
273
274function createTextGeneration(overrides: Partial<FakeGitTextGeneration> = {}): TextGenerationShape {
275 const implementation: FakeGitTextGeneration = {
276 generateCommitMessage: (input) =>
277 Effect.succeed({
278 subject: "Implement stacked git actions",
279 body: "",
280 ...(input.includeBranch ? { branch: "feature/implement-stacked-git-actions" } : {}),
281 }),
282 generatePrContent: () =>
283 Effect.succeed({
284 title: "Add stacked git actions",
285 body: "## Summary\n- Add stacked git workflow\n\n## Testing\n- Not run",
286 }),
287 generateBranchName: () =>
288 Effect.succeed({
289 branch: "update-workflow",
290 }),
291 generateThreadTitle: () =>
292 Effect.succeed({
293 title: "Update workflow",
294 }),
295 ...overrides,
296 };
297
298 return {
299 generateCommitMessage: (input) =>
300 implementation.generateCommitMessage(input).pipe(
301 Effect.mapError(
302 (cause) =>
303 new TextGenerationError({
304 operation: "generateCommitMessage",
305 detail: "fake text generation failed",
306 ...(cause !== undefined ? { cause } : {}),
307 }),
308 ),
309 ),
310 generatePrContent: (input) =>
311 implementation.generatePrContent(input).pipe(
312 Effect.mapError(
313 (cause) =>
314 new TextGenerationError({
315 operation: "generatePrContent",
316 detail: "fake text generation failed",
317 ...(cause !== undefined ? { cause } : {}),
318 }),
319 ),
320 ),
321 generateBranchName: (input) =>
322 implementation.generateBranchName(input).pipe(
323 Effect.mapError(
324 (cause) =>
325 new TextGenerationError({
326 operation: "generateBranchName",
327 detail: "fake text generation failed",
328 ...(cause !== undefined ? { cause } : {}),
329 }),
330 ),
331 ),
332 generateThreadTitle: (input) =>
333 implementation.generateThreadTitle(input).pipe(
334 Effect.mapError(
335 (cause) =>
336 new TextGenerationError({
337 operation: "generateThreadTitle",
338 detail: "fake text generation failed",
339 ...(cause !== undefined ? { cause } : {}),
340 }),
341 ),
342 ),
343 };
344}
345
346function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): {
347 service: GitHubCliShape;
348 ghCalls: string[];
349} {
350 const prListQueue = [...(scenario.prListSequence ?? [])];
351 const prListQueueByHeadSelector = new Map(
352 Object.entries(scenario.prListSequenceByHeadSelector ?? {}).map(([headSelector, values]) => [
353 headSelector,
354 [...values],
355 ]),
356 );
357 const ghCalls: string[] = [];
358
359 const execute: GitHubCliShape["execute"] = (input) => {
360 const args = [...input.args];
361 ghCalls.push(args.join(" "));
362
363 if (scenario.failWith) {
364 return Effect.fail(scenario.failWith);
365 }
366
367 if (args[0] === "pr" && args[1] === "list") {
368 const headSelectorIndex = args.findIndex((value) => value === "--head");
369 const headSelector =
370 headSelectorIndex >= 0 && headSelectorIndex < args.length - 1
371 ? args[headSelectorIndex + 1]
372 : undefined;
373 const mappedQueue =
374 typeof headSelector === "string"
375 ? prListQueueByHeadSelector.get(headSelector)?.shift()
376 : undefined;
377 const mappedStdout =
378 typeof headSelector === "string"
379 ? scenario.prListByHeadSelector?.[headSelector]
380 : undefined;
381 const stdout = (mappedQueue ?? mappedStdout ?? prListQueue.shift() ?? "[]") + "\n";
382 return Effect.succeed({
383 stdout,
384 stderr: "",
385 code: 0,
386 signal: null,
387 timedOut: false,
388 });
389 }
390
391 if (args[0] === "pr" && args[1] === "create") {
392 return Effect.succeed({
393 stdout:
394 (scenario.createdPrUrl ?? "https://github.com/pingdotgg/codething-mvp/pull/101") + "\n",
395 stderr: "",
396 code: 0,
397 signal: null,
398 timedOut: false,
399 });
400 }
401
402 if (args[0] === "pr" && args[1] === "view") {
403 const pullRequest: FakePullRequest = scenario.pullRequest ?? {
404 number: 101,
405 title: "Pull request",
406 url: "https://github.com/pingdotgg/codething-mvp/pull/101",
407 baseRefName: "main",
408 headRefName: "feature/pull-request",
409 state: "open",
410 };
411 return Effect.succeed({
412 stdout:
413 JSON.stringify({
414 ...pullRequest,
415 ...(pullRequest.headRepositoryNameWithOwner
416 ? {
417 headRepository: {
418 nameWithOwner: pullRequest.headRepositoryNameWithOwner,
419 },
420 }
421 : {}),
422 ...(pullRequest.headRepositoryOwnerLogin
423 ? {
424 headRepositoryOwner: {
425 login: pullRequest.headRepositoryOwnerLogin,
426 },
427 }
428 : {}),
429 }) + "\n",
430 stderr: "",
431 code: 0,
432 signal: null,
433 timedOut: false,
434 });
435 }
436
437 if (args[0] === "pr" && args[1] === "checkout") {
438 return Effect.try({
439 try: () => {
440 const headBranch = scenario.pullRequest?.headRefName;
441 if (headBranch) {
442 const existingBranch = spawnSync(
443 "git",
444 ["show-ref", "--verify", "--quiet", `refs/heads/${headBranch}`],
445 {
446 cwd: input.cwd,
447 encoding: "utf8",
448 },
449 );
450 if (existingBranch.status === 0) {
451 runGitSyncForFakeGh(input.cwd, ["checkout", headBranch]);
452 } else {
453 runGitSyncForFakeGh(input.cwd, ["checkout", "-b", headBranch]);
454 }
455 }
456 return {
457 stdout: "",
458 stderr: "",
459 code: 0,
460 signal: null,
461 timedOut: false,
462 };
463 },
464 catch: (error) =>
465 isGitHubCliError(error)
466 ? error
467 : new GitHubCliError({
468 operation: "execute",
469 detail:
470 error instanceof Error
471 ? `Failed to simulate gh checkout: ${error.message}`
472 : "Failed to simulate gh checkout.",
473 }),
474 });
475 }
476
477 if (args[0] === "repo" && args[1] === "view") {
478 const repository = args[2];
479 if (typeof repository === "string" && args.includes("nameWithOwner,url,sshUrl")) {
480 const cloneUrls = scenario.repositoryCloneUrls?.[repository];
481 if (!cloneUrls) {
482 return Effect.fail(
483 new GitHubCliError({
484 operation: "execute",
485 detail: `Unexpected repository lookup: ${repository}`,
486 }),
487 );
488 }
489 return Effect.succeed({
490 stdout:
491 JSON.stringify({
492 nameWithOwner: repository,
493 url: cloneUrls.url,
494 sshUrl: cloneUrls.sshUrl,
495 }) + "\n",
496 stderr: "",
497 code: 0,
498 signal: null,
499 timedOut: false,
500 });
501 }
502 return Effect.succeed({
503 stdout: `${scenario.defaultBranch ?? "main"}\n`,
504 stderr: "",
505 code: 0,
506 signal: null,
507 timedOut: false,
508 });
509 }
510
511 return Effect.fail(
512 new GitHubCliError({
513 operation: "execute",
514 detail: `Unexpected gh command: ${args.join(" ")}`,
515 }),
516 );
517 };
518
519 return {
520 service: {
521 execute,
522 listOpenPullRequests: (input) =>
523 execute({
524 cwd: input.cwd,
525 args: [
526 "pr",
527 "list",
528 "--head",
529 input.headSelector,
530 "--state",
531 "open",
532 "--limit",
533 String(input.limit ?? 1),
534 "--json",
535 "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner",
536 ],
537 }).pipe(
538 Effect.map((result) => JSON.parse(result.stdout) as unknown[]),
539 Effect.map((raw) =>
540 raw
541 .map((entry) => normalizeFakePullRequestSummary(entry))
542 .filter((entry): entry is GitHubPullRequestSummary => entry !== null),
543 ),
544 ),
545 createPullRequest: (input) =>
546 execute({
547 cwd: input.cwd,
548 args: [
549 "pr",
550 "create",
551 "--base",
552 input.baseBranch,
553 "--head",
554 input.headSelector,
555 "--title",
556 input.title,
557 "--body-file",
558 input.bodyFile,
559 ],
560 }).pipe(Effect.asVoid),
561 getDefaultBranch: (input) =>
562 execute({
563 cwd: input.cwd,
564 args: ["repo", "view", "--json", "defaultBranchRef", "--jq", ".defaultBranchRef.name"],
565 }).pipe(
566 Effect.map((result) => {
567 const value = result.stdout.trim();
568 return value.length > 0 ? value : null;
569 }),
570 ),
571 getPullRequest: (input) =>
572 execute({
573 cwd: input.cwd,
574 args: [
575 "pr",
576 "view",
577 input.reference,
578 "--json",
579 "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner",
580 ],
581 }).pipe(Effect.map((result) => JSON.parse(result.stdout) as GitHubPullRequestSummary)),
582 getRepositoryCloneUrls: (input) =>
583 execute({
584 cwd: input.cwd,
585 args: ["repo", "view", input.repository, "--json", "nameWithOwner,url,sshUrl"],
586 }).pipe(Effect.map((result) => JSON.parse(result.stdout))),
587 checkoutPullRequest: (input) =>
588 execute({
589 cwd: input.cwd,
590 args: ["pr", "checkout", input.reference, ...(input.force ? ["--force"] : [])],
591 }).pipe(Effect.asVoid),
592 },
593 ghCalls,
594 };
595}
596
597function runStackedAction(
598 manager: GitManagerShape,
599 input: {
600 cwd: string;
601 action: "commit" | "push" | "create_pr" | "commit_push" | "commit_push_pr";
602 actionId?: string;
603 commitMessage?: string;
604 featureBranch?: boolean;
605 filePaths?: readonly string[];
606 },
607 options?: Parameters<GitManagerShape["runStackedAction"]>[1],
608) {
609 return manager.runStackedAction(
610 {
611 ...input,
612 actionId: input.actionId ?? "test-action-id",
613 },
614 options,
615 );
616}
617
618function resolvePullRequest(manager: GitManagerShape, input: { cwd: string; reference: string }) {
619 return manager.resolvePullRequest(input);
620}
621
622function preparePullRequestThread(
623 manager: GitManagerShape,
624 input: GitPreparePullRequestThreadInput,
625) {
626 return manager.preparePullRequestThread(input);
627}
628
629function makeManager(input?: {
630 ghScenario?: FakeGhScenario;
631 textGeneration?: Partial<FakeGitTextGeneration>;
632 setupScriptRunner?: ProjectSetupScriptRunnerShape;
633}) {
634 const { service: gitHubCli, ghCalls } = createGitHubCliWithFakeGh(input?.ghScenario);
635 const textGeneration = createTextGeneration(input?.textGeneration);
636 const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), {
637 prefix: "t3-git-manager-test-",
638 });
639
640 const serverSettingsLayer = ServerSettingsService.layerTest();
641
642 const gitCoreLayer = GitCoreLive.pipe(
643 Layer.provideMerge(NodeServices.layer),
644 Layer.provideMerge(ServerConfigLayer),
645 );
646
647 const managerLayer = Layer.mergeAll(
648 Layer.succeed(GitHubCli, gitHubCli),
649 Layer.succeed(TextGeneration, textGeneration),
650 Layer.succeed(
651 ProjectSetupScriptRunner,
652 input?.setupScriptRunner ?? {
653 runForThread: () => Effect.succeed({ status: "no-script" as const }),
654 },
655 ),
656 gitCoreLayer,
657 serverSettingsLayer,
658 ).pipe(Layer.provideMerge(NodeServices.layer));
659
660 return makeGitManager().pipe(
661 Effect.provide(managerLayer),
662 Effect.map((manager) => ({ manager, ghCalls })),
663 );
664}
665
666const asThreadId = (threadId: string) => threadId as ThreadId;
667
668const GitManagerTestLayer = GitCoreLive.pipe(
669 Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-manager-test-" })),
670 Layer.provideMerge(NodeServices.layer),
671);
672
673it.layer(GitManagerTestLayer)("GitManager", (it) => {
674 it.effect("status includes PR metadata when branch already has an open PR", () =>
675 Effect.gen(function* () {
676 const repoDir = yield* makeTempDir("t3code-git-manager-");
677 yield* initRepo(repoDir);
678 yield* runGit(repoDir, ["checkout", "-b", "feature/status-open-pr"]);
679 const remoteDir = yield* createBareRemote();
680 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
681 yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-open-pr"]);
682
683 const { manager } = yield* makeManager({
684 ghScenario: {
685 prListSequence: [
686 JSON.stringify([
687 {
688 number: 13,
689 title: "Existing PR",
690 url: "https://github.com/pingdotgg/codething-mvp/pull/13",
691 baseRefName: "main",
692 headRefName: "feature/status-open-pr",
693 },
694 ]),
695 ],
696 },
697 });
698
699 const status = yield* manager.status({ cwd: repoDir });
700 expect(status.isRepo).toBe(true);
701 expect(status.hasOriginRemote).toBe(true);
702 expect(status.isDefaultBranch).toBe(false);
703 expect(status.branch).toBe("feature/status-open-pr");
704 expect(status.pr).toEqual({
705 number: 13,
706 title: "Existing PR",
707 url: "https://github.com/pingdotgg/codething-mvp/pull/13",
708 baseBranch: "main",
709 headBranch: "feature/status-open-pr",
710 state: "open",
711 });
712 }),
713 );
714
715 it.effect("status trims PR metadata returned by gh before publishing it", () =>
716 Effect.gen(function* () {
717 const repoDir = yield* makeTempDir("t3code-git-manager-");
718 yield* initRepo(repoDir);
719 yield* runGit(repoDir, ["checkout", "-b", "feature/status-trimmed-pr"]);
720 const remoteDir = yield* createBareRemote();
721 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
722 yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-trimmed-pr"]);
723
724 const { manager } = yield* makeManager({
725 ghScenario: {
726 prListSequence: [
727 JSON.stringify([
728 {
729 number: 14,
730 title: " Existing PR title \n",
731 url: " https://github.com/pingdotgg/codething-mvp/pull/14 ",
732 baseRefName: " main ",
733 headRefName: "\tfeature/status-trimmed-pr\t",
734 },
735 ]),
736 ],
737 },
738 });
739
740 const status = yield* manager.status({ cwd: repoDir });
741
742 expect(status.pr).toEqual({
743 number: 14,
744 title: "Existing PR title",
745 url: "https://github.com/pingdotgg/codething-mvp/pull/14",
746 baseBranch: "main",
747 headBranch: "feature/status-trimmed-pr",
748 state: "open",
749 });
750 }),
751 );
752
753 it.effect("status ignores invalid gh pr list entries and keeps valid ones", () =>
754 Effect.gen(function* () {
755 const repoDir = yield* makeTempDir("t3code-git-manager-");
756 yield* initRepo(repoDir);
757 yield* runGit(repoDir, ["checkout", "-b", "feature/status-valid-pr-entry"]);
758 const remoteDir = yield* createBareRemote();
759 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
760 yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-valid-pr-entry"]);
761
762 const { manager } = yield* makeManager({
763 ghScenario: {
764 prListSequence: [
765 JSON.stringify([
766 {
767 number: 0,
768 title: "invalid",
769 url: "https://github.com/pingdotgg/codething-mvp/pull/0",
770 baseRefName: "main",
771 headRefName: "feature/invalid",
772 },
773 {
774 number: 15,
775 title: " Valid PR title ",
776 url: " https://github.com/pingdotgg/codething-mvp/pull/15 ",
777 baseRefName: " main ",
778 headRefName: "\tfeature/status-valid-pr-entry\t",
779 headRepository: {
780 nameWithOwner: " ",
781 },
782 headRepositoryOwner: {
783 login: " ",
784 },
785 },
786 ]),
787 ],
788 },
789 });
790
791 const status = yield* manager.status({ cwd: repoDir });
792
793 expect(status.pr).toEqual({
794 number: 15,
795 title: "Valid PR title",
796 url: "https://github.com/pingdotgg/codething-mvp/pull/15",
797 baseBranch: "main",
798 headBranch: "feature/status-valid-pr-entry",
799 state: "open",
800 });
801 }),
802 );
803
804 it.effect("status preserves lowercase merged and closed PR states from gh json", () =>
805 Effect.gen(function* () {
806 const repoDir = yield* makeTempDir("t3code-git-manager-");
807 yield* initRepo(repoDir);
808 yield* runGit(repoDir, ["checkout", "-b", "feature/status-lowercase-state"]);
809 const remoteDir = yield* createBareRemote();
810 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
811 yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-lowercase-state"]);
812
813 const { manager } = yield* makeManager({
814 ghScenario: {
815 prListSequence: [
816 JSON.stringify([
817 {
818 number: 16,
819 title: "Closed PR",
820 url: "https://github.com/pingdotgg/codething-mvp/pull/16",
821 baseRefName: "main",
822 headRefName: "feature/status-lowercase-state",
823 state: "closed",
824 updatedAt: "2026-01-01T00:00:00.000Z",
825 },
826 {
827 number: 17,
828 title: "Merged PR",
829 url: "https://github.com/pingdotgg/codething-mvp/pull/17",
830 baseRefName: "main",
831 headRefName: "feature/status-lowercase-state",
832 state: "merged",
833 updatedAt: "2026-01-02T00:00:00.000Z",
834 },
835 ]),
836 ],
837 },
838 });
839
840 const status = yield* manager.status({ cwd: repoDir });
841
842 expect(status.pr).toEqual({
843 number: 17,
844 title: "Merged PR",
845 url: "https://github.com/pingdotgg/codething-mvp/pull/17",
846 baseBranch: "main",
847 headBranch: "feature/status-lowercase-state",
848 state: "merged",
849 });
850 }),
851 );
852
853 it.effect("status returns an explicit non-repo result for non-git directories", () =>
854 Effect.gen(function* () {
855 const cwd = yield* makeTempDir("t3code-git-manager-non-repo-");
856 const { manager } = yield* makeManager();
857
858 const status = yield* manager.status({ cwd });
859
860 expect(status).toEqual({
861 isRepo: false,
862 hasOriginRemote: false,
863 isDefaultBranch: false,
864 branch: null,
865 hasWorkingTreeChanges: false,
866 workingTree: {
867 files: [],
868 insertions: 0,
869 deletions: 0,
870 },
871 hasUpstream: false,
872 aheadCount: 0,
873 behindCount: 0,
874 pr: null,
875 });
876 }),
877 );
878
879 it.effect("status returns an explicit non-repo result for deleted directories", () =>
880 Effect.gen(function* () {
881 const rootDir = yield* makeTempDir("t3code-git-manager-missing-dir-");
882 const cwd = path.join(rootDir, "deleted-repo");
883 yield* makeDirectory(cwd);
884 yield* removePath(cwd);
885 const { manager } = yield* makeManager();
886
887 const status = yield* manager.status({ cwd });
888
889 expect(status).toEqual({
890 isRepo: false,
891 hasOriginRemote: false,
892 isDefaultBranch: false,
893 branch: null,
894 hasWorkingTreeChanges: false,
895 workingTree: {
896 files: [],
897 insertions: 0,
898 deletions: 0,
899 },
900 hasUpstream: false,
901 aheadCount: 0,
902 behindCount: 0,
903 pr: null,
904 });
905 }),
906 );
907
908 it.effect("status briefly caches repeated lookups for the same cwd", () =>
909 Effect.gen(function* () {
910 const repoDir = yield* makeTempDir("t3code-git-manager-");
911 yield* initRepo(repoDir);
912 yield* runGit(repoDir, ["checkout", "-b", "feature/status-cache"]);
913 const remoteDir = yield* createBareRemote();
914 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
915 yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-cache"]);
916
917 const existingPr = {
918 number: 113,
919 title: "Cached PR",
920 url: "https://github.com/pingdotgg/codething-mvp/pull/113",
921 baseRefName: "main",
922 headRefName: "feature/status-cache",
923 };
924 const { manager, ghCalls } = yield* makeManager({
925 ghScenario: {
926 prListSequence: [JSON.stringify([existingPr]), JSON.stringify([existingPr])],
927 },
928 });
929
930 const first = yield* manager.status({ cwd: repoDir });
931 const second = yield* manager.status({ cwd: repoDir });
932
933 expect(first.pr?.number).toBe(113);
934 expect(second.pr?.number).toBe(113);
935 expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(1);
936 }),
937 );
938
939 it.effect(
940 "status ignores unrelated fork PRs when the current branch tracks the same repository",
941 () =>
942 Effect.gen(function* () {
943 const repoDir = yield* makeTempDir("t3code-git-manager-");
944 yield* initRepo(repoDir);
945 const remoteDir = yield* createBareRemote();
946 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
947 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
948
949 const { manager } = yield* makeManager({
950 ghScenario: {
951 prListSequence: [
952 JSON.stringify([
953 {
954 number: 1661,
955 title: "Fork PR from main",
956 url: "https://github.com/pingdotgg/t3code/pull/1661",
957 baseRefName: "main",
958 headRefName: "main",
959 state: "OPEN",
960 updatedAt: "2026-04-01T15:00:00Z",
961 isCrossRepository: true,
962 headRepository: {
963 nameWithOwner: "lnieuwenhuis/t3code",
964 },
965 headRepositoryOwner: {
966 login: "lnieuwenhuis",
967 },
968 },
969 ]),
970 ],
971 },
972 });
973
974 const status = yield* manager.status({ cwd: repoDir });
975 expect(status.branch).toBe("main");
976 expect(status.pr).toBeNull();
977 }),
978 );
979
980 it.effect(
981 "status detects cross-repo PRs from the upstream remote URL owner",
982 () =>
983 Effect.gen(function* () {
984 const repoDir = yield* makeTempDir("t3code-git-manager-");
985 yield* initRepo(repoDir);
986 const forkDir = yield* createBareRemote();
987 yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]);
988 yield* runGit(repoDir, ["checkout", "-b", "statemachine"]);
989 fs.writeFileSync(path.join(repoDir, "fork-pr.txt"), "fork pr\n");
990 yield* runGit(repoDir, ["add", "fork-pr.txt"]);
991 yield* runGit(repoDir, ["commit", "-m", "Fork PR branch"]);
992 yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]);
993 yield* runGit(repoDir, ["checkout", "-b", "t3code/pr-488/statemachine"]);
994 yield* runGit(repoDir, ["branch", "--set-upstream-to", "fork-seed/statemachine"]);
995 yield* runGit(repoDir, [
996 "config",
997 "remote.fork-seed.url",
998 "git@github.com:jasonLaster/codething-mvp.git",
999 ]);
1000
1001 const { manager, ghCalls } = yield* makeManager({
1002 ghScenario: {
1003 prListSequence: [
1004 JSON.stringify([]),
1005 JSON.stringify([]),
1006 JSON.stringify([
1007 {
1008 number: 488,
1009 title: "Rebase this PR on latest main",
1010 url: "https://github.com/pingdotgg/codething-mvp/pull/488",
1011 baseRefName: "main",
1012 headRefName: "statemachine",
1013 state: "OPEN",
1014 updatedAt: "2026-03-10T07:00:00Z",
1015 isCrossRepository: true,
1016 headRepository: {
1017 nameWithOwner: "jasonLaster/codething-mvp",
1018 },
1019 headRepositoryOwner: {
1020 login: "jasonLaster",
1021 },
1022 },
1023 ]),
1024 ],
1025 },
1026 });
1027
1028 const status = yield* manager.status({ cwd: repoDir });
1029 expect(status.branch).toBe("t3code/pr-488/statemachine");
1030 expect(status.pr).toEqual({
1031 number: 488,
1032 title: "Rebase this PR on latest main",
1033 url: "https://github.com/pingdotgg/codething-mvp/pull/488",
1034 baseBranch: "main",
1035 headBranch: "statemachine",
1036 state: "open",
1037 });
1038 expect(ghCalls).toContain(
1039 "pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",
1040 );
1041 }),
1042 20_000,
1043 );
1044
1045 it.effect(
1046 "status ignores synthetic local branch aliases when the upstream remote name contains slashes",
1047 () =>
1048 Effect.gen(function* () {
1049 const repoDir = yield* makeTempDir("t3code-git-manager-");
1050 yield* initRepo(repoDir);
1051 const originDir = yield* createBareRemote();
1052 const upstreamDir = yield* createBareRemote();
1053 yield* configureRemote(repoDir, "origin", originDir, "origin");
1054 yield* configureRemote(repoDir, "my-org/upstream", upstreamDir, "my-org/upstream");
1055
1056 yield* runGit(repoDir, ["checkout", "-b", "effect-atom"]);
1057 yield* runGit(repoDir, ["push", "-u", "origin", "effect-atom"]);
1058 yield* runGit(repoDir, ["push", "-u", "my-org/upstream", "effect-atom"]);
1059 yield* runGit(repoDir, [
1060 "config",
1061 "remote.origin.url",
1062 "git@github.com:pingdotgg/codething-mvp.git",
1063 ]);
1064 yield* runGit(repoDir, ["config", "remote.origin.pushurl", originDir]);
1065 yield* runGit(repoDir, [
1066 "config",
1067 "remote.my-org/upstream.url",
1068 "git@github.com:pingdotgg/codething-mvp.git",
1069 ]);
1070 yield* runGit(repoDir, ["config", "remote.my-org/upstream.pushurl", upstreamDir]);
1071 yield* runGit(repoDir, ["checkout", "main"]);
1072 yield* runGit(repoDir, ["branch", "-D", "effect-atom"]);
1073 yield* runGit(repoDir, ["checkout", "--track", "my-org/upstream/effect-atom"]);
1074
1075 const { manager, ghCalls } = yield* makeManager({
1076 ghScenario: {
1077 prListByHeadSelector: {
1078 "effect-atom": JSON.stringify([
1079 {
1080 number: 1618,
1081 title: "Correct PR",
1082 url: "https://github.com/pingdotgg/t3code/pull/1618",
1083 baseRefName: "main",
1084 headRefName: "effect-atom",
1085 state: "OPEN",
1086 updatedAt: "2026-03-01T10:00:00Z",
1087 },
1088 ]),
1089 "upstream/effect-atom": JSON.stringify([
1090 {
1091 number: 1518,
1092 title: "Wrong PR",
1093 url: "https://github.com/pingdotgg/t3code/pull/1518",
1094 baseRefName: "main",
1095 headRefName: "upstream/effect-atom",
1096 state: "OPEN",
1097 updatedAt: "2026-04-01T10:00:00Z",
1098 },
1099 ]),
1100 "pingdotgg:effect-atom": JSON.stringify([]),
1101 "my-org/upstream:effect-atom": JSON.stringify([]),
1102 "pingdotgg:upstream/effect-atom": JSON.stringify([
1103 {
1104 number: 1518,
1105 title: "Wrong PR",
1106 url: "https://github.com/pingdotgg/t3code/pull/1518",
1107 baseRefName: "main",
1108 headRefName: "upstream/effect-atom",
1109 state: "OPEN",
1110 updatedAt: "2026-04-01T10:00:00Z",
1111 },
1112 ]),
1113 "my-org/upstream:upstream/effect-atom": JSON.stringify([
1114 {
1115 number: 1518,
1116 title: "Wrong PR",
1117 url: "https://github.com/pingdotgg/t3code/pull/1518",
1118 baseRefName: "main",
1119 headRefName: "upstream/effect-atom",
1120 state: "OPEN",
1121 updatedAt: "2026-04-01T10:00:00Z",
1122 },
1123 ]),
1124 },
1125 },
1126 });
1127
1128 const status = yield* manager.status({ cwd: repoDir });
1129 expect(status.branch).toBe("upstream/effect-atom");
1130 expect(status.pr).toEqual({
1131 number: 1618,
1132 title: "Correct PR",
1133 url: "https://github.com/pingdotgg/t3code/pull/1618",
1134 baseBranch: "main",
1135 headBranch: "effect-atom",
1136 state: "open",
1137 });
1138 expect(ghCalls.some((call) => call.includes("pr list --head upstream/effect-atom "))).toBe(
1139 false,
1140 );
1141 expect(
1142 ghCalls.some((call) => call.includes("pr list --head pingdotgg:upstream/effect-atom ")),
1143 ).toBe(false);
1144 expect(
1145 ghCalls.some((call) =>
1146 call.includes("pr list --head my-org/upstream:upstream/effect-atom "),
1147 ),
1148 ).toBe(false);
1149 }),
1150 20_000,
1151 );
1152
1153 it.effect("status returns merged PR state when latest PR was merged", () =>
1154 Effect.gen(function* () {
1155 const repoDir = yield* makeTempDir("t3code-git-manager-");
1156 yield* initRepo(repoDir);
1157 yield* runGit(repoDir, ["checkout", "-b", "feature/status-merged-pr"]);
1158
1159 const { manager } = yield* makeManager({
1160 ghScenario: {
1161 prListSequence: [
1162 JSON.stringify([
1163 {
1164 number: 22,
1165 title: "Merged PR",
1166 url: "https://github.com/pingdotgg/codething-mvp/pull/22",
1167 baseRefName: "main",
1168 headRefName: "feature/status-merged-pr",
1169 state: "MERGED",
1170 mergedAt: "2026-01-30T10:00:00Z",
1171 updatedAt: "2026-01-30T10:00:00Z",
1172 },
1173 ]),
1174 ],
1175 },
1176 });
1177
1178 const status = yield* manager.status({ cwd: repoDir });
1179 expect(status.branch).toBe("feature/status-merged-pr");
1180 expect(status.pr).toEqual({
1181 number: 22,
1182 title: "Merged PR",
1183 url: "https://github.com/pingdotgg/codething-mvp/pull/22",
1184 baseBranch: "main",
1185 headBranch: "feature/status-merged-pr",
1186 state: "merged",
1187 });
1188 }),
1189 );
1190
1191 it.effect("status hides merged PRs on the default branch", () =>
1192 Effect.gen(function* () {
1193 const repoDir = yield* makeTempDir("t3code-git-manager-");
1194 yield* initRepo(repoDir);
1195
1196 const { manager } = yield* makeManager({
1197 ghScenario: {
1198 prListSequence: [
1199 JSON.stringify([
1200 {
1201 number: 23,
1202 title: "Merged PR",
1203 url: "https://github.com/pingdotgg/codething-mvp/pull/23",
1204 baseRefName: "feature/status-default-branch-target",
1205 headRefName: "main",
1206 state: "MERGED",
1207 mergedAt: "2026-01-30T10:00:00Z",
1208 updatedAt: "2026-01-30T10:00:00Z",
1209 },
1210 ]),
1211 ],
1212 },
1213 });
1214
1215 const status = yield* manager.status({ cwd: repoDir });
1216 expect(status.branch).toBe("main");
1217 expect(status.pr).toBeNull();
1218 }),
1219 );
1220
1221 it.effect("status prefers open PR when merged PR has newer updatedAt", () =>
1222 Effect.gen(function* () {
1223 const repoDir = yield* makeTempDir("t3code-git-manager-");
1224 yield* initRepo(repoDir);
1225 yield* runGit(repoDir, ["checkout", "-b", "feature/status-open-over-merged"]);
1226
1227 const { manager } = yield* makeManager({
1228 ghScenario: {
1229 prListSequence: [
1230 JSON.stringify([
1231 {
1232 number: 45,
1233 title: "Merged PR",
1234 url: "https://github.com/pingdotgg/codething-mvp/pull/45",
1235 baseRefName: "main",
1236 headRefName: "feature/status-open-over-merged",
1237 state: "MERGED",
1238 mergedAt: "2026-01-31T10:00:00Z",
1239 updatedAt: "2026-02-01T10:00:00Z",
1240 },
1241 {
1242 number: 46,
1243 title: "Open PR",
1244 url: "https://github.com/pingdotgg/codething-mvp/pull/46",
1245 baseRefName: "main",
1246 headRefName: "feature/status-open-over-merged",
1247 state: "OPEN",
1248 updatedAt: "2026-01-30T10:00:00Z",
1249 },
1250 ]),
1251 ],
1252 },
1253 });
1254
1255 const status = yield* manager.status({ cwd: repoDir });
1256 expect(status.branch).toBe("feature/status-open-over-merged");
1257 expect(status.pr).toEqual({
1258 number: 46,
1259 title: "Open PR",
1260 url: "https://github.com/pingdotgg/codething-mvp/pull/46",
1261 baseBranch: "main",
1262 headBranch: "feature/status-open-over-merged",
1263 state: "open",
1264 });
1265 }),
1266 );
1267
1268 it.effect("status is resilient to gh lookup failures and returns pr null", () =>
1269 Effect.gen(function* () {
1270 const repoDir = yield* makeTempDir("t3code-git-manager-");
1271 yield* initRepo(repoDir);
1272 yield* runGit(repoDir, ["checkout", "-b", "feature/status-no-gh"]);
1273 const remoteDir = yield* createBareRemote();
1274 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
1275 yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-no-gh"]);
1276
1277 const { manager } = yield* makeManager({
1278 ghScenario: {
1279 failWith: new GitHubCliError({
1280 operation: "execute",
1281 detail: "GitHub CLI (`gh`) is required but not available on PATH.",
1282 }),
1283 },
1284 });
1285
1286 const status = yield* manager.status({ cwd: repoDir });
1287 expect(status.branch).toBe("feature/status-no-gh");
1288 expect(status.pr).toBeNull();
1289 }),
1290 );
1291
1292 it.effect("creates a commit when working tree is dirty", () =>
1293 Effect.gen(function* () {
1294 const repoDir = yield* makeTempDir("t3code-git-manager-");
1295 yield* initRepo(repoDir);
1296 fs.writeFileSync(path.join(repoDir, "README.md"), "hello\nworld\n");
1297
1298 const { manager } = yield* makeManager();
1299 const result = yield* runStackedAction(manager, {
1300 cwd: repoDir,
1301 action: "commit",
1302 });
1303
1304 expect(result.branch.status).toBe("skipped_not_requested");
1305 expect(result.commit.status).toBe("created");
1306 expect(result.push.status).toBe("skipped_not_requested");
1307 expect(result.pr.status).toBe("skipped_not_requested");
1308 expect(result.toast).toMatchObject({
1309 description: "Implement stacked git actions",
1310 cta: {
1311 kind: "run_action",
1312 label: "Push",
1313 action: {
1314 kind: "push",
1315 },
1316 },
1317 });
1318 expect(result.toast.title).toMatch(/^Committed [0-9a-f]{7}$/);
1319 expect(
1320 yield* runGit(repoDir, ["log", "-1", "--pretty=%s"]).pipe(
1321 Effect.map((result) => result.stdout.trim()),
1322 ),
1323 ).toBe("Implement stacked git actions");
1324 }),
1325 );
1326
1327 it.effect("uses custom commit message when provided", () =>
1328 Effect.gen(function* () {
1329 const repoDir = yield* makeTempDir("t3code-git-manager-");
1330 yield* initRepo(repoDir);
1331 fs.writeFileSync(path.join(repoDir, "README.md"), "hello\ncustom\n");
1332 let generatedCount = 0;
1333
1334 const { manager } = yield* makeManager({
1335 textGeneration: {
1336 generateCommitMessage: (input) =>
1337 Effect.sync(() => {
1338 generatedCount += 1;
1339 return {
1340 subject: "this should not be used",
1341 body: "",
1342 ...(input.includeBranch ? { branch: "feature/unused" } : {}),
1343 };
1344 }),
1345 },
1346 });
1347 const result = yield* runStackedAction(manager, {
1348 cwd: repoDir,
1349 action: "commit",
1350 commitMessage: "feat: custom summary line\n\n- details from user",
1351 });
1352
1353 expect(result.branch.status).toBe("skipped_not_requested");
1354 expect(result.commit.status).toBe("created");
1355 expect(result.commit.subject).toBe("feat: custom summary line");
1356 expect(generatedCount).toBe(0);
1357 expect(
1358 yield* runGit(repoDir, ["log", "-1", "--pretty=%s"]).pipe(
1359 Effect.map((result) => result.stdout.trim()),
1360 ),
1361 ).toBe("feat: custom summary line");
1362 expect(
1363 yield* runGit(repoDir, ["log", "-1", "--pretty=%b"]).pipe(
1364 Effect.map((result) => result.stdout.trim()),
1365 ),
1366 ).toContain("- details from user");
1367 }),
1368 );
1369
1370 it.effect("commits only selected files when filePaths is provided", () =>
1371 Effect.gen(function* () {
1372 const repoDir = yield* makeTempDir("t3code-git-manager-");
1373 yield* initRepo(repoDir);
1374 fs.writeFileSync(path.join(repoDir, "a.txt"), "file a\n");
1375 fs.writeFileSync(path.join(repoDir, "b.txt"), "file b\n");
1376
1377 const { manager } = yield* makeManager();
1378 const result = yield* runStackedAction(manager, {
1379 cwd: repoDir,
1380 action: "commit",
1381 filePaths: ["a.txt"],
1382 });
1383
1384 expect(result.commit.status).toBe("created");
1385
1386 // b.txt should remain in the working tree
1387 const statusStdout = yield* runGit(repoDir, ["status", "--porcelain"]).pipe(
1388 Effect.map((r) => r.stdout),
1389 );
1390 expect(statusStdout).toContain("b.txt");
1391 expect(statusStdout).not.toContain("a.txt");
1392 }),
1393 );
1394
1395 it.effect("creates feature branch, commits, and pushes with featureBranch option", () =>
1396 Effect.gen(function* () {
1397 const repoDir = yield* makeTempDir("t3code-git-manager-");
1398 yield* initRepo(repoDir);
1399 const remoteDir = yield* createBareRemote();
1400 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
1401 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
1402 fs.writeFileSync(path.join(repoDir, "README.md"), "hello\nfeature-branch\n");
1403 let generatedCount = 0;
1404
1405 const { manager } = yield* makeManager({
1406 textGeneration: {
1407 generateCommitMessage: (input) =>
1408 Effect.sync(() => {
1409 generatedCount += 1;
1410 return {
1411 subject: "Implement stacked git actions",
1412 body: "",
1413 ...(input.includeBranch ? { branch: "feature/implement-stacked-git-actions" } : {}),
1414 };
1415 }),
1416 },
1417 });
1418 const result = yield* runStackedAction(manager, {
1419 cwd: repoDir,
1420 action: "commit_push",
1421 featureBranch: true,
1422 });
1423
1424 expect(result.branch.status).toBe("created");
1425 expect(result.branch.name).toBe("feature/implement-stacked-git-actions");
1426 expect(result.commit.status).toBe("created");
1427 expect(result.push.status).toBe("pushed");
1428 expect(result.toast).toMatchObject({
1429 description: "Implement stacked git actions",
1430 cta: {
1431 kind: "run_action",
1432 label: "Create PR",
1433 action: {
1434 kind: "create_pr",
1435 },
1436 },
1437 });
1438 expect(result.toast.title).toMatch(
1439 /^Pushed [0-9a-f]{7} to origin\/feature\/implement-stacked-git-actions$/,
1440 );
1441 expect(
1442 yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "HEAD"]).pipe(
1443 Effect.map((result) => result.stdout.trim()),
1444 ),
1445 ).toBe("feature/implement-stacked-git-actions");
1446
1447 const mainSha = yield* runGit(repoDir, ["rev-parse", "main"]).pipe(
1448 Effect.map((r) => r.stdout.trim()),
1449 );
1450 const mergeBase = yield* runGit(repoDir, ["merge-base", "main", "HEAD"]).pipe(
1451 Effect.map((r) => r.stdout.trim()),
1452 );
1453 expect(mergeBase).toBe(mainSha);
1454 expect(generatedCount).toBe(1);
1455 }),
1456 );
1457
1458 it.effect("featureBranch uses custom commit message and derives branch name", () =>
1459 Effect.gen(function* () {
1460 const repoDir = yield* makeTempDir("t3code-git-manager-");
1461 yield* initRepo(repoDir);
1462 fs.writeFileSync(path.join(repoDir, "README.md"), "hello\ncustom-feature\n");
1463 let generatedCount = 0;
1464
1465 const { manager } = yield* makeManager({
1466 textGeneration: {
1467 generateCommitMessage: (input) =>
1468 Effect.sync(() => {
1469 generatedCount += 1;
1470 return {
1471 subject: "unused",
1472 body: "",
1473 ...(input.includeBranch ? { branch: "feature/unused" } : {}),
1474 };
1475 }),
1476 },
1477 });
1478 const result = yield* runStackedAction(manager, {
1479 cwd: repoDir,
1480 action: "commit",
1481 featureBranch: true,
1482 commitMessage: "feat: custom summary line\n\n- details from user",
1483 });
1484
1485 expect(result.branch.status).toBe("created");
1486 expect(result.branch.name).toBe("feature/feat-custom-summary-line");
1487 expect(result.commit.status).toBe("created");
1488 expect(result.commit.subject).toBe("feat: custom summary line");
1489 expect(generatedCount).toBe(0);
1490
1491 const mainSha = yield* runGit(repoDir, ["rev-parse", "main"]).pipe(
1492 Effect.map((r) => r.stdout.trim()),
1493 );
1494 const mergeBase = yield* runGit(repoDir, ["merge-base", "main", result.branch.name!]).pipe(
1495 Effect.map((r) => r.stdout.trim()),
1496 );
1497 expect(mergeBase).toBe(mainSha);
1498 }),
1499 );
1500
1501 it.effect("skips commit when there are no uncommitted changes", () =>
1502 Effect.gen(function* () {
1503 const repoDir = yield* makeTempDir("t3code-git-manager-");
1504 yield* initRepo(repoDir);
1505
1506 const { manager } = yield* makeManager();
1507 const result = yield* runStackedAction(manager, {
1508 cwd: repoDir,
1509 action: "commit",
1510 });
1511
1512 expect(result.branch.status).toBe("skipped_not_requested");
1513 expect(result.commit.status).toBe("skipped_no_changes");
1514 expect(result.push.status).toBe("skipped_not_requested");
1515 expect(result.pr.status).toBe("skipped_not_requested");
1516 }),
1517 );
1518
1519 it.effect("featureBranch returns error when worktree is clean", () =>
1520 Effect.gen(function* () {
1521 const repoDir = yield* makeTempDir("t3code-git-manager-");
1522 yield* initRepo(repoDir);
1523
1524 const { manager } = yield* makeManager();
1525 const errorMessage = yield* runStackedAction(manager, {
1526 cwd: repoDir,
1527 action: "commit",
1528 featureBranch: true,
1529 }).pipe(
1530 Effect.flip,
1531 Effect.map((error) => error.message),
1532 );
1533
1534 expect(errorMessage).toContain("no changes to commit");
1535 }),
1536 );
1537
1538 it.effect("commits and pushes with upstream auto-setup when needed", () =>
1539 Effect.gen(function* () {
1540 const repoDir = yield* makeTempDir("t3code-git-manager-");
1541 yield* initRepo(repoDir);
1542 yield* runGit(repoDir, ["checkout", "-b", "feature/stacked-flow"]);
1543 const remoteDir = yield* createBareRemote();
1544 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
1545 fs.writeFileSync(path.join(repoDir, "feature.txt"), "feature\n");
1546
1547 const { manager } = yield* makeManager();
1548 const result = yield* runStackedAction(manager, {
1549 cwd: repoDir,
1550 action: "commit_push",
1551 });
1552
1553 expect(result.branch.status).toBe("skipped_not_requested");
1554 expect(result.commit.status).toBe("created");
1555 expect(result.push.status).toBe("pushed");
1556 expect(result.push.setUpstream).toBe(true);
1557 expect(
1558 yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"]).pipe(
1559 Effect.map((result) => result.stdout.trim()),
1560 ),
1561 ).toBe("origin/feature/stacked-flow");
1562 }),
1563 );
1564
1565 it.effect(
1566 "pushes and creates PR from a no-upstream branch when local commits are ahead of base",
1567 () =>
1568 Effect.gen(function* () {
1569 const repoDir = yield* makeTempDir("t3code-git-manager-");
1570 yield* initRepo(repoDir);
1571 yield* runGit(repoDir, ["checkout", "-b", "feature/no-upstream-pr"]);
1572 const remoteDir = yield* createBareRemote();
1573 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
1574 fs.writeFileSync(path.join(repoDir, "feature.txt"), "feature\n");
1575
1576 const { manager, ghCalls } = yield* makeManager({
1577 ghScenario: {
1578 prListSequence: [
1579 "[]",
1580 JSON.stringify([
1581 {
1582 number: 77,
1583 title: "Add no-upstream PR flow",
1584 url: "https://github.com/pingdotgg/codething-mvp/pull/77",
1585 baseRefName: "main",
1586 headRefName: "feature/no-upstream-pr",
1587 },
1588 ]),
1589 ],
1590 },
1591 });
1592
1593 const result = yield* runStackedAction(manager, {
1594 cwd: repoDir,
1595 action: "commit_push_pr",
1596 });
1597
1598 expect(result.branch.status).toBe("skipped_not_requested");
1599 expect(result.commit.status).toBe("created");
1600 expect(result.push.status).toBe("pushed");
1601 expect(result.push.setUpstream).toBe(true);
1602 expect(result.pr.status).toBe("created");
1603 expect(
1604 yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"]).pipe(
1605 Effect.map((result) => result.stdout.trim()),
1606 ),
1607 ).toBe("origin/feature/no-upstream-pr");
1608 expect(
1609 ghCalls.some((call) =>
1610 call.includes("pr create --base main --head feature/no-upstream-pr"),
1611 ),
1612 ).toBe(true);
1613 }),
1614 );
1615
1616 it.effect("skips push when branch is already up to date", () =>
1617 Effect.gen(function* () {
1618 const repoDir = yield* makeTempDir("t3code-git-manager-");
1619 yield* initRepo(repoDir);
1620 yield* runGit(repoDir, ["checkout", "-b", "feature/up-to-date"]);
1621 const remoteDir = yield* createBareRemote();
1622 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
1623 yield* runGit(repoDir, ["push", "-u", "origin", "feature/up-to-date"]);
1624
1625 const { manager } = yield* makeManager();
1626 const result = yield* runStackedAction(manager, {
1627 cwd: repoDir,
1628 action: "commit_push",
1629 });
1630
1631 expect(result.branch.status).toBe("skipped_not_requested");
1632 expect(result.commit.status).toBe("skipped_no_changes");
1633 expect(result.push.status).toBe("skipped_up_to_date");
1634 }),
1635 );
1636
1637 it.effect("pushes existing clean commits without rerunning commit logic", () =>
1638 Effect.gen(function* () {
1639 const repoDir = yield* makeTempDir("t3code-git-manager-");
1640 yield* initRepo(repoDir);
1641 yield* runGit(repoDir, ["checkout", "-b", "feature/push-only"]);
1642 const remoteDir = yield* createBareRemote();
1643 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
1644 fs.writeFileSync(path.join(repoDir, "push-only.txt"), "push only\n");
1645 yield* runGit(repoDir, ["add", "push-only.txt"]);
1646 yield* runGit(repoDir, ["commit", "-m", "Push only branch"]);
1647
1648 const { manager } = yield* makeManager();
1649 const result = yield* runStackedAction(manager, {
1650 cwd: repoDir,
1651 action: "push",
1652 });
1653
1654 expect(result.commit.status).toBe("skipped_not_requested");
1655 expect(result.push.status).toBe("pushed");
1656 expect(result.pr.status).toBe("skipped_not_requested");
1657 expect(
1658 yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"]).pipe(
1659 Effect.map((output) => output.stdout.trim()),
1660 ),
1661 ).toBe("origin/feature/push-only");
1662 }),
1663 );
1664
1665 it.effect("create_pr pushes a clean branch before creating the PR when needed", () =>
1666 Effect.gen(function* () {
1667 const repoDir = yield* makeTempDir("t3code-git-manager-");
1668 yield* initRepo(repoDir);
1669 yield* runGit(repoDir, ["checkout", "-b", "feature/create-pr-only"]);
1670 const remoteDir = yield* createBareRemote();
1671 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
1672 fs.writeFileSync(path.join(repoDir, "create-pr-only.txt"), "create pr\n");
1673 yield* runGit(repoDir, ["add", "create-pr-only.txt"]);
1674 yield* runGit(repoDir, ["commit", "-m", "Create PR only branch"]);
1675
1676 const { manager, ghCalls } = yield* makeManager({
1677 ghScenario: {
1678 prListSequence: [
1679 "[]",
1680 JSON.stringify([
1681 {
1682 number: 303,
1683 title: "Create PR only branch",
1684 url: "https://github.com/pingdotgg/codething-mvp/pull/303",
1685 baseRefName: "main",
1686 headRefName: "feature/create-pr-only",
1687 },
1688 ]),
1689 ],
1690 },
1691 });
1692
1693 const result = yield* runStackedAction(manager, {
1694 cwd: repoDir,
1695 action: "create_pr",
1696 });
1697
1698 expect(result.commit.status).toBe("skipped_not_requested");
1699 expect(result.push.status).toBe("pushed");
1700 expect(result.push.setUpstream).toBe(true);
1701 expect(result.pr.status).toBe("created");
1702 expect(result.pr.number).toBe(303);
1703 expect(
1704 ghCalls.some((call) =>
1705 call.includes("pr create --base main --head feature/create-pr-only"),
1706 ),
1707 ).toBe(true);
1708 }),
1709 );
1710
1711 it.effect("returns existing PR metadata for commit/push/pr action", () =>
1712 Effect.gen(function* () {
1713 const repoDir = yield* makeTempDir("t3code-git-manager-");
1714 yield* initRepo(repoDir);
1715 yield* runGit(repoDir, ["checkout", "-b", "feature/existing-pr"]);
1716 const remoteDir = yield* createBareRemote();
1717 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
1718 yield* runGit(repoDir, ["push", "-u", "origin", "feature/existing-pr"]);
1719
1720 const { manager, ghCalls } = yield* makeManager({
1721 ghScenario: {
1722 prListSequence: [
1723 JSON.stringify([
1724 {
1725 number: 42,
1726 title: "Existing PR",
1727 url: "https://github.com/pingdotgg/codething-mvp/pull/42",
1728 baseRefName: "main",
1729 headRefName: "feature/existing-pr",
1730 },
1731 ]),
1732 ],
1733 },
1734 });
1735 const result = yield* runStackedAction(manager, {
1736 cwd: repoDir,
1737 action: "commit_push_pr",
1738 });
1739
1740 expect(result.branch.status).toBe("skipped_not_requested");
1741 expect(result.pr.status).toBe("opened_existing");
1742 expect(result.pr.number).toBe(42);
1743 expect(result.toast).toEqual({
1744 title: "Opened PR #42",
1745 description: "Existing PR",
1746 cta: {
1747 kind: "open_pr",
1748 label: "View PR",
1749 url: "https://github.com/pingdotgg/codething-mvp/pull/42",
1750 },
1751 });
1752 expect(ghCalls.some((call) => call.startsWith("pr view "))).toBe(false);
1753 }),
1754 );
1755
1756 it.effect(
1757 "returns existing cross-repo PR metadata using the fork owner selector",
1758 () =>
1759 Effect.gen(function* () {
1760 const repoDir = yield* makeTempDir("t3code-git-manager-");
1761 yield* initRepo(repoDir);
1762 yield* runGit(repoDir, ["checkout", "-b", "statemachine"]);
1763 const forkDir = yield* createBareRemote();
1764 yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]);
1765 yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]);
1766 yield* runGit(repoDir, [
1767 "config",
1768 "remote.fork-seed.url",
1769 "git@github.com:octocat/codething-mvp.git",
1770 ]);
1771
1772 const { manager, ghCalls } = yield* makeManager({
1773 ghScenario: {
1774 prListSequence: [
1775 JSON.stringify([]),
1776 JSON.stringify([
1777 {
1778 number: 142,
1779 title: "Existing fork PR",
1780 url: "https://github.com/pingdotgg/codething-mvp/pull/142",
1781 baseRefName: "main",
1782 headRefName: "statemachine",
1783 state: "OPEN",
1784 isCrossRepository: true,
1785 headRepository: {
1786 nameWithOwner: "octocat/codething-mvp",
1787 },
1788 headRepositoryOwner: {
1789 login: "octocat",
1790 },
1791 },
1792 ]),
1793 ],
1794 },
1795 });
1796
1797 const result = yield* runStackedAction(manager, {
1798 cwd: repoDir,
1799 action: "commit_push_pr",
1800 });
1801
1802 expect(result.pr.status).toBe("opened_existing");
1803 expect(result.pr.number).toBe(142);
1804 expect(
1805 ghCalls.some((call) =>
1806 call.includes("pr list --head octocat:statemachine --state open --limit 1"),
1807 ),
1808 ).toBe(true);
1809 expect(ghCalls.some((call) => call.startsWith("pr create "))).toBe(false);
1810 }),
1811 12_000,
1812 );
1813
1814 it.effect(
1815 "returns the correct existing PR when a slash remote checks out to a synthetic local alias",
1816 () =>
1817 Effect.gen(function* () {
1818 const repoDir = yield* makeTempDir("t3code-git-manager-");
1819 yield* initRepo(repoDir);
1820 const originDir = yield* createBareRemote();
1821 const upstreamDir = yield* createBareRemote();
1822 yield* configureRemote(repoDir, "origin", originDir, "origin");
1823 yield* configureRemote(repoDir, "my-org/upstream", upstreamDir, "my-org/upstream");
1824
1825 yield* runGit(repoDir, ["checkout", "-b", "effect-atom"]);
1826 yield* runGit(repoDir, ["push", "-u", "origin", "effect-atom"]);
1827 yield* runGit(repoDir, ["push", "-u", "my-org/upstream", "effect-atom"]);
1828 yield* runGit(repoDir, [
1829 "config",
1830 "remote.origin.url",
1831 "git@github.com:pingdotgg/codething-mvp.git",
1832 ]);
1833 yield* runGit(repoDir, ["config", "remote.origin.pushurl", originDir]);
1834 yield* runGit(repoDir, [
1835 "config",
1836 "remote.my-org/upstream.url",
1837 "git@github.com:pingdotgg/codething-mvp.git",
1838 ]);
1839 yield* runGit(repoDir, ["config", "remote.my-org/upstream.pushurl", upstreamDir]);
1840 yield* runGit(repoDir, ["checkout", "main"]);
1841 yield* runGit(repoDir, ["branch", "-D", "effect-atom"]);
1842 yield* runGit(repoDir, ["checkout", "--track", "my-org/upstream/effect-atom"]);
1843 fs.writeFileSync(path.join(repoDir, "changes.txt"), "change\n");
1844 yield* runGit(repoDir, ["add", "changes.txt"]);
1845 yield* runGit(repoDir, ["commit", "-m", "Feature commit"]);
1846
1847 const { manager, ghCalls } = yield* makeManager({
1848 ghScenario: {
1849 prListByHeadSelector: {
1850 "effect-atom": JSON.stringify([
1851 {
1852 number: 1618,
1853 title: "Correct PR",
1854 url: "https://github.com/pingdotgg/t3code/pull/1618",
1855 baseRefName: "main",
1856 headRefName: "effect-atom",
1857 },
1858 ]),
1859 "upstream/effect-atom": JSON.stringify([
1860 {
1861 number: 1518,
1862 title: "Wrong PR",
1863 url: "https://github.com/pingdotgg/t3code/pull/1518",
1864 baseRefName: "main",
1865 headRefName: "upstream/effect-atom",
1866 },
1867 ]),
1868 "pingdotgg:effect-atom": JSON.stringify([]),
1869 "my-org/upstream:effect-atom": JSON.stringify([]),
1870 "pingdotgg:upstream/effect-atom": JSON.stringify([
1871 {
1872 number: 1518,
1873 title: "Wrong PR",
1874 url: "https://github.com/pingdotgg/t3code/pull/1518",
1875 baseRefName: "main",
1876 headRefName: "upstream/effect-atom",
1877 },
1878 ]),
1879 "my-org/upstream:upstream/effect-atom": JSON.stringify([
1880 {
1881 number: 1518,
1882 title: "Wrong PR",
1883 url: "https://github.com/pingdotgg/t3code/pull/1518",
1884 baseRefName: "main",
1885 headRefName: "upstream/effect-atom",
1886 },
1887 ]),
1888 },
1889 },
1890 });
1891
1892 const result = yield* runStackedAction(manager, {
1893 cwd: repoDir,
1894 action: "commit_push_pr",
1895 });
1896
1897 expect(result.pr.status).toBe("opened_existing");
1898 expect(result.pr.number).toBe(1618);
1899 expect(ghCalls.some((call) => call.includes("pr list --head upstream/effect-atom "))).toBe(
1900 false,
1901 );
1902 }),
1903 20_000,
1904 );
1905
1906 it.effect(
1907 "prefers owner-qualified selectors before bare branch names for cross-repo PRs",
1908 () =>
1909 Effect.gen(function* () {
1910 const repoDir = yield* makeTempDir("t3code-git-manager-");
1911 yield* initRepo(repoDir);
1912 yield* runGit(repoDir, ["checkout", "-b", "statemachine"]);
1913 const forkDir = yield* createBareRemote();
1914 yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]);
1915 yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]);
1916 yield* runGit(repoDir, ["checkout", "-b", "t3code/pr-142/statemachine"]);
1917 yield* runGit(repoDir, ["branch", "--set-upstream-to", "fork-seed/statemachine"]);
1918 yield* runGit(repoDir, [
1919 "config",
1920 "remote.fork-seed.url",
1921 "git@github.com:octocat/codething-mvp.git",
1922 ]);
1923
1924 const { manager, ghCalls } = yield* makeManager({
1925 ghScenario: {
1926 prListByHeadSelector: {
1927 "t3code/pr-142/statemachine": JSON.stringify([]),
1928 statemachine: JSON.stringify([
1929 {
1930 number: 41,
1931 title: "Unrelated same-repo PR",
1932 url: "https://github.com/pingdotgg/codething-mvp/pull/41",
1933 baseRefName: "main",
1934 headRefName: "statemachine",
1935 },
1936 ]),
1937 "octocat:statemachine": JSON.stringify([
1938 {
1939 number: 142,
1940 title: "Existing fork PR",
1941 url: "https://github.com/pingdotgg/codething-mvp/pull/142",
1942 baseRefName: "main",
1943 headRefName: "statemachine",
1944 state: "OPEN",
1945 isCrossRepository: true,
1946 headRepository: {
1947 nameWithOwner: "octocat/codething-mvp",
1948 },
1949 headRepositoryOwner: {
1950 login: "octocat",
1951 },
1952 },
1953 ]),
1954 "fork-seed:statemachine": JSON.stringify([]),
1955 },
1956 },
1957 });
1958
1959 const result = yield* runStackedAction(manager, {
1960 cwd: repoDir,
1961 action: "commit_push_pr",
1962 });
1963
1964 expect(result.pr.status).toBe("opened_existing");
1965 expect(result.pr.number).toBe(142);
1966
1967 const ownerSelectorCallIndex = ghCalls.findIndex((call) =>
1968 call.includes("pr list --head octocat:statemachine --state open --limit 1"),
1969 );
1970 expect(ownerSelectorCallIndex).toBeGreaterThanOrEqual(0);
1971 expect(ghCalls.some((call) => call.startsWith("pr create "))).toBe(false);
1972 }),
1973 12_000,
1974 );
1975
1976 it.effect(
1977 "stops probing head selectors after finding an existing PR",
1978 () =>
1979 Effect.gen(function* () {
1980 const repoDir = yield* makeTempDir("t3code-git-manager-");
1981 yield* initRepo(repoDir);
1982 yield* runGit(repoDir, ["checkout", "-b", "statemachine"]);
1983 const forkDir = yield* createBareRemote();
1984 yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]);
1985 yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]);
1986 yield* runGit(repoDir, ["checkout", "-b", "t3code/pr-142/statemachine"]);
1987 yield* runGit(repoDir, ["branch", "--set-upstream-to", "fork-seed/statemachine"]);
1988 yield* runGit(repoDir, [
1989 "config",
1990 "remote.fork-seed.url",
1991 "git@github.com:octocat/codething-mvp.git",
1992 ]);
1993
1994 const { manager, ghCalls } = yield* makeManager({
1995 ghScenario: {
1996 prListByHeadSelector: {
1997 "octocat:statemachine": JSON.stringify([
1998 {
1999 number: 142,
2000 title: "Existing fork PR",
2001 url: "https://github.com/pingdotgg/codething-mvp/pull/142",
2002 baseRefName: "main",
2003 headRefName: "statemachine",
2004 state: "OPEN",
2005 isCrossRepository: true,
2006 headRepository: {
2007 nameWithOwner: "octocat/codething-mvp",
2008 },
2009 headRepositoryOwner: {
2010 login: "octocat",
2011 },
2012 },
2013 ]),
2014 "fork-seed:statemachine": JSON.stringify([]),
2015 "t3code/pr-142/statemachine": JSON.stringify([]),
2016 statemachine: JSON.stringify([]),
2017 },
2018 },
2019 });
2020
2021 const result = yield* runStackedAction(manager, {
2022 cwd: repoDir,
2023 action: "commit_push_pr",
2024 });
2025
2026 expect(result.pr.status).toBe("opened_existing");
2027 expect(result.pr.number).toBe(142);
2028
2029 const openLookupCalls = ghCalls.filter((call) => call.includes("--state open --limit 1"));
2030 expect(openLookupCalls).toHaveLength(1);
2031 expect(openLookupCalls[0]).toContain(
2032 "pr list --head octocat:statemachine --state open --limit 1",
2033 );
2034 }),
2035 12_000,
2036 );
2037
2038 it.effect("creates PR when one does not already exist", () =>
2039 Effect.gen(function* () {
2040 const repoDir = yield* makeTempDir("t3code-git-manager-");
2041 yield* initRepo(repoDir);
2042 yield* runGit(repoDir, ["checkout", "-b", "feature-create-pr"]);
2043 const remoteDir = yield* createBareRemote();
2044 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
2045 fs.writeFileSync(path.join(repoDir, "changes.txt"), "change\n");
2046 yield* runGit(repoDir, ["add", "changes.txt"]);
2047 yield* runGit(repoDir, ["commit", "-m", "Feature commit"]);
2048 yield* runGit(repoDir, ["push", "-u", "origin", "feature-create-pr"]);
2049 yield* runGit(repoDir, ["config", "branch.feature-create-pr.gh-merge-base", "main"]);
2050
2051 const { manager, ghCalls } = yield* makeManager({
2052 ghScenario: {
2053 prListSequence: [
2054 "[]",
2055 JSON.stringify([
2056 {
2057 number: 88,
2058 title: "Add stacked git actions",
2059 url: "https://github.com/pingdotgg/codething-mvp/pull/88",
2060 baseRefName: "main",
2061 headRefName: "feature-create-pr",
2062 },
2063 ]),
2064 ],
2065 },
2066 });
2067 const result = yield* runStackedAction(manager, {
2068 cwd: repoDir,
2069 action: "commit_push_pr",
2070 });
2071
2072 expect(result.branch.status).toBe("skipped_not_requested");
2073 expect(result.pr.status).toBe("created");
2074 expect(result.pr.number).toBe(88);
2075 expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(2);
2076 expect(
2077 ghCalls.some((call) => call.includes("pr create --base main --head feature-create-pr")),
2078 ).toBe(true);
2079 expect(ghCalls.some((call) => call.startsWith("pr view "))).toBe(false);
2080 }),
2081 );
2082
2083 it.effect(
2084 "creates a new PR instead of reusing an unrelated fork PR with the same head branch",
2085 () =>
2086 Effect.gen(function* () {
2087 const repoDir = yield* makeTempDir("t3code-git-manager-");
2088 yield* initRepo(repoDir);
2089 yield* runGit(repoDir, ["checkout", "-b", "feature/no-fork-match"]);
2090 const remoteDir = yield* createBareRemote();
2091 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
2092 fs.writeFileSync(path.join(repoDir, "changes.txt"), "change\n");
2093 yield* runGit(repoDir, ["add", "changes.txt"]);
2094 yield* runGit(repoDir, ["commit", "-m", "Feature commit"]);
2095 yield* runGit(repoDir, ["push", "-u", "origin", "feature/no-fork-match"]);
2096
2097 const { manager, ghCalls } = yield* makeManager({
2098 ghScenario: {
2099 prListSequence: [
2100 JSON.stringify([
2101 {
2102 number: 1661,
2103 title: "Fork PR with same branch name",
2104 url: "https://github.com/pingdotgg/t3code/pull/1661",
2105 baseRefName: "main",
2106 headRefName: "feature/no-fork-match",
2107 state: "OPEN",
2108 isCrossRepository: true,
2109 headRepository: {
2110 nameWithOwner: "lnieuwenhuis/t3code",
2111 },
2112 headRepositoryOwner: {
2113 login: "lnieuwenhuis",
2114 },
2115 },
2116 ]),
2117 JSON.stringify([
2118 {
2119 number: 188,
2120 title: "Add stacked git actions",
2121 url: "https://github.com/pingdotgg/codething-mvp/pull/188",
2122 baseRefName: "main",
2123 headRefName: "feature/no-fork-match",
2124 state: "OPEN",
2125 isCrossRepository: false,
2126 },
2127 ]),
2128 ],
2129 },
2130 });
2131 const result = yield* runStackedAction(manager, {
2132 cwd: repoDir,
2133 action: "commit_push_pr",
2134 });
2135
2136 expect(result.pr.status).toBe("created");
2137 expect(result.pr.number).toBe(188);
2138 expect(result.toast).toEqual({
2139 title: "Created PR #188",
2140 description: "Add stacked git actions",
2141 cta: {
2142 kind: "open_pr",
2143 label: "View PR",
2144 url: "https://github.com/pingdotgg/codething-mvp/pull/188",
2145 },
2146 });
2147 expect(
2148 ghCalls.some((call) =>
2149 call.includes("pr create --base main --head feature/no-fork-match"),
2150 ),
2151 ).toBe(true);
2152 }),
2153 );
2154
2155 it.effect("creates cross-repo PRs with the fork owner selector and default base branch", () =>
2156 Effect.gen(function* () {
2157 const repoDir = yield* makeTempDir("t3code-git-manager-");
2158 yield* initRepo(repoDir);
2159 const forkDir = yield* createBareRemote();
2160 yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]);
2161 yield* runGit(repoDir, ["checkout", "-b", "statemachine"]);
2162 fs.writeFileSync(path.join(repoDir, "changes.txt"), "change\n");
2163 yield* runGit(repoDir, ["add", "changes.txt"]);
2164 yield* runGit(repoDir, ["commit", "-m", "Feature commit"]);
2165 yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]);
2166 yield* runGit(repoDir, ["checkout", "-b", "t3code/pr-91/statemachine"]);
2167 yield* runGit(repoDir, ["branch", "--set-upstream-to", "fork-seed/statemachine"]);
2168 yield* runGit(repoDir, [
2169 "config",
2170 "remote.fork-seed.url",
2171 "git@github.com:octocat/codething-mvp.git",
2172 ]);
2173
2174 const { manager, ghCalls } = yield* makeManager({
2175 ghScenario: {
2176 prListSequenceByHeadSelector: {
2177 "octocat:statemachine": [
2178 JSON.stringify([]),
2179 JSON.stringify([
2180 {
2181 number: 188,
2182 title: "Add stacked git actions",
2183 url: "https://github.com/pingdotgg/codething-mvp/pull/188",
2184 baseRefName: "main",
2185 headRefName: "statemachine",
2186 state: "OPEN",
2187 isCrossRepository: true,
2188 headRepository: {
2189 nameWithOwner: "octocat/codething-mvp",
2190 },
2191 headRepositoryOwner: {
2192 login: "octocat",
2193 },
2194 },
2195 ]),
2196 ],
2197 "fork-seed:statemachine": [JSON.stringify([])],
2198 statemachine: [JSON.stringify([])],
2199 },
2200 },
2201 });
2202
2203 const result = yield* runStackedAction(manager, {
2204 cwd: repoDir,
2205 action: "commit_push_pr",
2206 });
2207
2208 expect(result.pr.status).toBe("created");
2209 expect(result.pr.number).toBe(188);
2210 expect(
2211 ghCalls.some((call) => call.includes("pr create --base main --head octocat:statemachine")),
2212 ).toBe(true);
2213 expect(
2214 ghCalls.some((call) =>
2215 call.includes("pr create --base statemachine --head octocat:statemachine"),
2216 ),
2217 ).toBe(false);
2218 }),
2219 );
2220
2221 it.effect("rejects push/pr actions from detached HEAD", () =>
2222 Effect.gen(function* () {
2223 const repoDir = yield* makeTempDir("t3code-git-manager-");
2224 yield* initRepo(repoDir);
2225 yield* runGit(repoDir, ["checkout", "--detach", "HEAD"]);
2226
2227 const { manager } = yield* makeManager();
2228 const errorMessage = yield* runStackedAction(manager, {
2229 cwd: repoDir,
2230 action: "commit_push",
2231 }).pipe(
2232 Effect.flip,
2233 Effect.map((error) => error.message),
2234 );
2235 expect(errorMessage).toContain("detached HEAD");
2236 }),
2237 );
2238
2239 it.effect("surfaces missing gh binary errors", () =>
2240 Effect.gen(function* () {
2241 const repoDir = yield* makeTempDir("t3code-git-manager-");
2242 yield* initRepo(repoDir);
2243 yield* runGit(repoDir, ["checkout", "-b", "feature/gh-missing"]);
2244 const remoteDir = yield* createBareRemote();
2245 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
2246 yield* runGit(repoDir, ["push", "-u", "origin", "feature/gh-missing"]);
2247
2248 const { manager } = yield* makeManager({
2249 ghScenario: {
2250 failWith: new GitHubCliError({
2251 operation: "execute",
2252 detail: "GitHub CLI (`gh`) is required but not available on PATH.",
2253 }),
2254 },
2255 });
2256
2257 const errorMessage = yield* runStackedAction(manager, {
2258 cwd: repoDir,
2259 action: "commit_push_pr",
2260 }).pipe(
2261 Effect.flip,
2262 Effect.map((error) => error.message),
2263 );
2264 expect(errorMessage).toContain("GitHub CLI (`gh`) is required");
2265 }),
2266 );
2267
2268 it.effect("surfaces gh auth errors with guidance", () =>
2269 Effect.gen(function* () {
2270 const repoDir = yield* makeTempDir("t3code-git-manager-");
2271 yield* initRepo(repoDir);
2272 yield* runGit(repoDir, ["checkout", "-b", "feature/gh-auth"]);
2273 const remoteDir = yield* createBareRemote();
2274 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
2275 yield* runGit(repoDir, ["push", "-u", "origin", "feature/gh-auth"]);
2276
2277 const { manager } = yield* makeManager({
2278 ghScenario: {
2279 failWith: new GitHubCliError({
2280 operation: "execute",
2281 detail: "GitHub CLI is not authenticated. Run `gh auth login` and retry.",
2282 }),
2283 },
2284 });
2285
2286 const errorMessage = yield* runStackedAction(manager, {
2287 cwd: repoDir,
2288 action: "commit_push_pr",
2289 }).pipe(
2290 Effect.flip,
2291 Effect.map((error) => error.message),
2292 );
2293 expect(errorMessage).toContain("gh auth login");
2294 }),
2295 );
2296
2297 it.effect("resolves pull requests from #number references", () =>
2298 Effect.gen(function* () {
2299 const repoDir = yield* makeTempDir("t3code-git-manager-");
2300 yield* initRepo(repoDir);
2301
2302 const { manager, ghCalls } = yield* makeManager({
2303 ghScenario: {
2304 pullRequest: {
2305 number: 42,
2306 title: "Resolve PR",
2307 url: "https://github.com/pingdotgg/codething-mvp/pull/42",
2308 baseRefName: "main",
2309 headRefName: "feature/resolve-pr",
2310 state: "open",
2311 },
2312 },
2313 });
2314
2315 const result = yield* resolvePullRequest(manager, {
2316 cwd: repoDir,
2317 reference: "#42",
2318 });
2319
2320 expect(result.pullRequest).toEqual({
2321 number: 42,
2322 title: "Resolve PR",
2323 url: "https://github.com/pingdotgg/codething-mvp/pull/42",
2324 baseBranch: "main",
2325 headBranch: "feature/resolve-pr",
2326 state: "open",
2327 });
2328 expect(ghCalls.some((call) => call.startsWith("pr view 42 "))).toBe(true);
2329 }),
2330 );
2331
2332 it.effect("prepares pull request threads in local mode by checking out the PR branch", () =>
2333 Effect.gen(function* () {
2334 const repoDir = yield* makeTempDir("t3code-git-manager-");
2335 yield* initRepo(repoDir);
2336 yield* runGit(repoDir, ["checkout", "-b", "feature/pr-local"]);
2337 fs.writeFileSync(path.join(repoDir, "local.txt"), "local\n");
2338 yield* runGit(repoDir, ["add", "local.txt"]);
2339 yield* runGit(repoDir, ["commit", "-m", "Local PR branch"]);
2340
2341 const { manager, ghCalls } = yield* makeManager({
2342 ghScenario: {
2343 pullRequest: {
2344 number: 64,
2345 title: "Local PR",
2346 url: "https://github.com/pingdotgg/codething-mvp/pull/64",
2347 baseRefName: "main",
2348 headRefName: "feature/pr-local",
2349 state: "open",
2350 },
2351 },
2352 });
2353
2354 const result = yield* preparePullRequestThread(manager, {
2355 cwd: repoDir,
2356 reference: "#64",
2357 mode: "local",
2358 });
2359
2360 expect(result.branch).toBe("feature/pr-local");
2361 expect(result.worktreePath).toBeNull();
2362 const branch = (yield* runGit(repoDir, ["branch", "--show-current"])).stdout.trim();
2363 expect(branch).toBe("feature/pr-local");
2364 expect(ghCalls).toContain("pr checkout 64 --force");
2365 }),
2366 );
2367
2368 it.effect("prepares pull request threads in worktree mode on the PR head branch", () =>
2369 Effect.gen(function* () {
2370 const repoDir = yield* makeTempDir("t3code-git-manager-");
2371 yield* initRepo(repoDir);
2372 const remoteDir = yield* createBareRemote();
2373 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
2374 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
2375 yield* runGit(repoDir, ["checkout", "-b", "feature/pr-worktree"]);
2376 fs.writeFileSync(path.join(repoDir, "worktree.txt"), "worktree\n");
2377 yield* runGit(repoDir, ["add", "worktree.txt"]);
2378 yield* runGit(repoDir, ["commit", "-m", "PR worktree branch"]);
2379 yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-worktree"]);
2380 yield* runGit(repoDir, ["push", "origin", "HEAD:refs/pull/77/head"]);
2381 yield* runGit(repoDir, ["checkout", "main"]);
2382
2383 const { manager } = yield* makeManager({
2384 ghScenario: {
2385 pullRequest: {
2386 number: 77,
2387 title: "Worktree PR",
2388 url: "https://github.com/pingdotgg/codething-mvp/pull/77",
2389 baseRefName: "main",
2390 headRefName: "feature/pr-worktree",
2391 state: "open",
2392 },
2393 },
2394 });
2395
2396 const result = yield* preparePullRequestThread(manager, {
2397 cwd: repoDir,
2398 reference: "77",
2399 mode: "worktree",
2400 });
2401
2402 expect(result.branch).toBe("feature/pr-worktree");
2403 expect(result.worktreePath).not.toBeNull();
2404 expect(fs.existsSync(result.worktreePath as string)).toBe(true);
2405 const worktreeBranch = (yield* runGit(result.worktreePath as string, [
2406 "branch",
2407 "--show-current",
2408 ])).stdout.trim();
2409 expect(worktreeBranch).toBe("feature/pr-worktree");
2410 }),
2411 );
2412
2413 it.effect("launches setup only when creating a new PR worktree", () =>
2414 Effect.gen(function* () {
2415 const repoDir = yield* makeTempDir("t3code-git-manager-");
2416 yield* initRepo(repoDir);
2417 const remoteDir = yield* createBareRemote();
2418 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
2419 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
2420 yield* runGit(repoDir, ["checkout", "-b", "feature/pr-worktree-setup"]);
2421 fs.writeFileSync(path.join(repoDir, "setup.txt"), "setup\n");
2422 yield* runGit(repoDir, ["add", "setup.txt"]);
2423 yield* runGit(repoDir, ["commit", "-m", "PR worktree setup branch"]);
2424 yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-worktree-setup"]);
2425 yield* runGit(repoDir, ["push", "origin", "HEAD:refs/pull/177/head"]);
2426 yield* runGit(repoDir, ["checkout", "main"]);
2427
2428 const setupCalls: ProjectSetupScriptRunnerInput[] = [];
2429 const { manager } = yield* makeManager({
2430 ghScenario: {
2431 pullRequest: {
2432 number: 177,
2433 title: "Worktree setup PR",
2434 url: "https://github.com/pingdotgg/codething-mvp/pull/177",
2435 baseRefName: "main",
2436 headRefName: "feature/pr-worktree-setup",
2437 state: "open",
2438 },
2439 },
2440 setupScriptRunner: {
2441 runForThread: (setupInput) =>
2442 Effect.sync(() => {
2443 setupCalls.push(setupInput);
2444 return { status: "no-script" as const };
2445 }),
2446 },
2447 });
2448
2449 const result = yield* preparePullRequestThread(manager, {
2450 cwd: repoDir,
2451 reference: "177",
2452 mode: "worktree",
2453 threadId: asThreadId("thread-pr-setup"),
2454 });
2455
2456 expect(result.worktreePath).not.toBeNull();
2457 expect(setupCalls).toHaveLength(1);
2458 expect(setupCalls[0]).toEqual({
2459 threadId: "thread-pr-setup",
2460 projectCwd: repoDir,
2461 worktreePath: result.worktreePath as string,
2462 });
2463 }),
2464 );
2465
2466 it.effect("preserves fork upstream tracking when preparing a worktree PR thread", () =>
2467 Effect.gen(function* () {
2468 const repoDir = yield* makeTempDir("t3code-git-manager-");
2469 yield* initRepo(repoDir);
2470 const originDir = yield* createBareRemote();
2471 const forkDir = yield* createBareRemote();
2472 yield* runGit(repoDir, ["remote", "add", "origin", originDir]);
2473 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
2474 yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]);
2475 yield* runGit(repoDir, ["checkout", "-b", "feature/pr-fork"]);
2476 fs.writeFileSync(path.join(repoDir, "fork.txt"), "fork\n");
2477 yield* runGit(repoDir, ["add", "fork.txt"]);
2478 yield* runGit(repoDir, ["commit", "-m", "Fork PR branch"]);
2479 yield* runGit(repoDir, ["push", "-u", "fork-seed", "feature/pr-fork"]);
2480 yield* runGit(repoDir, ["checkout", "main"]);
2481
2482 const { manager } = yield* makeManager({
2483 ghScenario: {
2484 pullRequest: {
2485 number: 81,
2486 title: "Fork PR",
2487 url: "https://github.com/pingdotgg/codething-mvp/pull/81",
2488 baseRefName: "main",
2489 headRefName: "feature/pr-fork",
2490 state: "open",
2491 isCrossRepository: true,
2492 headRepositoryNameWithOwner: "octocat/codething-mvp",
2493 headRepositoryOwnerLogin: "octocat",
2494 },
2495 repositoryCloneUrls: {
2496 "octocat/codething-mvp": {
2497 url: forkDir,
2498 sshUrl: forkDir,
2499 },
2500 },
2501 },
2502 });
2503
2504 const result = yield* preparePullRequestThread(manager, {
2505 cwd: repoDir,
2506 reference: "81",
2507 mode: "worktree",
2508 });
2509
2510 expect(result.worktreePath).not.toBeNull();
2511 const upstreamRef = (yield* runGit(result.worktreePath as string, [
2512 "rev-parse",
2513 "--abbrev-ref",
2514 "@{upstream}",
2515 ])).stdout.trim();
2516 expect(upstreamRef).toBe("fork-seed/feature/pr-fork");
2517 expect(upstreamRef.startsWith("origin/")).toBe(false);
2518 expect(
2519 (yield* runGit(result.worktreePath as string, [
2520 "config",
2521 "--get",
2522 "remote.fork-seed.url",
2523 ])).stdout.trim(),
2524 ).toBe(forkDir);
2525 }),
2526 );
2527
2528 it.effect("preserves fork upstream tracking when preparing a local PR thread", () =>
2529 Effect.gen(function* () {
2530 const repoDir = yield* makeTempDir("t3code-git-manager-");
2531 yield* initRepo(repoDir);
2532 const originDir = yield* createBareRemote();
2533 const forkDir = yield* createBareRemote();
2534 yield* runGit(repoDir, ["remote", "add", "origin", originDir]);
2535 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
2536 yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]);
2537 yield* runGit(repoDir, ["checkout", "-b", "feature/pr-local-fork"]);
2538 fs.writeFileSync(path.join(repoDir, "local-fork.txt"), "local fork\n");
2539 yield* runGit(repoDir, ["add", "local-fork.txt"]);
2540 yield* runGit(repoDir, ["commit", "-m", "Local fork PR branch"]);
2541 yield* runGit(repoDir, ["push", "-u", "fork-seed", "feature/pr-local-fork"]);
2542 yield* runGit(repoDir, ["checkout", "main"]);
2543 yield* runGit(repoDir, ["branch", "-D", "feature/pr-local-fork"]);
2544
2545 const { manager } = yield* makeManager({
2546 ghScenario: {
2547 pullRequest: {
2548 number: 82,
2549 title: "Local Fork PR",
2550 url: "https://github.com/pingdotgg/codething-mvp/pull/82",
2551 baseRefName: "main",
2552 headRefName: "feature/pr-local-fork",
2553 state: "open",
2554 isCrossRepository: true,
2555 headRepositoryNameWithOwner: "octocat/codething-mvp",
2556 headRepositoryOwnerLogin: "octocat",
2557 },
2558 repositoryCloneUrls: {
2559 "octocat/codething-mvp": {
2560 url: forkDir,
2561 sshUrl: forkDir,
2562 },
2563 },
2564 },
2565 });
2566
2567 const result = yield* preparePullRequestThread(manager, {
2568 cwd: repoDir,
2569 reference: "82",
2570 mode: "local",
2571 });
2572
2573 expect(result.worktreePath).toBeNull();
2574 expect(result.branch).toBe("feature/pr-local-fork");
2575 expect(
2576 (yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"])).stdout.trim(),
2577 ).toBe("fork-seed/feature/pr-local-fork");
2578 }),
2579 );
2580
2581 it.effect("derives fork repository identity from PR URL when GitHub omits nameWithOwner", () =>
2582 Effect.gen(function* () {
2583 const repoDir = yield* makeTempDir("t3code-git-manager-");
2584 yield* initRepo(repoDir);
2585 const originDir = yield* createBareRemote();
2586 const forkDir = yield* createBareRemote();
2587 yield* runGit(repoDir, ["remote", "add", "origin", originDir]);
2588 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
2589 yield* runGit(repoDir, ["remote", "add", "binbandit-seed", forkDir]);
2590 yield* runGit(repoDir, ["checkout", "-b", "fix/git-action-default-without-origin"]);
2591 fs.writeFileSync(path.join(repoDir, "derived-fork.txt"), "derived fork\n");
2592 yield* runGit(repoDir, ["add", "derived-fork.txt"]);
2593 yield* runGit(repoDir, ["commit", "-m", "Derived fork PR branch"]);
2594 yield* runGit(repoDir, [
2595 "push",
2596 "-u",
2597 "binbandit-seed",
2598 "fix/git-action-default-without-origin",
2599 ]);
2600 yield* runGit(repoDir, ["checkout", "main"]);
2601 yield* runGit(repoDir, ["branch", "-D", "fix/git-action-default-without-origin"]);
2602
2603 const { manager } = yield* makeManager({
2604 ghScenario: {
2605 pullRequest: {
2606 number: 642,
2607 title: "fix: use commit as the default git action without origin",
2608 url: "https://github.com/pingdotgg/t3code/pull/642",
2609 baseRefName: "main",
2610 headRefName: "fix/git-action-default-without-origin",
2611 state: "open",
2612 isCrossRepository: true,
2613 headRepositoryOwnerLogin: "binbandit",
2614 },
2615 repositoryCloneUrls: {
2616 "binbandit/t3code": {
2617 url: forkDir,
2618 sshUrl: forkDir,
2619 },
2620 },
2621 },
2622 });
2623
2624 const result = yield* preparePullRequestThread(manager, {
2625 cwd: repoDir,
2626 reference: "642",
2627 mode: "local",
2628 });
2629
2630 expect(result.branch).toBe("fix/git-action-default-without-origin");
2631 expect(result.worktreePath).toBeNull();
2632 expect(
2633 (yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"])).stdout.trim(),
2634 ).toBe("binbandit-seed/fix/git-action-default-without-origin");
2635 }),
2636 );
2637
2638 it.effect("reuses an existing dedicated worktree for the PR head branch", () =>
2639 Effect.gen(function* () {
2640 const repoDir = yield* makeTempDir("t3code-git-manager-");
2641 yield* initRepo(repoDir);
2642 yield* runGit(repoDir, ["checkout", "-b", "feature/pr-existing-worktree"]);
2643 fs.writeFileSync(path.join(repoDir, "existing.txt"), "existing\n");
2644 yield* runGit(repoDir, ["add", "existing.txt"]);
2645 yield* runGit(repoDir, ["commit", "-m", "Existing worktree branch"]);
2646 yield* runGit(repoDir, ["checkout", "main"]);
2647 const worktreePath = path.join(repoDir, "..", `pr-existing-${Date.now()}`);
2648 yield* runGit(repoDir, ["worktree", "add", worktreePath, "feature/pr-existing-worktree"]);
2649
2650 const setupCalls: ProjectSetupScriptRunnerInput[] = [];
2651 const { manager } = yield* makeManager({
2652 ghScenario: {
2653 pullRequest: {
2654 number: 78,
2655 title: "Existing worktree PR",
2656 url: "https://github.com/pingdotgg/codething-mvp/pull/78",
2657 baseRefName: "main",
2658 headRefName: "feature/pr-existing-worktree",
2659 state: "open",
2660 },
2661 },
2662 setupScriptRunner: {
2663 runForThread: (setupInput) =>
2664 Effect.sync(() => {
2665 setupCalls.push(setupInput);
2666 return { status: "no-script" as const };
2667 }),
2668 },
2669 });
2670
2671 const result = yield* preparePullRequestThread(manager, {
2672 cwd: repoDir,
2673 reference: "78",
2674 mode: "worktree",
2675 threadId: asThreadId("thread-pr-existing-worktree"),
2676 });
2677
2678 expect(result.worktreePath && fs.realpathSync.native(result.worktreePath)).toBe(
2679 fs.realpathSync.native(worktreePath),
2680 );
2681 expect(result.branch).toBe("feature/pr-existing-worktree");
2682 expect(setupCalls).toHaveLength(0);
2683 }),
2684 );
2685
2686 it.effect(
2687 "does not block fork PR worktree prep when the fork head branch collides with root main",
2688 () =>
2689 Effect.gen(function* () {
2690 const repoDir = yield* makeTempDir("t3code-git-manager-");
2691 yield* initRepo(repoDir);
2692 const originDir = yield* createBareRemote();
2693 const forkDir = yield* createBareRemote();
2694 yield* runGit(repoDir, ["remote", "add", "origin", originDir]);
2695 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
2696 yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]);
2697 yield* runGit(repoDir, ["checkout", "-b", "fork-main-source"]);
2698 fs.writeFileSync(path.join(repoDir, "fork-main.txt"), "fork main\n");
2699 yield* runGit(repoDir, ["add", "fork-main.txt"]);
2700 yield* runGit(repoDir, ["commit", "-m", "Fork main branch"]);
2701 yield* runGit(repoDir, ["push", "-u", "fork-seed", "fork-main-source:main"]);
2702 yield* runGit(repoDir, ["checkout", "main"]);
2703 const mainBefore = (yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim();
2704
2705 const { manager } = yield* makeManager({
2706 ghScenario: {
2707 pullRequest: {
2708 number: 91,
2709 title: "Fork main PR",
2710 url: "https://github.com/pingdotgg/codething-mvp/pull/91",
2711 baseRefName: "main",
2712 headRefName: "main",
2713 state: "open",
2714 isCrossRepository: true,
2715 headRepositoryNameWithOwner: "octocat/codething-mvp",
2716 headRepositoryOwnerLogin: "octocat",
2717 },
2718 repositoryCloneUrls: {
2719 "octocat/codething-mvp": {
2720 url: forkDir,
2721 sshUrl: forkDir,
2722 },
2723 },
2724 },
2725 });
2726
2727 const result = yield* preparePullRequestThread(manager, {
2728 cwd: repoDir,
2729 reference: "91",
2730 mode: "worktree",
2731 });
2732
2733 expect(result.branch).toBe("t3code/pr-91/main");
2734 expect(result.worktreePath).not.toBeNull();
2735 expect((yield* runGit(repoDir, ["branch", "--show-current"])).stdout.trim()).toBe("main");
2736 expect((yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim()).toBe(mainBefore);
2737 expect(
2738 (yield* runGit(result.worktreePath as string, [
2739 "branch",
2740 "--show-current",
2741 ])).stdout.trim(),
2742 ).toBe("t3code/pr-91/main");
2743 }),
2744 );
2745
2746 it.effect(
2747 "does not overwrite an existing local main branch when preparing a fork PR worktree",
2748 () =>
2749 Effect.gen(function* () {
2750 const repoDir = yield* makeTempDir("t3code-git-manager-");
2751 yield* initRepo(repoDir);
2752 const originDir = yield* createBareRemote();
2753 const forkDir = yield* createBareRemote();
2754 yield* runGit(repoDir, ["remote", "add", "origin", originDir]);
2755 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
2756 yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]);
2757 yield* runGit(repoDir, ["checkout", "-b", "fork-main-source"]);
2758 fs.writeFileSync(path.join(repoDir, "fork-main-second.txt"), "fork main second\n");
2759 yield* runGit(repoDir, ["add", "fork-main-second.txt"]);
2760 yield* runGit(repoDir, ["commit", "-m", "Fork main second branch"]);
2761 yield* runGit(repoDir, ["push", "-u", "fork-seed", "fork-main-source:main"]);
2762 yield* runGit(repoDir, ["checkout", "main"]);
2763 const localMainBefore = (yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim();
2764 yield* runGit(repoDir, ["checkout", "-b", "feature/root-branch"]);
2765
2766 const { manager } = yield* makeManager({
2767 ghScenario: {
2768 pullRequest: {
2769 number: 92,
2770 title: "Fork main overwrite PR",
2771 url: "https://github.com/pingdotgg/codething-mvp/pull/92",
2772 baseRefName: "main",
2773 headRefName: "main",
2774 state: "open",
2775 isCrossRepository: true,
2776 headRepositoryNameWithOwner: "octocat/codething-mvp",
2777 headRepositoryOwnerLogin: "octocat",
2778 },
2779 repositoryCloneUrls: {
2780 "octocat/codething-mvp": {
2781 url: forkDir,
2782 sshUrl: forkDir,
2783 },
2784 },
2785 },
2786 });
2787
2788 const result = yield* preparePullRequestThread(manager, {
2789 cwd: repoDir,
2790 reference: "92",
2791 mode: "worktree",
2792 });
2793
2794 expect(result.branch).toBe("t3code/pr-92/main");
2795 expect((yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim()).toBe(localMainBefore);
2796 expect(
2797 (yield* runGit(result.worktreePath as string, [
2798 "rev-parse",
2799 "--abbrev-ref",
2800 "@{upstream}",
2801 ])).stdout.trim(),
2802 ).toBe("fork-seed/main");
2803 }),
2804 );
2805
2806 it.effect("reuses an existing PR worktree and restores fork upstream tracking", () =>
2807 Effect.gen(function* () {
2808 const repoDir = yield* makeTempDir("t3code-git-manager-");
2809 yield* initRepo(repoDir);
2810 const originDir = yield* createBareRemote();
2811 const forkDir = yield* createBareRemote();
2812 yield* runGit(repoDir, ["remote", "add", "origin", originDir]);
2813 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
2814 yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]);
2815 yield* runGit(repoDir, ["checkout", "-b", "feature/pr-reused-fork"]);
2816 fs.writeFileSync(path.join(repoDir, "reused-fork.txt"), "reused fork\n");
2817 yield* runGit(repoDir, ["add", "reused-fork.txt"]);
2818 yield* runGit(repoDir, ["commit", "-m", "Reused fork PR branch"]);
2819 yield* runGit(repoDir, ["push", "-u", "fork-seed", "feature/pr-reused-fork"]);
2820 yield* runGit(repoDir, ["checkout", "main"]);
2821 const worktreePath = path.join(repoDir, "..", `pr-reused-fork-${Date.now()}`);
2822 yield* runGit(repoDir, ["worktree", "add", worktreePath, "feature/pr-reused-fork"]);
2823 yield* runGit(worktreePath, ["branch", "--unset-upstream"], true);
2824
2825 const { manager } = yield* makeManager({
2826 ghScenario: {
2827 pullRequest: {
2828 number: 83,
2829 title: "Reused Fork PR",
2830 url: "https://github.com/pingdotgg/codething-mvp/pull/83",
2831 baseRefName: "main",
2832 headRefName: "feature/pr-reused-fork",
2833 state: "open",
2834 isCrossRepository: true,
2835 headRepositoryNameWithOwner: "octocat/codething-mvp",
2836 headRepositoryOwnerLogin: "octocat",
2837 },
2838 repositoryCloneUrls: {
2839 "octocat/codething-mvp": {
2840 url: forkDir,
2841 sshUrl: forkDir,
2842 },
2843 },
2844 },
2845 });
2846
2847 const result = yield* preparePullRequestThread(manager, {
2848 cwd: repoDir,
2849 reference: "83",
2850 mode: "worktree",
2851 });
2852
2853 expect(result.worktreePath && fs.realpathSync.native(result.worktreePath)).toBe(
2854 fs.realpathSync.native(worktreePath),
2855 );
2856 expect(
2857 (yield* runGit(worktreePath, ["rev-parse", "--abbrev-ref", "@{upstream}"])).stdout.trim(),
2858 ).toBe("fork-seed/feature/pr-reused-fork");
2859 }),
2860 );
2861
2862 it.effect("does not fail PR worktree prep when setup terminal startup fails", () =>
2863 Effect.gen(function* () {
2864 const repoDir = yield* makeTempDir("t3code-git-manager-");
2865 yield* initRepo(repoDir);
2866 const remoteDir = yield* createBareRemote();
2867 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
2868 yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
2869 yield* runGit(repoDir, ["checkout", "-b", "feature/pr-setup-failure"]);
2870 fs.writeFileSync(path.join(repoDir, "setup-failure.txt"), "setup failure\n");
2871 yield* runGit(repoDir, ["add", "setup-failure.txt"]);
2872 yield* runGit(repoDir, ["commit", "-m", "PR setup failure branch"]);
2873 yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-setup-failure"]);
2874 yield* runGit(repoDir, ["push", "origin", "HEAD:refs/pull/184/head"]);
2875 yield* runGit(repoDir, ["checkout", "main"]);
2876
2877 const { manager } = yield* makeManager({
2878 ghScenario: {
2879 pullRequest: {
2880 number: 184,
2881 title: "Setup failure PR",
2882 url: "https://github.com/pingdotgg/codething-mvp/pull/184",
2883 baseRefName: "main",
2884 headRefName: "feature/pr-setup-failure",
2885 state: "open",
2886 },
2887 },
2888 setupScriptRunner: {
2889 runForThread: () => Effect.fail(new Error("terminal start failed")),
2890 },
2891 });
2892
2893 const result = yield* preparePullRequestThread(manager, {
2894 cwd: repoDir,
2895 reference: "184",
2896 mode: "worktree",
2897 threadId: asThreadId("thread-pr-setup-failure"),
2898 });
2899
2900 expect(result.branch).toBe("feature/pr-setup-failure");
2901 expect(result.worktreePath).not.toBeNull();
2902 expect(fs.existsSync(result.worktreePath as string)).toBe(true);
2903 }),
2904 );
2905
2906 it.effect("rejects worktree prep when the PR head branch is checked out in the main repo", () =>
2907 Effect.gen(function* () {
2908 const repoDir = yield* makeTempDir("t3code-git-manager-");
2909 yield* initRepo(repoDir);
2910 yield* runGit(repoDir, ["checkout", "-b", "feature/pr-root-only"]);
2911
2912 const { manager } = yield* makeManager({
2913 ghScenario: {
2914 pullRequest: {
2915 number: 79,
2916 title: "Root-only PR",
2917 url: "https://github.com/pingdotgg/codething-mvp/pull/79",
2918 baseRefName: "main",
2919 headRefName: "feature/pr-root-only",
2920 state: "open",
2921 },
2922 },
2923 });
2924
2925 const errorMessage = yield* preparePullRequestThread(manager, {
2926 cwd: repoDir,
2927 reference: "79",
2928 mode: "worktree",
2929 }).pipe(
2930 Effect.flip,
2931 Effect.map((error) => error.message),
2932 );
2933
2934 expect(errorMessage).toContain("already checked out in the main repo");
2935 }),
2936 );
2937
2938 it.effect("emits ordered progress events for commit hooks", () =>
2939 Effect.gen(function* () {
2940 const repoDir = yield* makeTempDir("t3code-git-manager-");
2941 yield* initRepo(repoDir);
2942 fs.writeFileSync(path.join(repoDir, "hooked.txt"), "hooked\n");
2943 fs.writeFileSync(
2944 path.join(repoDir, ".git", "hooks", "pre-commit"),
2945 '#!/bin/sh\necho "hook: start" >&2\nsleep 1\necho "hook: end" >&2\n',
2946 { mode: 0o755 },
2947 );
2948
2949 const { manager } = yield* makeManager();
2950 const events: GitActionProgressEvent[] = [];
2951
2952 const result = yield* runStackedAction(
2953 manager,
2954 {
2955 cwd: repoDir,
2956 action: "commit",
2957 },
2958 {
2959 actionId: "action-1",
2960 progressReporter: {
2961 publish: (event) =>
2962 Effect.sync(() => {
2963 events.push(event);
2964 }),
2965 },
2966 },
2967 );
2968
2969 expect(result.commit.status).toBe("created");
2970 expect(events.map((event) => event.kind)).toContain("action_started");
2971 expect(events).toEqual(
2972 expect.arrayContaining([
2973 expect.objectContaining({
2974 kind: "phase_started",
2975 phase: "commit",
2976 }),
2977 expect.objectContaining({
2978 kind: "hook_started",
2979 hookName: "pre-commit",
2980 }),
2981 expect.objectContaining({
2982 kind: "hook_output",
2983 text: "hook: start",
2984 }),
2985 expect.objectContaining({
2986 kind: "hook_output",
2987 text: "hook: end",
2988 }),
2989 expect.objectContaining({
2990 kind: "hook_finished",
2991 hookName: "pre-commit",
2992 }),
2993 expect.objectContaining({
2994 kind: "action_finished",
2995 }),
2996 ]),
2997 );
2998 }),
2999 );
3000
3001 it.effect("emits action_failed when a commit hook rejects", () =>
3002 Effect.gen(function* () {
3003 const repoDir = yield* makeTempDir("t3code-git-manager-");
3004 yield* initRepo(repoDir);
3005 fs.writeFileSync(path.join(repoDir, "hook-failure.txt"), "broken\n");
3006 fs.writeFileSync(
3007 path.join(repoDir, ".git", "hooks", "pre-commit"),
3008 '#!/bin/sh\necho "hook: fail" >&2\nexit 1\n',
3009 { mode: 0o755 },
3010 );
3011
3012 const { manager } = yield* makeManager();
3013 const events: GitActionProgressEvent[] = [];
3014
3015 const errorMessage = yield* runStackedAction(
3016 manager,
3017 {
3018 cwd: repoDir,
3019 action: "commit",
3020 },
3021 {
3022 actionId: "action-2",
3023 progressReporter: {
3024 publish: (event) =>
3025 Effect.sync(() => {
3026 events.push(event);
3027 }),
3028 },
3029 },
3030 ).pipe(
3031 Effect.flip,
3032 Effect.map((error) => error.message),
3033 );
3034
3035 expect(errorMessage).toContain("hook: fail");
3036 expect(events).toEqual(
3037 expect.arrayContaining([
3038 expect.objectContaining({
3039 kind: "hook_started",
3040 hookName: "pre-commit",
3041 }),
3042 expect.objectContaining({
3043 kind: "action_failed",
3044 phase: "commit",
3045 }),
3046 ]),
3047 );
3048 }),
3049 );
3050
3051 it.effect("create_pr emits only the PR phase when the branch is already pushed", () =>
3052 Effect.gen(function* () {
3053 const repoDir = yield* makeTempDir("t3code-git-manager-");
3054 yield* initRepo(repoDir);
3055 yield* runGit(repoDir, ["checkout", "-b", "feature/pr-only-follow-up"]);
3056 const remoteDir = yield* createBareRemote();
3057 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
3058 fs.writeFileSync(path.join(repoDir, "pr-only.txt"), "pr only\n");
3059 yield* runGit(repoDir, ["add", "pr-only.txt"]);
3060 yield* runGit(repoDir, ["commit", "-m", "PR only branch"]);
3061 yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-only-follow-up"]);
3062
3063 const { manager } = yield* makeManager({
3064 ghScenario: {
3065 prListSequence: [
3066 JSON.stringify([]),
3067 JSON.stringify([
3068 {
3069 number: 201,
3070 title: "PR only branch",
3071 url: "https://github.com/pingdotgg/codething-mvp/pull/201",
3072 baseRefName: "main",
3073 headRefName: "feature/pr-only-follow-up",
3074 state: "OPEN",
3075 isCrossRepository: false,
3076 },
3077 ]),
3078 ],
3079 },
3080 });
3081 const events: GitActionProgressEvent[] = [];
3082
3083 const result = yield* runStackedAction(
3084 manager,
3085 {
3086 cwd: repoDir,
3087 action: "create_pr",
3088 },
3089 {
3090 actionId: "action-pr-only",
3091 progressReporter: {
3092 publish: (event) =>
3093 Effect.sync(() => {
3094 events.push(event);
3095 }),
3096 },
3097 },
3098 );
3099
3100 expect(result.commit.status).toBe("skipped_not_requested");
3101 expect(result.push.status).toBe("skipped_not_requested");
3102 expect(result.pr.status).toBe("created");
3103 expect(
3104 events.filter(
3105 (event): event is Extract<GitActionProgressEvent, { kind: "phase_started" }> =>
3106 event.kind === "phase_started",
3107 ),
3108 ).toEqual([
3109 expect.objectContaining({
3110 kind: "phase_started",
3111 phase: "pr",
3112 label: "Preparing PR...",
3113 }),
3114 expect.objectContaining({
3115 kind: "phase_started",
3116 phase: "pr",
3117 label: "Generating PR content...",
3118 }),
3119 expect.objectContaining({
3120 kind: "phase_started",
3121 phase: "pr",
3122 label: "Creating GitHub pull request...",
3123 }),
3124 ]);
3125 }),
3126 );
3127});
apps/server/src/git/Layers/GitManager.ts deleted-1740
......@@ -1,1740 +0,0 @@
1import { randomUUID } from "node:crypto";
2import { realpathSync } from "node:fs";
3
4import {
5 Cache,
6 Duration,
7 Effect,
8 Exit,
9 FileSystem,
10 Layer,
11 Option,
12 Path,
13 Ref,
14 Result,
15} from "effect";
16import {
17 GitActionProgressEvent,
18 GitActionProgressPhase,
19 GitCommandError,
20 GitRunStackedActionResult,
21 GitStackedAction,
22 type GitStatusLocalResult,
23 type GitStatusRemoteResult,
24 ModelSelection,
25} from "@t3tools/contracts";
26import {
27 detectGitHostingProviderFromRemoteUrl,
28 mergeGitStatusParts,
29 resolveAutoFeatureBranchName,
30 sanitizeBranchFragment,
31 sanitizeFeatureBranchName,
32} from "@t3tools/shared/git";
33
34import { GitManagerError } from "@t3tools/contracts";
35import {
36 GitManager,
37 type GitActionProgressReporter,
38 type GitManagerShape,
39 type GitRunStackedActionOptions,
40} from "../Services/GitManager.ts";
41import { GitCore } from "../Services/GitCore.ts";
42import type { GitStatusDetails } from "../Services/GitCore.ts";
43import { GitHubCli, type GitHubPullRequestSummary } from "../Services/GitHubCli.ts";
44import { TextGeneration } from "../Services/TextGeneration.ts";
45import { ProjectSetupScriptRunner } from "../../project/Services/ProjectSetupScriptRunner.ts";
46import { extractBranchNameFromRemoteRef } from "../remoteRefs.ts";
47import { ServerSettingsService } from "../../serverSettings.ts";
48import type { GitManagerServiceError } from "@t3tools/contracts";
49import {
50 decodeGitHubPullRequestListJson,
51 formatGitHubJsonDecodeError,
52} from "../githubPullRequests.ts";
53
54const COMMIT_TIMEOUT_MS = 10 * 60_000;
55const MAX_PROGRESS_TEXT_LENGTH = 500;
56const SHORT_SHA_LENGTH = 7;
57const TOAST_DESCRIPTION_MAX = 72;
58const STATUS_RESULT_CACHE_TTL = Duration.seconds(1);
59const STATUS_RESULT_CACHE_CAPACITY = 2_048;
60type StripProgressContext<T> = T extends any ? Omit<T, "actionId" | "cwd" | "action"> : never;
61type GitActionProgressPayload = StripProgressContext<GitActionProgressEvent>;
62type GitActionProgressEmitter = (event: GitActionProgressPayload) => Effect.Effect<void, never>;
63
64function isNotGitRepositoryError(error: GitCommandError): boolean {
65 return error.message.toLowerCase().includes("not a git repository");
66}
67
68interface OpenPrInfo {
69 number: number;
70 title: string;
71 url: string;
72 baseRefName: string;
73 headRefName: string;
74}
75
76interface PullRequestInfo extends OpenPrInfo, PullRequestHeadRemoteInfo {
77 state: "open" | "closed" | "merged";
78 updatedAt: string | null;
79}
80
81interface ResolvedPullRequest {
82 number: number;
83 title: string;
84 url: string;
85 baseBranch: string;
86 headBranch: string;
87 state: "open" | "closed" | "merged";
88}
89
90interface PullRequestHeadRemoteInfo {
91 isCrossRepository?: boolean;
92 headRepositoryNameWithOwner?: string | null;
93 headRepositoryOwnerLogin?: string | null;
94}
95
96interface BranchHeadContext {
97 localBranch: string;
98 headBranch: string;
99 headSelectors: ReadonlyArray<string>;
100 preferredHeadSelector: string;
101 remoteName: string | null;
102 headRepositoryNameWithOwner: string | null;
103 headRepositoryOwnerLogin: string | null;
104 isCrossRepository: boolean;
105}
106
107function parseRepositoryNameFromPullRequestUrl(url: string): string | null {
108 const trimmed = url.trim();
109 const match = /^https:\/\/github\.com\/[^/]+\/([^/]+)\/pull\/\d+(?:\/.*)?$/i.exec(trimmed);
110 const repositoryName = match?.[1]?.trim() ?? "";
111 return repositoryName.length > 0 ? repositoryName : null;
112}
113
114function resolveHeadRepositoryNameWithOwner(
115 pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo,
116): string | null {
117 const explicitRepository = pullRequest.headRepositoryNameWithOwner?.trim() ?? "";
118 if (explicitRepository.length > 0) {
119 return explicitRepository;
120 }
121
122 if (!pullRequest.isCrossRepository) {
123 return null;
124 }
125
126 const ownerLogin = pullRequest.headRepositoryOwnerLogin?.trim() ?? "";
127 const repositoryName = parseRepositoryNameFromPullRequestUrl(pullRequest.url);
128 if (ownerLogin.length === 0 || !repositoryName) {
129 return null;
130 }
131
132 return `${ownerLogin}/${repositoryName}`;
133}
134
135function resolvePullRequestWorktreeLocalBranchName(
136 pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo,
137): string {
138 if (!pullRequest.isCrossRepository) {
139 return pullRequest.headBranch;
140 }
141
142 const sanitizedHeadBranch = sanitizeBranchFragment(pullRequest.headBranch).trim();
143 const suffix = sanitizedHeadBranch.length > 0 ? sanitizedHeadBranch : "head";
144 return `t3code/pr-${pullRequest.number}/${suffix}`;
145}
146
147function parseGitHubRepositoryNameWithOwnerFromRemoteUrl(url: string | null): string | null {
148 const trimmed = url?.trim() ?? "";
149 if (trimmed.length === 0) {
150 return null;
151 }
152
153 const match =
154 /^(?:git@github\.com:|ssh:\/\/git@github\.com\/|https:\/\/github\.com\/|git:\/\/github\.com\/)([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/i.exec(
155 trimmed,
156 );
157 const repositoryNameWithOwner = match?.[1]?.trim() ?? "";
158 return repositoryNameWithOwner.length > 0 ? repositoryNameWithOwner : null;
159}
160
161function parseRepositoryOwnerLogin(nameWithOwner: string | null): string | null {
162 const trimmed = nameWithOwner?.trim() ?? "";
163 if (trimmed.length === 0) {
164 return null;
165 }
166 const [ownerLogin] = trimmed.split("/");
167 const normalizedOwnerLogin = ownerLogin?.trim() ?? "";
168 return normalizedOwnerLogin.length > 0 ? normalizedOwnerLogin : null;
169}
170
171function normalizeOptionalString(value: string | null | undefined): string | null {
172 const trimmed = value?.trim() ?? "";
173 return trimmed.length > 0 ? trimmed : null;
174}
175
176function normalizeOptionalRepositoryNameWithOwner(value: string | null | undefined): string | null {
177 const normalized = normalizeOptionalString(value);
178 return normalized ? normalized.toLowerCase() : null;
179}
180
181function normalizeOptionalOwnerLogin(value: string | null | undefined): string | null {
182 const normalized = normalizeOptionalString(value);
183 return normalized ? normalized.toLowerCase() : null;
184}
185
186function resolvePullRequestHeadRepositoryNameWithOwner(
187 pr: PullRequestHeadRemoteInfo & { url: string },
188) {
189 const explicitRepository = normalizeOptionalString(pr.headRepositoryNameWithOwner);
190 if (explicitRepository) {
191 return explicitRepository;
192 }
193
194 if (!pr.isCrossRepository) {
195 return null;
196 }
197
198 const ownerLogin = normalizeOptionalString(pr.headRepositoryOwnerLogin);
199 const repositoryName = parseRepositoryNameFromPullRequestUrl(pr.url);
200 if (!ownerLogin || !repositoryName) {
201 return null;
202 }
203
204 return `${ownerLogin}/${repositoryName}`;
205}
206
207function matchesBranchHeadContext(
208 pr: PullRequestInfo,
209 headContext: Pick<
210 BranchHeadContext,
211 "headBranch" | "headRepositoryNameWithOwner" | "headRepositoryOwnerLogin" | "isCrossRepository"
212 >,
213): boolean {
214 if (pr.headRefName !== headContext.headBranch) {
215 return false;
216 }
217
218 const expectedHeadRepository = normalizeOptionalRepositoryNameWithOwner(
219 headContext.headRepositoryNameWithOwner,
220 );
221 const expectedHeadOwner =
222 normalizeOptionalOwnerLogin(headContext.headRepositoryOwnerLogin) ??
223 parseRepositoryOwnerLogin(expectedHeadRepository);
224 const prHeadRepository = normalizeOptionalRepositoryNameWithOwner(
225 resolvePullRequestHeadRepositoryNameWithOwner(pr),
226 );
227 const prHeadOwner =
228 normalizeOptionalOwnerLogin(pr.headRepositoryOwnerLogin) ??
229 parseRepositoryOwnerLogin(prHeadRepository);
230
231 if (headContext.isCrossRepository) {
232 if (pr.isCrossRepository === false) {
233 return false;
234 }
235 if ((expectedHeadRepository || expectedHeadOwner) && !prHeadRepository && !prHeadOwner) {
236 return false;
237 }
238 if (expectedHeadRepository && prHeadRepository && expectedHeadRepository !== prHeadRepository) {
239 return false;
240 }
241 if (expectedHeadOwner && prHeadOwner && expectedHeadOwner !== prHeadOwner) {
242 return false;
243 }
244 return true;
245 }
246
247 if (pr.isCrossRepository === true) {
248 return false;
249 }
250 if (expectedHeadRepository && prHeadRepository && expectedHeadRepository !== prHeadRepository) {
251 return false;
252 }
253 if (expectedHeadOwner && prHeadOwner && expectedHeadOwner !== prHeadOwner) {
254 return false;
255 }
256 return true;
257}
258
259function toPullRequestInfo(summary: GitHubPullRequestSummary): PullRequestInfo {
260 return {
261 number: summary.number,
262 title: summary.title,
263 url: summary.url,
264 baseRefName: summary.baseRefName,
265 headRefName: summary.headRefName,
266 state: summary.state ?? "open",
267 updatedAt: null,
268 ...(summary.isCrossRepository !== undefined
269 ? { isCrossRepository: summary.isCrossRepository }
270 : {}),
271 ...(summary.headRepositoryNameWithOwner !== undefined
272 ? { headRepositoryNameWithOwner: summary.headRepositoryNameWithOwner }
273 : {}),
274 ...(summary.headRepositoryOwnerLogin !== undefined
275 ? { headRepositoryOwnerLogin: summary.headRepositoryOwnerLogin }
276 : {}),
277 };
278}
279
280function gitManagerError(operation: string, detail: string, cause?: unknown): GitManagerError {
281 return new GitManagerError({
282 operation,
283 detail,
284 ...(cause !== undefined ? { cause } : {}),
285 });
286}
287
288function limitContext(value: string, maxChars: number): string {
289 if (value.length <= maxChars) return value;
290 return `${value.slice(0, maxChars)}\n\n[truncated]`;
291}
292
293function shortenSha(sha: string | undefined): string | null {
294 if (!sha) return null;
295 return sha.slice(0, SHORT_SHA_LENGTH);
296}
297
298function truncateText(
299 value: string | undefined,
300 maxLength = TOAST_DESCRIPTION_MAX,
301): string | undefined {
302 if (!value) return undefined;
303 if (value.length <= maxLength) return value;
304 if (maxLength <= 3) return "...".slice(0, maxLength);
305 return `${value.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`;
306}
307
308function withDescription(title: string, description: string | undefined) {
309 return description ? { title, description } : { title };
310}
311
312function summarizeGitActionResult(
313 result: Pick<GitRunStackedActionResult, "commit" | "push" | "pr">,
314): {
315 title: string;
316 description?: string;
317} {
318 if (result.pr.status === "created" || result.pr.status === "opened_existing") {
319 const prNumber = result.pr.number ? ` #${result.pr.number}` : "";
320 const title = `${result.pr.status === "created" ? "Created PR" : "Opened PR"}${prNumber}`;
321 return withDescription(title, truncateText(result.pr.title));
322 }
323
324 if (result.push.status === "pushed") {
325 const shortSha = shortenSha(result.commit.commitSha);
326 const branch = result.push.upstreamBranch ?? result.push.branch;
327 const pushedCommitPart = shortSha ? ` ${shortSha}` : "";
328 const branchPart = branch ? ` to ${branch}` : "";
329 return withDescription(
330 `Pushed${pushedCommitPart}${branchPart}`,
331 truncateText(result.commit.subject),
332 );
333 }
334
335 if (result.commit.status === "created") {
336 const shortSha = shortenSha(result.commit.commitSha);
337 const title = shortSha ? `Committed ${shortSha}` : "Committed changes";
338 return withDescription(title, truncateText(result.commit.subject));
339 }
340
341 return { title: "Done" };
342}
343
344function sanitizeCommitMessage(generated: {
345 subject: string;
346 body: string;
347 branch?: string | undefined;
348}): {
349 subject: string;
350 body: string;
351 branch?: string | undefined;
352} {
353 const rawSubject = generated.subject.trim().split(/\r?\n/g)[0]?.trim() ?? "";
354 const subject = rawSubject.replace(/[.]+$/g, "").trim();
355 const safeSubject = subject.length > 0 ? subject.slice(0, 72).trimEnd() : "Update project files";
356 return {
357 subject: safeSubject,
358 body: generated.body.trim(),
359 ...(generated.branch !== undefined ? { branch: generated.branch } : {}),
360 };
361}
362
363function sanitizeProgressText(value: string): string | null {
364 const trimmed = value.trim();
365 if (trimmed.length === 0) {
366 return null;
367 }
368 if (trimmed.length <= MAX_PROGRESS_TEXT_LENGTH) {
369 return trimmed;
370 }
371 return trimmed.slice(0, MAX_PROGRESS_TEXT_LENGTH).trimEnd();
372}
373
374interface CommitAndBranchSuggestion {
375 subject: string;
376 body: string;
377 branch?: string | undefined;
378 commitMessage: string;
379}
380
381function isCommitAction(
382 action: GitStackedAction,
383): action is "commit" | "commit_push" | "commit_push_pr" {
384 return action === "commit" || action === "commit_push" || action === "commit_push_pr";
385}
386
387function formatCommitMessage(subject: string, body: string): string {
388 const trimmedBody = body.trim();
389 if (trimmedBody.length === 0) {
390 return subject;
391 }
392 return `${subject}\n\n${trimmedBody}`;
393}
394
395function parseCustomCommitMessage(raw: string): { subject: string; body: string } | null {
396 const normalized = raw.replace(/\r\n/g, "\n").trim();
397 if (normalized.length === 0) {
398 return null;
399 }
400
401 const [firstLine, ...rest] = normalized.split("\n");
402 const subject = firstLine?.trim() ?? "";
403 if (subject.length === 0) {
404 return null;
405 }
406
407 return {
408 subject,
409 body: rest.join("\n").trim(),
410 };
411}
412
413function appendUnique(values: string[], next: string | null | undefined): void {
414 const trimmed = next?.trim() ?? "";
415 if (trimmed.length === 0 || values.includes(trimmed)) {
416 return;
417 }
418 values.push(trimmed);
419}
420
421function toStatusPr(pr: PullRequestInfo): {
422 number: number;
423 title: string;
424 url: string;
425 baseBranch: string;
426 headBranch: string;
427 state: "open" | "closed" | "merged";
428} {
429 return {
430 number: pr.number,
431 title: pr.title,
432 url: pr.url,
433 baseBranch: pr.baseRefName,
434 headBranch: pr.headRefName,
435 state: pr.state,
436 };
437}
438
439function normalizePullRequestReference(reference: string): string {
440 const trimmed = reference.trim();
441 const hashNumber = /^#(\d+)$/.exec(trimmed);
442 return hashNumber?.[1] ?? trimmed;
443}
444
445function canonicalizeExistingPath(value: string): string {
446 try {
447 return realpathSync.native(value);
448 } catch {
449 return value;
450 }
451}
452
453function toResolvedPullRequest(pr: {
454 number: number;
455 title: string;
456 url: string;
457 baseRefName: string;
458 headRefName: string;
459 state?: "open" | "closed" | "merged";
460}): ResolvedPullRequest {
461 return {
462 number: pr.number,
463 title: pr.title,
464 url: pr.url,
465 baseBranch: pr.baseRefName,
466 headBranch: pr.headRefName,
467 state: pr.state ?? "open",
468 };
469}
470
471function shouldPreferSshRemote(url: string | null): boolean {
472 if (!url) return false;
473 const trimmed = url.trim();
474 return trimmed.startsWith("git@") || trimmed.startsWith("ssh://");
475}
476
477function toPullRequestHeadRemoteInfo(pr: {
478 isCrossRepository?: boolean;
479 headRepositoryNameWithOwner?: string | null;
480 headRepositoryOwnerLogin?: string | null;
481}): PullRequestHeadRemoteInfo {
482 return {
483 ...(pr.isCrossRepository !== undefined ? { isCrossRepository: pr.isCrossRepository } : {}),
484 ...(pr.headRepositoryNameWithOwner !== undefined
485 ? { headRepositoryNameWithOwner: pr.headRepositoryNameWithOwner }
486 : {}),
487 ...(pr.headRepositoryOwnerLogin !== undefined
488 ? { headRepositoryOwnerLogin: pr.headRepositoryOwnerLogin }
489 : {}),
490 };
491}
492
493export const makeGitManager = Effect.fn("makeGitManager")(function* () {
494 const gitCore = yield* GitCore;
495 const gitHubCli = yield* GitHubCli;
496 const textGeneration = yield* TextGeneration;
497 const projectSetupScriptRunner = yield* ProjectSetupScriptRunner;
498 const serverSettingsService = yield* ServerSettingsService;
499
500 const createProgressEmitter = (
501 input: { cwd: string; action: GitStackedAction },
502 options?: GitRunStackedActionOptions,
503 ) => {
504 const actionId = options?.actionId ?? randomUUID();
505 const reporter = options?.progressReporter;
506
507 const emit = (event: GitActionProgressPayload) =>
508 reporter
509 ? reporter.publish({
510 actionId,
511 cwd: input.cwd,
512 action: input.action,
513 ...event,
514 } as GitActionProgressEvent)
515 : Effect.void;
516
517 return {
518 actionId,
519 emit,
520 };
521 };
522
523 const configurePullRequestHeadUpstreamBase = Effect.fn("configurePullRequestHeadUpstream")(
524 function* (
525 cwd: string,
526 pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo,
527 localBranch = pullRequest.headBranch,
528 ) {
529 const repositoryNameWithOwner = resolveHeadRepositoryNameWithOwner(pullRequest) ?? "";
530 if (repositoryNameWithOwner.length === 0) {
531 return;
532 }
533
534 const cloneUrls = yield* gitHubCli.getRepositoryCloneUrls({
535 cwd,
536 repository: repositoryNameWithOwner,
537 });
538 const originRemoteUrl = yield* gitCore.readConfigValue(cwd, "remote.origin.url");
539 const remoteUrl = shouldPreferSshRemote(originRemoteUrl) ? cloneUrls.sshUrl : cloneUrls.url;
540 const preferredRemoteName =
541 pullRequest.headRepositoryOwnerLogin?.trim() ||
542 repositoryNameWithOwner.split("/")[0]?.trim() ||
543 "fork";
544 const remoteName = yield* gitCore.ensureRemote({
545 cwd,
546 preferredName: preferredRemoteName,
547 url: remoteUrl,
548 });
549
550 yield* gitCore.setBranchUpstream({
551 cwd,
552 branch: localBranch,
553 remoteName,
554 remoteBranch: pullRequest.headBranch,
555 });
556 },
557 );
558
559 const configurePullRequestHeadUpstream = (
560 cwd: string,
561 pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo,
562 localBranch = pullRequest.headBranch,
563 ) =>
564 configurePullRequestHeadUpstreamBase(cwd, pullRequest, localBranch).pipe(
565 Effect.catch((error) =>
566 Effect.logWarning(
567 `GitManager.configurePullRequestHeadUpstream: failed to configure upstream for ${localBranch} -> ${pullRequest.headBranch} in ${cwd}: ${error.message}`,
568 ).pipe(Effect.asVoid),
569 ),
570 );
571
572 const materializePullRequestHeadBranchBase = Effect.fn("materializePullRequestHeadBranch")(
573 function* (
574 cwd: string,
575 pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo,
576 localBranch = pullRequest.headBranch,
577 ) {
578 const repositoryNameWithOwner = resolveHeadRepositoryNameWithOwner(pullRequest) ?? "";
579
580 if (repositoryNameWithOwner.length === 0) {
581 yield* gitCore.fetchPullRequestBranch({
582 cwd,
583 prNumber: pullRequest.number,
584 branch: localBranch,
585 });
586 return;
587 }
588
589 const cloneUrls = yield* gitHubCli.getRepositoryCloneUrls({
590 cwd,
591 repository: repositoryNameWithOwner,
592 });
593 const originRemoteUrl = yield* gitCore.readConfigValue(cwd, "remote.origin.url");
594 const remoteUrl = shouldPreferSshRemote(originRemoteUrl) ? cloneUrls.sshUrl : cloneUrls.url;
595 const preferredRemoteName =
596 pullRequest.headRepositoryOwnerLogin?.trim() ||
597 repositoryNameWithOwner.split("/")[0]?.trim() ||
598 "fork";
599 const remoteName = yield* gitCore.ensureRemote({
600 cwd,
601 preferredName: preferredRemoteName,
602 url: remoteUrl,
603 });
604
605 yield* gitCore.fetchRemoteBranch({
606 cwd,
607 remoteName,
608 remoteBranch: pullRequest.headBranch,
609 localBranch,
610 });
611 yield* gitCore.setBranchUpstream({
612 cwd,
613 branch: localBranch,
614 remoteName,
615 remoteBranch: pullRequest.headBranch,
616 });
617 },
618 );
619
620 const materializePullRequestHeadBranch = (
621 cwd: string,
622 pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo,
623 localBranch = pullRequest.headBranch,
624 ) =>
625 materializePullRequestHeadBranchBase(cwd, pullRequest, localBranch).pipe(
626 Effect.catch(() =>
627 gitCore.fetchPullRequestBranch({
628 cwd,
629 prNumber: pullRequest.number,
630 branch: localBranch,
631 }),
632 ),
633 );
634 const fileSystem = yield* FileSystem.FileSystem;
635 const path = yield* Path.Path;
636
637 const tempDir = process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp";
638 const normalizeStatusCacheKey = (cwd: string) => canonicalizeExistingPath(cwd);
639 const nonRepositoryStatusDetails = {
640 isRepo: false,
641 hasOriginRemote: false,
642 isDefaultBranch: false,
643 branch: null,
644 upstreamRef: null,
645 hasWorkingTreeChanges: false,
646 workingTree: { files: [], insertions: 0, deletions: 0 },
647 hasUpstream: false,
648 aheadCount: 0,
649 behindCount: 0,
650 } satisfies GitStatusDetails;
651 const readLocalStatus = Effect.fn("readLocalStatus")(function* (cwd: string) {
652 const details = yield* gitCore
653 .statusDetailsLocal(cwd)
654 .pipe(
655 Effect.catchIf(isNotGitRepositoryError, () => Effect.succeed(nonRepositoryStatusDetails)),
656 );
657 const hostingProvider = details.isRepo
658 ? yield* resolveHostingProvider(cwd, details.branch)
659 : null;
660
661 return {
662 isRepo: details.isRepo,
663 ...(hostingProvider ? { hostingProvider } : {}),
664 hasOriginRemote: details.hasOriginRemote,
665 isDefaultBranch: details.isDefaultBranch,
666 branch: details.branch,
667 hasWorkingTreeChanges: details.hasWorkingTreeChanges,
668 workingTree: details.workingTree,
669 } satisfies GitStatusLocalResult;
670 });
671 const localStatusResultCache = yield* Cache.makeWith(readLocalStatus, {
672 capacity: STATUS_RESULT_CACHE_CAPACITY,
673 timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_RESULT_CACHE_TTL : Duration.zero),
674 });
675 const invalidateLocalStatusResultCache = (cwd: string) =>
676 Cache.invalidate(localStatusResultCache, normalizeStatusCacheKey(cwd));
677 const readRemoteStatus = Effect.fn("readRemoteStatus")(function* (cwd: string) {
678 const details = yield* gitCore
679 .statusDetails(cwd)
680 .pipe(Effect.catchIf(isNotGitRepositoryError, () => Effect.succeed(null)));
681 if (details === null || !details.isRepo) {
682 return null;
683 }
684
685 const pr =
686 details.branch !== null
687 ? yield* findLatestPr(cwd, {
688 branch: details.branch,
689 upstreamRef: details.upstreamRef,
690 }).pipe(
691 Effect.map((latest) => {
692 if (!latest) return null;
693 // On the default branch, only surface open PRs.
694 // Merged/closed matches are usually reverse-merge history, not the thread's PR context.
695 if (details.isDefaultBranch && latest.state !== "open") return null;
696 return toStatusPr(latest);
697 }),
698 Effect.catch(() => Effect.succeed(null)),
699 )
700 : null;
701
702 return {
703 hasUpstream: details.hasUpstream,
704 aheadCount: details.aheadCount,
705 behindCount: details.behindCount,
706 pr,
707 } satisfies GitStatusRemoteResult;
708 });
709 const remoteStatusResultCache = yield* Cache.makeWith(readRemoteStatus, {
710 capacity: STATUS_RESULT_CACHE_CAPACITY,
711 timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_RESULT_CACHE_TTL : Duration.zero),
712 });
713 const invalidateRemoteStatusResultCache = (cwd: string) =>
714 Cache.invalidate(remoteStatusResultCache, normalizeStatusCacheKey(cwd));
715
716 const readConfigValueNullable = (cwd: string, key: string) =>
717 gitCore.readConfigValue(cwd, key).pipe(Effect.catch(() => Effect.succeed(null)));
718
719 const resolveHostingProvider = Effect.fn("resolveHostingProvider")(function* (
720 cwd: string,
721 branch: string | null,
722 ) {
723 const preferredRemoteName =
724 branch === null
725 ? "origin"
726 : ((yield* readConfigValueNullable(cwd, `branch.${branch}.remote`)) ?? "origin");
727 const remoteUrl =
728 (yield* readConfigValueNullable(cwd, `remote.${preferredRemoteName}.url`)) ??
729 (yield* readConfigValueNullable(cwd, "remote.origin.url"));
730
731 return remoteUrl ? detectGitHostingProviderFromRemoteUrl(remoteUrl) : null;
732 });
733
734 const resolveRemoteRepositoryContext = Effect.fn("resolveRemoteRepositoryContext")(function* (
735 cwd: string,
736 remoteName: string | null,
737 ) {
738 if (!remoteName) {
739 return {
740 repositoryNameWithOwner: null,
741 ownerLogin: null,
742 };
743 }
744
745 const remoteUrl = yield* readConfigValueNullable(cwd, `remote.${remoteName}.url`);
746 const repositoryNameWithOwner = parseGitHubRepositoryNameWithOwnerFromRemoteUrl(remoteUrl);
747 return {
748 repositoryNameWithOwner,
749 ownerLogin: parseRepositoryOwnerLogin(repositoryNameWithOwner),
750 };
751 });
752
753 const resolveBranchHeadContext = Effect.fn("resolveBranchHeadContext")(function* (
754 cwd: string,
755 details: { branch: string; upstreamRef: string | null },
756 ) {
757 const remoteName = yield* readConfigValueNullable(cwd, `branch.${details.branch}.remote`);
758 const headBranchFromUpstream = details.upstreamRef
759 ? extractBranchNameFromRemoteRef(details.upstreamRef, { remoteName })
760 : "";
761 const headBranch = headBranchFromUpstream.length > 0 ? headBranchFromUpstream : details.branch;
762 const shouldProbeLocalBranchSelector =
763 headBranchFromUpstream.length === 0 || headBranch === details.branch;
764
765 const [remoteRepository, originRepository] = yield* Effect.all(
766 [
767 resolveRemoteRepositoryContext(cwd, remoteName),
768 resolveRemoteRepositoryContext(cwd, "origin"),
769 ],
770 { concurrency: "unbounded" },
771 );
772
773 const isCrossRepository =
774 remoteRepository.repositoryNameWithOwner !== null &&
775 originRepository.repositoryNameWithOwner !== null
776 ? remoteRepository.repositoryNameWithOwner.toLowerCase() !==
777 originRepository.repositoryNameWithOwner.toLowerCase()
778 : remoteName !== null &&
779 remoteName !== "origin" &&
780 remoteRepository.repositoryNameWithOwner !== null;
781
782 const ownerHeadSelector =
783 remoteRepository.ownerLogin && headBranch.length > 0
784 ? `${remoteRepository.ownerLogin}:${headBranch}`
785 : null;
786 const remoteAliasHeadSelector =
787 remoteName && headBranch.length > 0 ? `${remoteName}:${headBranch}` : null;
788 const shouldProbeRemoteOwnedSelectors =
789 isCrossRepository || (remoteName !== null && remoteName !== "origin");
790
791 const headSelectors: string[] = [];
792 if (isCrossRepository && shouldProbeRemoteOwnedSelectors) {
793 appendUnique(headSelectors, ownerHeadSelector);
794 appendUnique(
795 headSelectors,
796 remoteAliasHeadSelector !== ownerHeadSelector ? remoteAliasHeadSelector : null,
797 );
798 }
799 if (shouldProbeLocalBranchSelector) {
800 appendUnique(headSelectors, details.branch);
801 }
802 appendUnique(headSelectors, headBranch !== details.branch ? headBranch : null);
803 if (!isCrossRepository && shouldProbeRemoteOwnedSelectors) {
804 appendUnique(headSelectors, ownerHeadSelector);
805 appendUnique(
806 headSelectors,
807 remoteAliasHeadSelector !== ownerHeadSelector ? remoteAliasHeadSelector : null,
808 );
809 }
810
811 return {
812 localBranch: details.branch,
813 headBranch,
814 headSelectors,
815 preferredHeadSelector:
816 ownerHeadSelector && isCrossRepository ? ownerHeadSelector : headBranch,
817 remoteName,
818 headRepositoryNameWithOwner: remoteRepository.repositoryNameWithOwner,
819 headRepositoryOwnerLogin: remoteRepository.ownerLogin,
820 isCrossRepository,
821 } satisfies BranchHeadContext;
822 });
823
824 const findOpenPr = Effect.fn("findOpenPr")(function* (
825 cwd: string,
826 headContext: Pick<
827 BranchHeadContext,
828 | "headBranch"
829 | "headSelectors"
830 | "headRepositoryNameWithOwner"
831 | "headRepositoryOwnerLogin"
832 | "isCrossRepository"
833 >,
834 ) {
835 for (const headSelector of headContext.headSelectors) {
836 const pullRequests = yield* gitHubCli.listOpenPullRequests({
837 cwd,
838 headSelector,
839 limit: 1,
840 });
841 const normalizedPullRequests = pullRequests.map(toPullRequestInfo);
842
843 const firstPullRequest = normalizedPullRequests.find((pullRequest) =>
844 matchesBranchHeadContext(pullRequest, headContext),
845 );
846 if (firstPullRequest) {
847 return {
848 number: firstPullRequest.number,
849 title: firstPullRequest.title,
850 url: firstPullRequest.url,
851 baseRefName: firstPullRequest.baseRefName,
852 headRefName: firstPullRequest.headRefName,
853 state: "open",
854 updatedAt: null,
855 } satisfies PullRequestInfo;
856 }
857 }
858
859 return null;
860 });
861
862 const findLatestPr = Effect.fn("findLatestPr")(function* (
863 cwd: string,
864 details: { branch: string; upstreamRef: string | null },
865 ) {
866 const headContext = yield* resolveBranchHeadContext(cwd, details);
867 const parsedByNumber = new Map<number, PullRequestInfo>();
868
869 for (const headSelector of headContext.headSelectors) {
870 const stdout = yield* gitHubCli
871 .execute({
872 cwd,
873 args: [
874 "pr",
875 "list",
876 "--head",
877 headSelector,
878 "--state",
879 "all",
880 "--limit",
881 "20",
882 "--json",
883 "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",
884 ],
885 })
886 .pipe(Effect.map((result) => result.stdout));
887
888 const raw = stdout.trim();
889 if (raw.length === 0) {
890 continue;
891 }
892
893 const pullRequests = yield* Effect.sync(() => decodeGitHubPullRequestListJson(raw)).pipe(
894 Effect.flatMap((decoded) => {
895 if (!Result.isSuccess(decoded)) {
896 return Effect.fail(
897 gitManagerError(
898 "findLatestPr",
899 `GitHub CLI returned invalid PR list JSON: ${formatGitHubJsonDecodeError(decoded.failure)}`,
900 decoded.failure,
901 ),
902 );
903 }
904
905 return Effect.succeed(decoded.success);
906 }),
907 );
908
909 for (const pr of pullRequests) {
910 if (!matchesBranchHeadContext(pr, headContext)) {
911 continue;
912 }
913 parsedByNumber.set(pr.number, pr);
914 }
915 }
916
917 const parsed = Array.from(parsedByNumber.values()).toSorted((a, b) => {
918 const left = a.updatedAt ? Date.parse(a.updatedAt) : 0;
919 const right = b.updatedAt ? Date.parse(b.updatedAt) : 0;
920 return right - left;
921 });
922
923 const latestOpenPr = parsed.find((pr) => pr.state === "open");
924 if (latestOpenPr) {
925 return latestOpenPr;
926 }
927 return parsed[0] ?? null;
928 });
929
930 const buildCompletionToast = Effect.fn("buildCompletionToast")(function* (
931 cwd: string,
932 result: Pick<GitRunStackedActionResult, "action" | "branch" | "commit" | "push" | "pr">,
933 ) {
934 const summary = summarizeGitActionResult(result);
935 let latestOpenPr: PullRequestInfo | null = null;
936 let currentBranchIsDefault = false;
937 let finalBranchContext: {
938 branch: string;
939 upstreamRef: string | null;
940 hasUpstream: boolean;
941 } | null = null;
942
943 if (result.action !== "commit") {
944 const finalStatus = yield* gitCore.statusDetails(cwd);
945 if (finalStatus.branch) {
946 finalBranchContext = {
947 branch: finalStatus.branch,
948 upstreamRef: finalStatus.upstreamRef,
949 hasUpstream: finalStatus.hasUpstream,
950 };
951 currentBranchIsDefault = finalStatus.isDefaultBranch;
952 }
953 }
954
955 const explicitResultPr =
956 (result.pr.status === "created" || result.pr.status === "opened_existing") && result.pr.url
957 ? {
958 url: result.pr.url,
959 state: "open" as const,
960 }
961 : null;
962 const shouldLookupExistingOpenPr =
963 (result.action === "commit_push" || result.action === "push") &&
964 result.push.status === "pushed" &&
965 result.branch.status !== "created" &&
966 !currentBranchIsDefault &&
967 explicitResultPr === null &&
968 finalBranchContext?.hasUpstream === true;
969
970 if (shouldLookupExistingOpenPr && finalBranchContext) {
971 latestOpenPr = yield* resolveBranchHeadContext(cwd, {
972 branch: finalBranchContext.branch,
973 upstreamRef: finalBranchContext.upstreamRef,
974 }).pipe(
975 Effect.flatMap((headContext) => findOpenPr(cwd, headContext)),
976 Effect.catch(() => Effect.succeed(null)),
977 );
978 }
979
980 const openPr = latestOpenPr ?? explicitResultPr;
981
982 const cta =
983 result.action === "commit" && result.commit.status === "created"
984 ? {
985 kind: "run_action" as const,
986 label: "Push",
987 action: { kind: "push" as const },
988 }
989 : (result.action === "push" ||
990 result.action === "create_pr" ||
991 result.action === "commit_push" ||
992 result.action === "commit_push_pr") &&
993 openPr?.url &&
994 (!currentBranchIsDefault ||
995 result.pr.status === "created" ||
996 result.pr.status === "opened_existing")
997 ? {
998 kind: "open_pr" as const,
999 label: "View PR",
1000 url: openPr.url,
1001 }
1002 : (result.action === "push" || result.action === "commit_push") &&
1003 result.push.status === "pushed" &&
1004 !currentBranchIsDefault
1005 ? {
1006 kind: "run_action" as const,
1007 label: "Create PR",
1008 action: { kind: "create_pr" as const },
1009 }
1010 : {
1011 kind: "none" as const,
1012 };
1013
1014 return {
1015 ...summary,
1016 cta,
1017 };
1018 });
1019
1020 const resolveBaseBranch = Effect.fn("resolveBaseBranch")(function* (
1021 cwd: string,
1022 branch: string,
1023 upstreamRef: string | null,
1024 headContext: Pick<BranchHeadContext, "isCrossRepository" | "remoteName">,
1025 ) {
1026 const configured = yield* gitCore.readConfigValue(cwd, `branch.${branch}.gh-merge-base`);
1027 if (configured) return configured;
1028
1029 if (upstreamRef && !headContext.isCrossRepository) {
1030 const upstreamBranch = extractBranchNameFromRemoteRef(upstreamRef, {
1031 remoteName: headContext.remoteName,
1032 });
1033 if (upstreamBranch.length > 0 && upstreamBranch !== branch) {
1034 return upstreamBranch;
1035 }
1036 }
1037
1038 const defaultFromGh = yield* gitHubCli
1039 .getDefaultBranch({ cwd })
1040 .pipe(Effect.catch(() => Effect.succeed(null)));
1041 if (defaultFromGh) {
1042 return defaultFromGh;
1043 }
1044
1045 return "main";
1046 });
1047
1048 const resolveCommitAndBranchSuggestion = Effect.fn("resolveCommitAndBranchSuggestion")(
1049 function* (input: {
1050 cwd: string;
1051 branch: string | null;
1052 commitMessage?: string;
1053 /** When true, also produce a semantic feature branch name. */
1054 includeBranch?: boolean;
1055 filePaths?: readonly string[];
1056 modelSelection: ModelSelection;
1057 }) {
1058 const context = yield* gitCore.prepareCommitContext(input.cwd, input.filePaths);
1059 if (!context) {
1060 return null;
1061 }
1062
1063 const customCommit = parseCustomCommitMessage(input.commitMessage ?? "");
1064 if (customCommit) {
1065 return {
1066 subject: customCommit.subject,
1067 body: customCommit.body,
1068 ...(input.includeBranch
1069 ? { branch: sanitizeFeatureBranchName(customCommit.subject) }
1070 : {}),
1071 commitMessage: formatCommitMessage(customCommit.subject, customCommit.body),
1072 };
1073 }
1074
1075 const generated = yield* textGeneration
1076 .generateCommitMessage({
1077 cwd: input.cwd,
1078 branch: input.branch,
1079 stagedSummary: limitContext(context.stagedSummary, 8_000),
1080 stagedPatch: limitContext(context.stagedPatch, 50_000),
1081 ...(input.includeBranch ? { includeBranch: true } : {}),
1082 modelSelection: input.modelSelection,
1083 })
1084 .pipe(Effect.map((result) => sanitizeCommitMessage(result)));
1085
1086 return {
1087 subject: generated.subject,
1088 body: generated.body,
1089 ...(generated.branch !== undefined ? { branch: generated.branch } : {}),
1090 commitMessage: formatCommitMessage(generated.subject, generated.body),
1091 };
1092 },
1093 );
1094
1095 const runCommitStep = Effect.fn("runCommitStep")(function* (
1096 modelSelection: ModelSelection,
1097 cwd: string,
1098 action: "commit" | "commit_push" | "commit_push_pr",
1099 branch: string | null,
1100 commitMessage?: string,
1101 preResolvedSuggestion?: CommitAndBranchSuggestion,
1102 filePaths?: readonly string[],
1103 progressReporter?: GitActionProgressReporter,
1104 actionId?: string,
1105 ) {
1106 const emit = (event: GitActionProgressPayload) =>
1107 progressReporter && actionId
1108 ? progressReporter.publish({
1109 actionId,
1110 cwd,
1111 action,
1112 ...event,
1113 } as GitActionProgressEvent)
1114 : Effect.void;
1115
1116 let suggestion: CommitAndBranchSuggestion | null | undefined = preResolvedSuggestion;
1117 if (!suggestion) {
1118 const needsGeneration = !commitMessage?.trim();
1119 if (needsGeneration) {
1120 yield* emit({
1121 kind: "phase_started",
1122 phase: "commit",
1123 label: "Generating commit message...",
1124 });
1125 }
1126 suggestion = yield* resolveCommitAndBranchSuggestion({
1127 cwd,
1128 branch,
1129 ...(commitMessage ? { commitMessage } : {}),
1130 ...(filePaths ? { filePaths } : {}),
1131 modelSelection,
1132 });
1133 }
1134 if (!suggestion) {
1135 return { status: "skipped_no_changes" as const };
1136 }
1137
1138 yield* emit({
1139 kind: "phase_started",
1140 phase: "commit",
1141 label: "Committing...",
1142 });
1143
1144 let currentHookName: string | null = null;
1145 const commitProgress =
1146 progressReporter && actionId
1147 ? {
1148 onOutputLine: ({ stream, text }: { stream: "stdout" | "stderr"; text: string }) => {
1149 const sanitized = sanitizeProgressText(text);
1150 if (!sanitized) {
1151 return Effect.void;
1152 }
1153 return emit({
1154 kind: "hook_output",
1155 hookName: currentHookName,
1156 stream,
1157 text: sanitized,
1158 });
1159 },
1160 onHookStarted: (hookName: string) => {
1161 currentHookName = hookName;
1162 return emit({
1163 kind: "hook_started",
1164 hookName,
1165 });
1166 },
1167 onHookFinished: ({
1168 hookName,
1169 exitCode,
1170 durationMs,
1171 }: {
1172 hookName: string;
1173 exitCode: number | null;
1174 durationMs: number | null;
1175 }) => {
1176 if (currentHookName === hookName) {
1177 currentHookName = null;
1178 }
1179 return emit({
1180 kind: "hook_finished",
1181 hookName,
1182 exitCode,
1183 durationMs,
1184 });
1185 },
1186 }
1187 : null;
1188 const { commitSha } = yield* gitCore.commit(cwd, suggestion.subject, suggestion.body, {
1189 timeoutMs: COMMIT_TIMEOUT_MS,
1190 ...(commitProgress ? { progress: commitProgress } : {}),
1191 });
1192 if (currentHookName !== null) {
1193 yield* emit({
1194 kind: "hook_finished",
1195 hookName: currentHookName,
1196 exitCode: 0,
1197 durationMs: null,
1198 });
1199 currentHookName = null;
1200 }
1201 return {
1202 status: "created" as const,
1203 commitSha,
1204 subject: suggestion.subject,
1205 };
1206 });
1207
1208 const runPrStep = Effect.fn("runPrStep")(function* (
1209 modelSelection: ModelSelection,
1210 cwd: string,
1211 fallbackBranch: string | null,
1212 emit: GitActionProgressEmitter,
1213 ) {
1214 const details = yield* gitCore.statusDetails(cwd);
1215 const branch = details.branch ?? fallbackBranch;
1216 if (!branch) {
1217 return yield* gitManagerError(
1218 "runPrStep",
1219 "Cannot create a pull request from detached HEAD.",
1220 );
1221 }
1222 if (!details.hasUpstream) {
1223 return yield* gitManagerError(
1224 "runPrStep",
1225 "Current branch has not been pushed. Push before creating a PR.",
1226 );
1227 }
1228
1229 const headContext = yield* resolveBranchHeadContext(cwd, {
1230 branch,
1231 upstreamRef: details.upstreamRef,
1232 });
1233
1234 const existing = yield* findOpenPr(cwd, headContext);
1235 if (existing) {
1236 return {
1237 status: "opened_existing" as const,
1238 url: existing.url,
1239 number: existing.number,
1240 baseBranch: existing.baseRefName,
1241 headBranch: existing.headRefName,
1242 title: existing.title,
1243 };
1244 }
1245
1246 const baseBranch = yield* resolveBaseBranch(cwd, branch, details.upstreamRef, headContext);
1247 yield* emit({
1248 kind: "phase_started",
1249 phase: "pr",
1250 label: "Generating PR content...",
1251 });
1252 const rangeContext = yield* gitCore.readRangeContext(cwd, baseBranch);
1253
1254 const generated = yield* textGeneration.generatePrContent({
1255 cwd,
1256 baseBranch,
1257 headBranch: headContext.headBranch,
1258 commitSummary: limitContext(rangeContext.commitSummary, 20_000),
1259 diffSummary: limitContext(rangeContext.diffSummary, 20_000),
1260 diffPatch: limitContext(rangeContext.diffPatch, 60_000),
1261 modelSelection,
1262 });
1263
1264 const bodyFile = path.join(tempDir, `t3code-pr-body-${process.pid}-${randomUUID()}.md`);
1265 yield* fileSystem
1266 .writeFileString(bodyFile, generated.body)
1267 .pipe(
1268 Effect.mapError((cause) =>
1269 gitManagerError("runPrStep", "Failed to write pull request body temp file.", cause),
1270 ),
1271 );
1272 yield* emit({
1273 kind: "phase_started",
1274 phase: "pr",
1275 label: "Creating GitHub pull request...",
1276 });
1277 yield* gitHubCli
1278 .createPullRequest({
1279 cwd,
1280 baseBranch,
1281 headSelector: headContext.preferredHeadSelector,
1282 title: generated.title,
1283 bodyFile,
1284 })
1285 .pipe(Effect.ensuring(fileSystem.remove(bodyFile).pipe(Effect.catch(() => Effect.void))));
1286
1287 const created = yield* findOpenPr(cwd, headContext);
1288 if (!created) {
1289 return {
1290 status: "created" as const,
1291 baseBranch,
1292 headBranch: headContext.headBranch,
1293 title: generated.title,
1294 };
1295 }
1296
1297 return {
1298 status: "created" as const,
1299 url: created.url,
1300 number: created.number,
1301 baseBranch: created.baseRefName,
1302 headBranch: created.headRefName,
1303 title: created.title,
1304 };
1305 });
1306
1307 const localStatus: GitManagerShape["localStatus"] = Effect.fn("localStatus")(function* (input) {
1308 return yield* Cache.get(localStatusResultCache, normalizeStatusCacheKey(input.cwd));
1309 });
1310 const remoteStatus: GitManagerShape["remoteStatus"] = Effect.fn("remoteStatus")(
1311 function* (input) {
1312 return yield* Cache.get(remoteStatusResultCache, normalizeStatusCacheKey(input.cwd));
1313 },
1314 );
1315 const status: GitManagerShape["status"] = Effect.fn("status")(function* (input) {
1316 const [local, remote] = yield* Effect.all([localStatus(input), remoteStatus(input)]);
1317 return mergeGitStatusParts(local, remote);
1318 });
1319 const invalidateLocalStatus: GitManagerShape["invalidateLocalStatus"] = Effect.fn(
1320 "invalidateLocalStatus",
1321 )(function* (cwd) {
1322 yield* invalidateLocalStatusResultCache(cwd);
1323 });
1324 const invalidateRemoteStatus: GitManagerShape["invalidateRemoteStatus"] = Effect.fn(
1325 "invalidateRemoteStatus",
1326 )(function* (cwd) {
1327 yield* invalidateRemoteStatusResultCache(cwd);
1328 });
1329 const invalidateStatus: GitManagerShape["invalidateStatus"] = Effect.fn("invalidateStatus")(
1330 function* (cwd) {
1331 yield* invalidateLocalStatusResultCache(cwd);
1332 yield* invalidateRemoteStatusResultCache(cwd);
1333 },
1334 );
1335
1336 const resolvePullRequest: GitManagerShape["resolvePullRequest"] = Effect.fn("resolvePullRequest")(
1337 function* (input) {
1338 const pullRequest = yield* gitHubCli
1339 .getPullRequest({
1340 cwd: input.cwd,
1341 reference: normalizePullRequestReference(input.reference),
1342 })
1343 .pipe(Effect.map((resolved) => toResolvedPullRequest(resolved)));
1344
1345 return { pullRequest };
1346 },
1347 );
1348
1349 const preparePullRequestThread: GitManagerShape["preparePullRequestThread"] = Effect.fn(
1350 "preparePullRequestThread",
1351 )(function* (input) {
1352 const maybeRunSetupScript = (worktreePath: string) => {
1353 if (!input.threadId) {
1354 return Effect.void;
1355 }
1356 return projectSetupScriptRunner
1357 .runForThread({
1358 threadId: input.threadId,
1359 projectCwd: input.cwd,
1360 worktreePath,
1361 })
1362 .pipe(
1363 Effect.catch((error) =>
1364 Effect.logWarning(
1365 `GitManager.preparePullRequestThread: failed to launch worktree setup script for thread ${input.threadId} in ${worktreePath}: ${error.message}`,
1366 ).pipe(Effect.asVoid),
1367 ),
1368 );
1369 };
1370 return yield* Effect.gen(function* () {
1371 const normalizedReference = normalizePullRequestReference(input.reference);
1372 const rootWorktreePath = canonicalizeExistingPath(input.cwd);
1373 const pullRequestSummary = yield* gitHubCli.getPullRequest({
1374 cwd: input.cwd,
1375 reference: normalizedReference,
1376 });
1377 const pullRequest = toResolvedPullRequest(pullRequestSummary);
1378
1379 if (input.mode === "local") {
1380 yield* gitHubCli.checkoutPullRequest({
1381 cwd: input.cwd,
1382 reference: normalizedReference,
1383 force: true,
1384 });
1385 const details = yield* gitCore.statusDetails(input.cwd);
1386 yield* configurePullRequestHeadUpstream(
1387 input.cwd,
1388 {
1389 ...pullRequest,
1390 ...toPullRequestHeadRemoteInfo(pullRequestSummary),
1391 },
1392 details.branch ?? pullRequest.headBranch,
1393 );
1394 return {
1395 pullRequest,
1396 branch: details.branch ?? pullRequest.headBranch,
1397 worktreePath: null,
1398 };
1399 }
1400
1401 const ensureExistingWorktreeUpstream = Effect.fn("ensureExistingWorktreeUpstream")(function* (
1402 worktreePath: string,
1403 ) {
1404 const details = yield* gitCore.statusDetails(worktreePath);
1405 yield* configurePullRequestHeadUpstream(
1406 worktreePath,
1407 {
1408 ...pullRequest,
1409 ...toPullRequestHeadRemoteInfo(pullRequestSummary),
1410 },
1411 details.branch ?? pullRequest.headBranch,
1412 );
1413 });
1414
1415 const pullRequestWithRemoteInfo = {
1416 ...pullRequest,
1417 ...toPullRequestHeadRemoteInfo(pullRequestSummary),
1418 } as const;
1419 const localPullRequestBranch =
1420 resolvePullRequestWorktreeLocalBranchName(pullRequestWithRemoteInfo);
1421
1422 const findLocalHeadBranch = (cwd: string) =>
1423 gitCore.listBranches({ cwd }).pipe(
1424 Effect.map((result) => {
1425 const localBranch = result.branches.find(
1426 (branch) => !branch.isRemote && branch.name === localPullRequestBranch,
1427 );
1428 if (localBranch) {
1429 return localBranch;
1430 }
1431 if (localPullRequestBranch === pullRequest.headBranch) {
1432 return null;
1433 }
1434 return (
1435 result.branches.find(
1436 (branch) =>
1437 !branch.isRemote &&
1438 branch.name === pullRequest.headBranch &&
1439 branch.worktreePath !== null &&
1440 canonicalizeExistingPath(branch.worktreePath) !== rootWorktreePath,
1441 ) ?? null
1442 );
1443 }),
1444 );
1445
1446 const existingBranchBeforeFetch = yield* findLocalHeadBranch(input.cwd);
1447 const existingBranchBeforeFetchPath = existingBranchBeforeFetch?.worktreePath
1448 ? canonicalizeExistingPath(existingBranchBeforeFetch.worktreePath)
1449 : null;
1450 if (
1451 existingBranchBeforeFetch?.worktreePath &&
1452 existingBranchBeforeFetchPath !== rootWorktreePath
1453 ) {
1454 yield* ensureExistingWorktreeUpstream(existingBranchBeforeFetch.worktreePath);
1455 return {
1456 pullRequest,
1457 branch: localPullRequestBranch,
1458 worktreePath: existingBranchBeforeFetch.worktreePath,
1459 };
1460 }
1461 if (existingBranchBeforeFetchPath === rootWorktreePath) {
1462 return yield* gitManagerError(
1463 "preparePullRequestThread",
1464 "This PR branch is already checked out in the main repo. Use Local, or switch the main repo off that branch before creating a worktree thread.",
1465 );
1466 }
1467
1468 yield* materializePullRequestHeadBranch(
1469 input.cwd,
1470 pullRequestWithRemoteInfo,
1471 localPullRequestBranch,
1472 );
1473
1474 const existingBranchAfterFetch = yield* findLocalHeadBranch(input.cwd);
1475 const existingBranchAfterFetchPath = existingBranchAfterFetch?.worktreePath
1476 ? canonicalizeExistingPath(existingBranchAfterFetch.worktreePath)
1477 : null;
1478 if (
1479 existingBranchAfterFetch?.worktreePath &&
1480 existingBranchAfterFetchPath !== rootWorktreePath
1481 ) {
1482 yield* ensureExistingWorktreeUpstream(existingBranchAfterFetch.worktreePath);
1483 return {
1484 pullRequest,
1485 branch: localPullRequestBranch,
1486 worktreePath: existingBranchAfterFetch.worktreePath,
1487 };
1488 }
1489 if (existingBranchAfterFetchPath === rootWorktreePath) {
1490 return yield* gitManagerError(
1491 "preparePullRequestThread",
1492 "This PR branch is already checked out in the main repo. Use Local, or switch the main repo off that branch before creating a worktree thread.",
1493 );
1494 }
1495
1496 const worktree = yield* gitCore.createWorktree({
1497 cwd: input.cwd,
1498 branch: localPullRequestBranch,
1499 path: null,
1500 });
1501 yield* ensureExistingWorktreeUpstream(worktree.worktree.path);
1502 yield* maybeRunSetupScript(worktree.worktree.path);
1503
1504 return {
1505 pullRequest,
1506 branch: worktree.worktree.branch,
1507 worktreePath: worktree.worktree.path,
1508 };
1509 }).pipe(Effect.ensuring(invalidateStatus(input.cwd)));
1510 });
1511
1512 const runFeatureBranchStep = Effect.fn("runFeatureBranchStep")(function* (
1513 modelSelection: ModelSelection,
1514 cwd: string,
1515 branch: string | null,
1516 commitMessage?: string,
1517 filePaths?: readonly string[],
1518 ) {
1519 const suggestion = yield* resolveCommitAndBranchSuggestion({
1520 cwd,
1521 branch,
1522 ...(commitMessage ? { commitMessage } : {}),
1523 ...(filePaths ? { filePaths } : {}),
1524 includeBranch: true,
1525 modelSelection,
1526 });
1527 if (!suggestion) {
1528 return yield* gitManagerError(
1529 "runFeatureBranchStep",
1530 "Cannot create a feature branch because there are no changes to commit.",
1531 );
1532 }
1533
1534 const preferredBranch = suggestion.branch ?? sanitizeFeatureBranchName(suggestion.subject);
1535 const existingBranchNames = yield* gitCore.listLocalBranchNames(cwd);
1536 const resolvedBranch = resolveAutoFeatureBranchName(existingBranchNames, preferredBranch);
1537
1538 yield* gitCore.createBranch({ cwd, branch: resolvedBranch });
1539 yield* Effect.scoped(gitCore.checkoutBranch({ cwd, branch: resolvedBranch }));
1540
1541 return {
1542 branchStep: { status: "created" as const, name: resolvedBranch },
1543 resolvedCommitMessage: suggestion.commitMessage,
1544 resolvedCommitSuggestion: suggestion,
1545 };
1546 });
1547
1548 const runStackedAction: GitManagerShape["runStackedAction"] = Effect.fn("runStackedAction")(
1549 function* (input, options) {
1550 const progress = createProgressEmitter(input, options);
1551 const currentPhase = yield* Ref.make<Option.Option<GitActionProgressPhase>>(Option.none());
1552
1553 const runAction = Effect.fn("runStackedAction.runAction")(function* (): Effect.fn.Return<
1554 GitRunStackedActionResult,
1555 GitManagerServiceError
1556 > {
1557 const initialStatus = yield* gitCore.statusDetails(input.cwd);
1558 const wantsCommit = isCommitAction(input.action);
1559 const wantsPush =
1560 input.action === "push" ||
1561 input.action === "commit_push" ||
1562 input.action === "commit_push_pr" ||
1563 (input.action === "create_pr" &&
1564 (!initialStatus.hasUpstream || initialStatus.aheadCount > 0));
1565 const wantsPr = input.action === "create_pr" || input.action === "commit_push_pr";
1566
1567 if (input.featureBranch && !wantsCommit) {
1568 return yield* gitManagerError(
1569 "runStackedAction",
1570 "Feature-branch checkout is only supported for commit actions.",
1571 );
1572 }
1573 if (input.action === "push" && initialStatus.hasWorkingTreeChanges) {
1574 return yield* gitManagerError(
1575 "runStackedAction",
1576 "Commit or stash local changes before pushing.",
1577 );
1578 }
1579 if (input.action === "create_pr" && initialStatus.hasWorkingTreeChanges) {
1580 return yield* gitManagerError(
1581 "runStackedAction",
1582 "Commit local changes before creating a PR.",
1583 );
1584 }
1585
1586 const phases: GitActionProgressPhase[] = [
1587 ...(input.featureBranch ? (["branch"] as const) : []),
1588 ...(wantsCommit ? (["commit"] as const) : []),
1589 ...(wantsPush ? (["push"] as const) : []),
1590 ...(wantsPr ? (["pr"] as const) : []),
1591 ];
1592
1593 yield* progress.emit({
1594 kind: "action_started",
1595 phases,
1596 });
1597
1598 if (!input.featureBranch && wantsPush && !initialStatus.branch) {
1599 return yield* gitManagerError("runStackedAction", "Cannot push from detached HEAD.");
1600 }
1601 if (!input.featureBranch && wantsPr && !initialStatus.branch) {
1602 return yield* gitManagerError(
1603 "runStackedAction",
1604 "Cannot create a pull request from detached HEAD.",
1605 );
1606 }
1607
1608 let branchStep: { status: "created" | "skipped_not_requested"; name?: string };
1609 let commitMessageForStep = input.commitMessage;
1610 let preResolvedCommitSuggestion: CommitAndBranchSuggestion | undefined = undefined;
1611
1612 const modelSelection = yield* serverSettingsService.getSettings.pipe(
1613 Effect.map((settings) => settings.textGenerationModelSelection),
1614 Effect.mapError((cause) =>
1615 gitManagerError("runStackedAction", "Failed to get server settings.", cause),
1616 ),
1617 );
1618
1619 if (input.featureBranch) {
1620 yield* Ref.set(currentPhase, Option.some("branch"));
1621 yield* progress.emit({
1622 kind: "phase_started",
1623 phase: "branch",
1624 label: "Preparing feature branch...",
1625 });
1626 const result = yield* runFeatureBranchStep(
1627 modelSelection,
1628 input.cwd,
1629 initialStatus.branch,
1630 input.commitMessage,
1631 input.filePaths,
1632 );
1633 branchStep = result.branchStep;
1634 commitMessageForStep = result.resolvedCommitMessage;
1635 preResolvedCommitSuggestion = result.resolvedCommitSuggestion;
1636 } else {
1637 branchStep = { status: "skipped_not_requested" as const };
1638 }
1639
1640 const currentBranch = branchStep.name ?? initialStatus.branch;
1641 const commitAction = isCommitAction(input.action) ? input.action : null;
1642
1643 const commit = commitAction
1644 ? yield* Ref.set(currentPhase, Option.some("commit")).pipe(
1645 Effect.flatMap(() =>
1646 runCommitStep(
1647 modelSelection,
1648 input.cwd,
1649 commitAction,
1650 currentBranch,
1651 commitMessageForStep,
1652 preResolvedCommitSuggestion,
1653 input.filePaths,
1654 options?.progressReporter,
1655 progress.actionId,
1656 ),
1657 ),
1658 )
1659 : { status: "skipped_not_requested" as const };
1660
1661 const push = wantsPush
1662 ? yield* progress
1663 .emit({
1664 kind: "phase_started",
1665 phase: "push",
1666 label: "Pushing...",
1667 })
1668 .pipe(
1669 Effect.tap(() => Ref.set(currentPhase, Option.some("push"))),
1670 Effect.flatMap(() => gitCore.pushCurrentBranch(input.cwd, currentBranch)),
1671 )
1672 : { status: "skipped_not_requested" as const };
1673
1674 const pr = wantsPr
1675 ? yield* progress
1676 .emit({
1677 kind: "phase_started",
1678 phase: "pr",
1679 label: "Preparing PR...",
1680 })
1681 .pipe(
1682 Effect.tap(() => Ref.set(currentPhase, Option.some("pr"))),
1683 Effect.flatMap(() =>
1684 runPrStep(modelSelection, input.cwd, currentBranch, progress.emit),
1685 ),
1686 )
1687 : { status: "skipped_not_requested" as const };
1688
1689 const toast = yield* buildCompletionToast(input.cwd, {
1690 action: input.action,
1691 branch: branchStep,
1692 commit,
1693 push,
1694 pr,
1695 });
1696
1697 const result = {
1698 action: input.action,
1699 branch: branchStep,
1700 commit,
1701 push,
1702 pr,
1703 toast,
1704 };
1705 yield* progress.emit({
1706 kind: "action_finished",
1707 result,
1708 });
1709 return result;
1710 });
1711
1712 return yield* runAction().pipe(
1713 Effect.ensuring(invalidateStatus(input.cwd)),
1714 Effect.tapError((error) =>
1715 Effect.flatMap(Ref.get(currentPhase), (phase) =>
1716 progress.emit({
1717 kind: "action_failed",
1718 phase: Option.getOrNull(phase),
1719 message: error.message,
1720 }),
1721 ),
1722 ),
1723 );
1724 },
1725 );
1726
1727 return {
1728 localStatus,
1729 remoteStatus,
1730 status,
1731 invalidateLocalStatus,
1732 invalidateRemoteStatus,
1733 invalidateStatus,
1734 resolvePullRequest,
1735 preparePullRequestThread,
1736 runStackedAction,
1737 } satisfies GitManagerShape;
1738});
1739
1740export const GitManagerLive = Layer.effect(GitManager, makeGitManager());
apps/server/src/git/Layers/GitStatusBroadcaster.test.ts deleted-312
......@@ -1,312 +0,0 @@
1import { assert, it } from "@effect/vitest";
2import { Deferred, Effect, Exit, Layer, Option, Scope, Stream } from "effect";
3import type {
4 GitStatusLocalResult,
5 GitStatusRemoteResult,
6 GitStatusResult,
7 GitStatusStreamEvent,
8} from "@t3tools/contracts";
9import { describe } from "vitest";
10
11import { GitStatusBroadcaster } from "../Services/GitStatusBroadcaster.ts";
12import { GitStatusBroadcasterLive } from "./GitStatusBroadcaster.ts";
13import { type GitManagerShape, GitManager } from "../Services/GitManager.ts";
14
15const baseLocalStatus: GitStatusLocalResult = {
16 isRepo: true,
17 hostingProvider: {
18 kind: "github",
19 name: "GitHub",
20 baseUrl: "https://github.com",
21 },
22 hasOriginRemote: true,
23 isDefaultBranch: false,
24 branch: "feature/status-broadcast",
25 hasWorkingTreeChanges: false,
26 workingTree: { files: [], insertions: 0, deletions: 0 },
27};
28
29const baseRemoteStatus: GitStatusRemoteResult = {
30 hasUpstream: true,
31 aheadCount: 0,
32 behindCount: 0,
33 pr: null,
34};
35
36const baseStatus: GitStatusResult = {
37 ...baseLocalStatus,
38 ...baseRemoteStatus,
39};
40
41function makeTestLayer(state: {
42 currentLocalStatus: GitStatusLocalResult;
43 currentRemoteStatus: GitStatusRemoteResult | null;
44 localStatusCalls: number;
45 remoteStatusCalls: number;
46 localInvalidationCalls: number;
47 remoteInvalidationCalls: number;
48}) {
49 const gitManager: GitManagerShape = {
50 localStatus: () =>
51 Effect.sync(() => {
52 state.localStatusCalls += 1;
53 return state.currentLocalStatus;
54 }),
55 remoteStatus: () =>
56 Effect.sync(() => {
57 state.remoteStatusCalls += 1;
58 return state.currentRemoteStatus;
59 }),
60 status: () => Effect.die("status should not be called in this test"),
61 invalidateLocalStatus: () =>
62 Effect.sync(() => {
63 state.localInvalidationCalls += 1;
64 }),
65 invalidateRemoteStatus: () =>
66 Effect.sync(() => {
67 state.remoteInvalidationCalls += 1;
68 }),
69 invalidateStatus: () => Effect.die("invalidateStatus should not be called in this test"),
70 resolvePullRequest: () => Effect.die("resolvePullRequest should not be called in this test"),
71 preparePullRequestThread: () =>
72 Effect.die("preparePullRequestThread should not be called in this test"),
73 runStackedAction: () => Effect.die("runStackedAction should not be called in this test"),
74 };
75
76 return GitStatusBroadcasterLive.pipe(Layer.provide(Layer.succeed(GitManager, gitManager)));
77}
78
79describe("GitStatusBroadcasterLive", () => {
80 it.effect("reuses the cached git status across repeated reads", () => {
81 const state = {
82 currentLocalStatus: baseLocalStatus,
83 currentRemoteStatus: baseRemoteStatus,
84 localStatusCalls: 0,
85 remoteStatusCalls: 0,
86 localInvalidationCalls: 0,
87 remoteInvalidationCalls: 0,
88 };
89
90 return Effect.gen(function* () {
91 const broadcaster = yield* GitStatusBroadcaster;
92
93 const first = yield* broadcaster.getStatus({ cwd: "/repo" });
94 const second = yield* broadcaster.getStatus({ cwd: "/repo" });
95
96 assert.deepStrictEqual(first, baseStatus);
97 assert.deepStrictEqual(second, baseStatus);
98 assert.equal(state.localStatusCalls, 1);
99 assert.equal(state.remoteStatusCalls, 1);
100 assert.equal(state.localInvalidationCalls, 0);
101 assert.equal(state.remoteInvalidationCalls, 0);
102 }).pipe(Effect.provide(makeTestLayer(state)));
103 });
104
105 it.effect("refreshes the cached snapshot after explicit invalidation", () => {
106 const state = {
107 currentLocalStatus: baseLocalStatus,
108 currentRemoteStatus: baseRemoteStatus,
109 localStatusCalls: 0,
110 remoteStatusCalls: 0,
111 localInvalidationCalls: 0,
112 remoteInvalidationCalls: 0,
113 };
114
115 return Effect.gen(function* () {
116 const broadcaster = yield* GitStatusBroadcaster;
117 const initial = yield* broadcaster.getStatus({ cwd: "/repo" });
118
119 state.currentLocalStatus = {
120 ...baseLocalStatus,
121 branch: "feature/updated-status",
122 };
123 state.currentRemoteStatus = {
124 ...baseRemoteStatus,
125 aheadCount: 2,
126 };
127 const refreshed = yield* broadcaster.refreshStatus("/repo");
128 const cached = yield* broadcaster.getStatus({ cwd: "/repo" });
129
130 assert.deepStrictEqual(initial, baseStatus);
131 assert.deepStrictEqual(refreshed, {
132 ...state.currentLocalStatus,
133 ...state.currentRemoteStatus,
134 });
135 assert.deepStrictEqual(cached, {
136 ...state.currentLocalStatus,
137 ...state.currentRemoteStatus,
138 });
139 assert.equal(state.localStatusCalls, 2);
140 assert.equal(state.remoteStatusCalls, 2);
141 assert.equal(state.localInvalidationCalls, 1);
142 assert.equal(state.remoteInvalidationCalls, 1);
143 }).pipe(Effect.provide(makeTestLayer(state)));
144 });
145
146 it.effect("refreshes only the cached local snapshot when requested", () => {
147 const state = {
148 currentLocalStatus: baseLocalStatus,
149 currentRemoteStatus: baseRemoteStatus,
150 localStatusCalls: 0,
151 remoteStatusCalls: 0,
152 localInvalidationCalls: 0,
153 remoteInvalidationCalls: 0,
154 };
155
156 return Effect.gen(function* () {
157 const broadcaster = yield* GitStatusBroadcaster;
158 const initial = yield* broadcaster.getStatus({ cwd: "/repo" });
159
160 state.currentLocalStatus = {
161 ...baseLocalStatus,
162 branch: "feature/local-only-refresh",
163 hasWorkingTreeChanges: true,
164 };
165
166 const refreshedLocal = yield* broadcaster.refreshLocalStatus("/repo");
167 const cached = yield* broadcaster.getStatus({ cwd: "/repo" });
168
169 assert.deepStrictEqual(initial, baseStatus);
170 assert.deepStrictEqual(refreshedLocal, state.currentLocalStatus);
171 assert.deepStrictEqual(cached, {
172 ...state.currentLocalStatus,
173 ...baseRemoteStatus,
174 });
175 assert.equal(state.localStatusCalls, 2);
176 assert.equal(state.remoteStatusCalls, 1);
177 assert.equal(state.localInvalidationCalls, 1);
178 assert.equal(state.remoteInvalidationCalls, 0);
179 }).pipe(Effect.provide(makeTestLayer(state)));
180 });
181
182 it.effect("streams a local snapshot first and remote updates later", () => {
183 const state = {
184 currentLocalStatus: baseLocalStatus,
185 currentRemoteStatus: baseRemoteStatus,
186 localStatusCalls: 0,
187 remoteStatusCalls: 0,
188 localInvalidationCalls: 0,
189 remoteInvalidationCalls: 0,
190 };
191
192 return Effect.gen(function* () {
193 const broadcaster = yield* GitStatusBroadcaster;
194 const snapshotDeferred = yield* Deferred.make<GitStatusStreamEvent>();
195 const remoteUpdatedDeferred = yield* Deferred.make<GitStatusStreamEvent>();
196 yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) => {
197 if (event._tag === "snapshot") {
198 return Deferred.succeed(snapshotDeferred, event).pipe(Effect.ignore);
199 }
200 if (event._tag === "remoteUpdated") {
201 return Deferred.succeed(remoteUpdatedDeferred, event).pipe(Effect.ignore);
202 }
203 return Effect.void;
204 }).pipe(Effect.forkScoped);
205
206 const snapshot = yield* Deferred.await(snapshotDeferred);
207 yield* broadcaster.refreshStatus("/repo");
208 const remoteUpdated = yield* Deferred.await(remoteUpdatedDeferred);
209
210 assert.deepStrictEqual(snapshot, {
211 _tag: "snapshot",
212 local: baseLocalStatus,
213 remote: null,
214 } satisfies GitStatusStreamEvent);
215 assert.deepStrictEqual(remoteUpdated, {
216 _tag: "remoteUpdated",
217 remote: baseRemoteStatus,
218 } satisfies GitStatusStreamEvent);
219 }).pipe(Effect.provide(makeTestLayer(state)));
220 });
221
222 it.effect("stops the remote poller after the last stream subscriber disconnects", () => {
223 const state = {
224 currentLocalStatus: baseLocalStatus,
225 currentRemoteStatus: baseRemoteStatus,
226 localStatusCalls: 0,
227 remoteStatusCalls: 0,
228 localInvalidationCalls: 0,
229 remoteInvalidationCalls: 0,
230 };
231 let remoteInterruptedDeferred: Deferred.Deferred<void, never> | null = null;
232 let remoteStartedDeferred: Deferred.Deferred<void, never> | null = null;
233 const testLayer = GitStatusBroadcasterLive.pipe(
234 Layer.provide(
235 Layer.succeed(GitManager, {
236 localStatus: () =>
237 Effect.sync(() => {
238 state.localStatusCalls += 1;
239 return state.currentLocalStatus;
240 }),
241 remoteStatus: () =>
242 Effect.sync(() => {
243 state.remoteStatusCalls += 1;
244 }).pipe(
245 Effect.andThen(
246 remoteStartedDeferred
247 ? Deferred.succeed(remoteStartedDeferred, undefined).pipe(Effect.ignore)
248 : Effect.void,
249 ),
250 Effect.andThen(Effect.never as Effect.Effect<GitStatusRemoteResult | null, never>),
251 Effect.onInterrupt(() =>
252 remoteInterruptedDeferred
253 ? Deferred.succeed(remoteInterruptedDeferred, undefined).pipe(Effect.ignore)
254 : Effect.void,
255 ),
256 ),
257 status: () => Effect.die("status should not be called in this test"),
258 invalidateLocalStatus: () =>
259 Effect.sync(() => {
260 state.localInvalidationCalls += 1;
261 }),
262 invalidateRemoteStatus: () =>
263 Effect.sync(() => {
264 state.remoteInvalidationCalls += 1;
265 }),
266 invalidateStatus: () => Effect.die("invalidateStatus should not be called in this test"),
267 resolvePullRequest: () =>
268 Effect.die("resolvePullRequest should not be called in this test"),
269 preparePullRequestThread: () =>
270 Effect.die("preparePullRequestThread should not be called in this test"),
271 runStackedAction: () => Effect.die("runStackedAction should not be called in this test"),
272 } satisfies GitManagerShape),
273 ),
274 );
275
276 return Effect.gen(function* () {
277 const remoteInterrupted = yield* Deferred.make<void>();
278 const remoteStarted = yield* Deferred.make<void>();
279 remoteInterruptedDeferred = remoteInterrupted;
280 remoteStartedDeferred = remoteStarted;
281
282 const broadcaster = yield* GitStatusBroadcaster;
283 const firstSnapshot = yield* Deferred.make<GitStatusStreamEvent>();
284 const secondSnapshot = yield* Deferred.make<GitStatusStreamEvent>();
285 const firstScope = yield* Scope.make();
286 const secondScope = yield* Scope.make();
287 yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) =>
288 event._tag === "snapshot"
289 ? Deferred.succeed(firstSnapshot, event).pipe(Effect.ignore)
290 : Effect.void,
291 ).pipe(Effect.forkIn(firstScope));
292 yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) =>
293 event._tag === "snapshot"
294 ? Deferred.succeed(secondSnapshot, event).pipe(Effect.ignore)
295 : Effect.void,
296 ).pipe(Effect.forkIn(secondScope));
297
298 yield* Deferred.await(firstSnapshot);
299 yield* Deferred.await(secondSnapshot);
300 yield* Deferred.await(remoteStarted);
301
302 assert.equal(state.remoteStatusCalls, 1);
303
304 yield* Scope.close(firstScope, Exit.void);
305 assert.equal(Option.isNone(yield* Deferred.poll(remoteInterrupted)), true);
306
307 yield* Scope.close(secondScope, Exit.void).pipe(Effect.forkScoped);
308 yield* Deferred.await(remoteInterrupted);
309 assert.equal(Option.isSome(yield* Deferred.poll(remoteInterrupted)), true);
310 }).pipe(Effect.provide(testLayer));
311 });
312});
apps/server/src/git/Layers/GitStatusBroadcaster.ts deleted-311
......@@ -1,311 +0,0 @@
1import { realpathSync } from "node:fs";
2
3import {
4 Duration,
5 Effect,
6 Exit,
7 Fiber,
8 Layer,
9 PubSub,
10 Ref,
11 Scope,
12 Stream,
13 SynchronizedRef,
14} from "effect";
15import type {
16 GitStatusInput,
17 GitStatusLocalResult,
18 GitStatusRemoteResult,
19 GitStatusStreamEvent,
20} from "@t3tools/contracts";
21import { mergeGitStatusParts } from "@t3tools/shared/git";
22
23import {
24 GitStatusBroadcaster,
25 type GitStatusBroadcasterShape,
26} from "../Services/GitStatusBroadcaster.ts";
27import { GitManager } from "../Services/GitManager.ts";
28
29const GIT_STATUS_REFRESH_INTERVAL = Duration.seconds(30);
30
31interface GitStatusChange {
32 readonly cwd: string;
33 readonly event: GitStatusStreamEvent;
34}
35
36interface CachedValue<T> {
37 readonly fingerprint: string;
38 readonly value: T;
39}
40
41interface CachedGitStatus {
42 readonly local: CachedValue<GitStatusLocalResult> | null;
43 readonly remote: CachedValue<GitStatusRemoteResult | null> | null;
44}
45
46interface ActiveRemotePoller {
47 readonly fiber: Fiber.Fiber<void, never>;
48 readonly subscriberCount: number;
49}
50
51function normalizeCwd(cwd: string): string {
52 try {
53 return realpathSync.native(cwd);
54 } catch {
55 return cwd;
56 }
57}
58
59function fingerprintStatusPart(status: unknown): string {
60 return JSON.stringify(status);
61}
62
63export const GitStatusBroadcasterLive = Layer.effect(
64 GitStatusBroadcaster,
65 Effect.gen(function* () {
66 const gitManager = yield* GitManager;
67 const changesPubSub = yield* Effect.acquireRelease(
68 PubSub.unbounded<GitStatusChange>(),
69 (pubsub) => PubSub.shutdown(pubsub),
70 );
71 const broadcasterScope = yield* Effect.acquireRelease(Scope.make(), (scope) =>
72 Scope.close(scope, Exit.void),
73 );
74 const cacheRef = yield* Ref.make(new Map<string, CachedGitStatus>());
75 const pollersRef = yield* SynchronizedRef.make(new Map<string, ActiveRemotePoller>());
76
77 const getCachedStatus = Effect.fn("getCachedStatus")(function* (cwd: string) {
78 return yield* Ref.get(cacheRef).pipe(Effect.map((cache) => cache.get(cwd) ?? null));
79 });
80
81 const updateCachedLocalStatus = Effect.fn("updateCachedLocalStatus")(function* (
82 cwd: string,
83 local: GitStatusLocalResult,
84 options?: { publish?: boolean },
85 ) {
86 const nextLocal = {
87 fingerprint: fingerprintStatusPart(local),
88 value: local,
89 } satisfies CachedValue<GitStatusLocalResult>;
90 const shouldPublish = yield* Ref.modify(cacheRef, (cache) => {
91 const previous = cache.get(cwd) ?? { local: null, remote: null };
92 const nextCache = new Map(cache);
93 nextCache.set(cwd, {
94 ...previous,
95 local: nextLocal,
96 });
97 return [previous.local?.fingerprint !== nextLocal.fingerprint, nextCache] as const;
98 });
99
100 if (options?.publish && shouldPublish) {
101 yield* PubSub.publish(changesPubSub, {
102 cwd,
103 event: {
104 _tag: "localUpdated",
105 local,
106 },
107 });
108 }
109
110 return local;
111 });
112
113 const updateCachedRemoteStatus = Effect.fn("updateCachedRemoteStatus")(function* (
114 cwd: string,
115 remote: GitStatusRemoteResult | null,
116 options?: { publish?: boolean },
117 ) {
118 const nextRemote = {
119 fingerprint: fingerprintStatusPart(remote),
120 value: remote,
121 } satisfies CachedValue<GitStatusRemoteResult | null>;
122 const shouldPublish = yield* Ref.modify(cacheRef, (cache) => {
123 const previous = cache.get(cwd) ?? { local: null, remote: null };
124 const nextCache = new Map(cache);
125 nextCache.set(cwd, {
126 ...previous,
127 remote: nextRemote,
128 });
129 return [previous.remote?.fingerprint !== nextRemote.fingerprint, nextCache] as const;
130 });
131
132 if (options?.publish && shouldPublish) {
133 yield* PubSub.publish(changesPubSub, {
134 cwd,
135 event: {
136 _tag: "remoteUpdated",
137 remote,
138 },
139 });
140 }
141
142 return remote;
143 });
144
145 const loadLocalStatus = Effect.fn("loadLocalStatus")(function* (cwd: string) {
146 const local = yield* gitManager.localStatus({ cwd });
147 return yield* updateCachedLocalStatus(cwd, local);
148 });
149
150 const loadRemoteStatus = Effect.fn("loadRemoteStatus")(function* (cwd: string) {
151 const remote = yield* gitManager.remoteStatus({ cwd });
152 return yield* updateCachedRemoteStatus(cwd, remote);
153 });
154
155 const getOrLoadLocalStatus = Effect.fn("getOrLoadLocalStatus")(function* (cwd: string) {
156 const cached = yield* getCachedStatus(cwd);
157 if (cached?.local) {
158 return cached.local.value;
159 }
160 return yield* loadLocalStatus(cwd);
161 });
162
163 const getOrLoadRemoteStatus = Effect.fn("getOrLoadRemoteStatus")(function* (cwd: string) {
164 const cached = yield* getCachedStatus(cwd);
165 if (cached?.remote) {
166 return cached.remote.value;
167 }
168 return yield* loadRemoteStatus(cwd);
169 });
170
171 const getStatus: GitStatusBroadcasterShape["getStatus"] = Effect.fn("getStatus")(function* (
172 input: GitStatusInput,
173 ) {
174 const normalizedCwd = normalizeCwd(input.cwd);
175 const [local, remote] = yield* Effect.all([
176 getOrLoadLocalStatus(normalizedCwd),
177 getOrLoadRemoteStatus(normalizedCwd),
178 ]);
179 return mergeGitStatusParts(local, remote);
180 });
181
182 const refreshLocalStatus: GitStatusBroadcasterShape["refreshLocalStatus"] = Effect.fn(
183 "refreshLocalStatus",
184 )(function* (cwd) {
185 const normalizedCwd = normalizeCwd(cwd);
186 yield* gitManager.invalidateLocalStatus(normalizedCwd);
187 const local = yield* gitManager.localStatus({ cwd: normalizedCwd });
188 return yield* updateCachedLocalStatus(normalizedCwd, local, { publish: true });
189 });
190
191 const refreshRemoteStatus = Effect.fn("refreshRemoteStatus")(function* (cwd: string) {
192 yield* gitManager.invalidateRemoteStatus(cwd);
193 const remote = yield* gitManager.remoteStatus({ cwd });
194 return yield* updateCachedRemoteStatus(cwd, remote, { publish: true });
195 });
196
197 const refreshStatus: GitStatusBroadcasterShape["refreshStatus"] = Effect.fn("refreshStatus")(
198 function* (cwd) {
199 const normalizedCwd = normalizeCwd(cwd);
200 const [local, remote] = yield* Effect.all([
201 refreshLocalStatus(normalizedCwd),
202 refreshRemoteStatus(normalizedCwd),
203 ]);
204 return mergeGitStatusParts(local, remote);
205 },
206 );
207
208 const makeRemoteRefreshLoop = (cwd: string) => {
209 const logRefreshFailure = (error: Error) =>
210 Effect.logWarning("git remote status refresh failed", {
211 cwd,
212 detail: error.message,
213 });
214
215 return refreshRemoteStatus(cwd).pipe(
216 Effect.catch(logRefreshFailure),
217 Effect.andThen(
218 Effect.forever(
219 Effect.sleep(GIT_STATUS_REFRESH_INTERVAL).pipe(
220 Effect.andThen(refreshRemoteStatus(cwd).pipe(Effect.catch(logRefreshFailure))),
221 ),
222 ),
223 ),
224 );
225 };
226
227 const retainRemotePoller = Effect.fn("retainRemotePoller")(function* (cwd: string) {
228 yield* SynchronizedRef.modifyEffect(pollersRef, (activePollers) => {
229 const existing = activePollers.get(cwd);
230 if (existing) {
231 const nextPollers = new Map(activePollers);
232 nextPollers.set(cwd, {
233 ...existing,
234 subscriberCount: existing.subscriberCount + 1,
235 });
236 return Effect.succeed([undefined, nextPollers] as const);
237 }
238
239 return makeRemoteRefreshLoop(cwd).pipe(
240 Effect.forkIn(broadcasterScope),
241 Effect.map((fiber) => {
242 const nextPollers = new Map(activePollers);
243 nextPollers.set(cwd, {
244 fiber,
245 subscriberCount: 1,
246 });
247 return [undefined, nextPollers] as const;
248 }),
249 );
250 });
251 });
252
253 const releaseRemotePoller = Effect.fn("releaseRemotePoller")(function* (cwd: string) {
254 const pollerToInterrupt = yield* SynchronizedRef.modify(pollersRef, (activePollers) => {
255 const existing = activePollers.get(cwd);
256 if (!existing) {
257 return [null, activePollers] as const;
258 }
259
260 if (existing.subscriberCount > 1) {
261 const nextPollers = new Map(activePollers);
262 nextPollers.set(cwd, {
263 ...existing,
264 subscriberCount: existing.subscriberCount - 1,
265 });
266 return [null, nextPollers] as const;
267 }
268
269 const nextPollers = new Map(activePollers);
270 nextPollers.delete(cwd);
271 return [existing.fiber, nextPollers] as const;
272 });
273
274 if (pollerToInterrupt) {
275 yield* Fiber.interrupt(pollerToInterrupt).pipe(Effect.ignore);
276 }
277 });
278
279 const streamStatus: GitStatusBroadcasterShape["streamStatus"] = (input) =>
280 Stream.unwrap(
281 Effect.gen(function* () {
282 const normalizedCwd = normalizeCwd(input.cwd);
283 const subscription = yield* PubSub.subscribe(changesPubSub);
284 const initialLocal = yield* getOrLoadLocalStatus(normalizedCwd);
285 const initialRemote = (yield* getCachedStatus(normalizedCwd))?.remote?.value ?? null;
286 yield* retainRemotePoller(normalizedCwd);
287
288 const release = releaseRemotePoller(normalizedCwd).pipe(Effect.ignore, Effect.asVoid);
289
290 return Stream.concat(
291 Stream.make({
292 _tag: "snapshot" as const,
293 local: initialLocal,
294 remote: initialRemote,
295 }),
296 Stream.fromSubscription(subscription).pipe(
297 Stream.filter((event) => event.cwd === normalizedCwd),
298 Stream.map((event) => event.event),
299 ),
300 ).pipe(Stream.ensuring(release));
301 }),
302 );
303
304 return {
305 getStatus,
306 refreshLocalStatus,
307 refreshStatus,
308 streamStatus,
309 } satisfies GitStatusBroadcasterShape;
310 }),
311);
apps/server/src/git/Layers/OpenCodeTextGeneration.test.ts deleted-346
......@@ -1,346 +0,0 @@
1import { OpenCodeSettings, ProviderInstanceId } from "@t3tools/contracts";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { it } from "@effect/vitest";
4import { Duration, Effect, Layer, Schema } from "effect";
5import { TestClock } from "effect/testing";
6import { NetService } from "@t3tools/shared/Net";
7import { beforeEach, expect } from "vitest";
8
9import { ServerConfig } from "../../config.ts";
10import {
11 OpenCodeRuntime,
12 OpenCodeRuntimeError,
13 type OpenCodeRuntimeShape,
14} from "../../provider/opencodeRuntime.ts";
15import { type TextGenerationShape } from "../Services/TextGeneration.ts";
16import { makeOpenCodeTextGeneration } from "./OpenCodeTextGeneration.ts";
17
18const runtimeMock = {
19 state: {
20 startCalls: [] as string[],
21 promptUrls: [] as string[],
22 authHeaders: [] as Array<string | null>,
23 closeCalls: [] as string[],
24 promptResult: undefined as
25 | { data?: { info?: { error?: unknown }; parts?: Array<{ type: string; text?: string }> } }
26 | undefined,
27 },
28 reset() {
29 this.state.startCalls.length = 0;
30 this.state.promptUrls.length = 0;
31 this.state.authHeaders.length = 0;
32 this.state.closeCalls.length = 0;
33 this.state.promptResult = undefined;
34 },
35};
36
37const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
38 startOpenCodeServerProcess: ({ binaryPath }) =>
39 Effect.gen(function* () {
40 const index = runtimeMock.state.startCalls.length + 1;
41 const url = `http://127.0.0.1:${4_300 + index}`;
42 runtimeMock.state.startCalls.push(binaryPath);
43 // The production runtime binds server lifetime to the caller's scope.
44 // Mirror that here so the closeCalls probe observes scope close.
45 yield* Effect.addFinalizer(() =>
46 Effect.sync(() => {
47 runtimeMock.state.closeCalls.push(url);
48 }),
49 );
50 return {
51 url,
52 exitCode: Effect.never,
53 };
54 }),
55 connectToOpenCodeServer: ({ serverUrl }) =>
56 Effect.succeed({
57 url: serverUrl ?? "http://127.0.0.1:4301",
58 exitCode: null,
59 external: Boolean(serverUrl),
60 }),
61 runOpenCodeCommand: () => Effect.succeed({ stdout: "", stderr: "", code: 0 }),
62 createOpenCodeSdkClient: ({ baseUrl, serverPassword }) =>
63 ({
64 session: {
65 create: async () => ({ data: { id: `${baseUrl}/session` } }),
66 prompt: async () => {
67 runtimeMock.state.promptUrls.push(baseUrl);
68 runtimeMock.state.authHeaders.push(
69 serverPassword ? `Basic ${btoa(`opencode:${serverPassword}`)}` : null,
70 );
71 return (
72 runtimeMock.state.promptResult ?? {
73 data: {
74 parts: [
75 {
76 type: "text",
77 text: JSON.stringify({
78 subject: "Improve OpenCode reuse",
79 body: "Reuse one server for the full action.",
80 }),
81 },
82 ],
83 },
84 }
85 );
86 },
87 },
88 }) as unknown as ReturnType<OpenCodeRuntimeShape["createOpenCodeSdkClient"]>,
89 loadOpenCodeInventory: () =>
90 Effect.fail(
91 new OpenCodeRuntimeError({
92 operation: "loadOpenCodeInventory",
93 detail: "OpenCodeRuntimeTestDouble.loadOpenCodeInventory not used in this test",
94 cause: null,
95 }),
96 ),
97};
98
99const DEFAULT_TEST_MODEL_SELECTION = {
100 instanceId: ProviderInstanceId.make("opencode"),
101 model: "openai/gpt-5",
102};
103
104const OPENCODE_TEXT_GENERATION_IDLE_TTL_MS = 30_000;
105
106const OpenCodeTextGenerationTestLayer = Layer.succeed(
107 OpenCodeRuntime,
108 OpenCodeRuntimeTestDouble,
109).pipe(
110 Layer.provideMerge(
111 ServerConfig.layerTest(process.cwd(), {
112 prefix: "t3code-opencode-text-generation-test-",
113 }),
114 ),
115 Layer.provideMerge(NetService.layer),
116 Layer.provideMerge(NodeServices.layer),
117);
118
119const OpenCodeTextGenerationExistingServerTestLayer = Layer.succeed(
120 OpenCodeRuntime,
121 OpenCodeRuntimeTestDouble,
122).pipe(
123 Layer.provideMerge(
124 ServerConfig.layerTest(process.cwd(), {
125 prefix: "t3code-opencode-text-generation-existing-server-test-",
126 }),
127 ),
128 Layer.provideMerge(NetService.layer),
129 Layer.provideMerge(NodeServices.layer),
130);
131
132const DEFAULT_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({
133 binaryPath: "fake-opencode",
134});
135const EXISTING_SERVER_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({
136 binaryPath: "fake-opencode",
137 serverUrl: "http://127.0.0.1:9999",
138 serverPassword: "secret-password",
139});
140
141function withOpenCodeTextGeneration<A, E, R>(
142 settings: OpenCodeSettings,
143 effectFn: (textGeneration: TextGenerationShape) => Effect.Effect<A, E, R>,
144) {
145 return Effect.gen(function* () {
146 const textGeneration = yield* makeOpenCodeTextGeneration(settings);
147 return yield* effectFn(textGeneration);
148 }).pipe(Effect.scoped);
149}
150
151beforeEach(() => {
152 runtimeMock.reset();
153});
154
155const advanceIdleClock = Effect.gen(function* () {
156 yield* Effect.yieldNow;
157 yield* TestClock.adjust(Duration.millis(OPENCODE_TEXT_GENERATION_IDLE_TTL_MS + 1));
158 yield* Effect.yieldNow;
159});
160
161it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGenerationLive", (it) => {
162 it.effect("reuses a warm server across back-to-back requests and closes it after idling", () =>
163 withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) =>
164 Effect.gen(function* () {
165 yield* textGeneration.generateCommitMessage({
166 cwd: process.cwd(),
167 branch: "feature/opencode-reuse",
168 stagedSummary: "M README.md",
169 stagedPatch: "diff --git a/README.md b/README.md",
170 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
171 });
172 yield* textGeneration.generateCommitMessage({
173 cwd: process.cwd(),
174 branch: "feature/opencode-reuse",
175 stagedSummary: "M README.md",
176 stagedPatch: "diff --git a/README.md b/README.md",
177 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
178 });
179
180 expect(runtimeMock.state.startCalls).toEqual(["fake-opencode"]);
181 expect(runtimeMock.state.promptUrls).toEqual([
182 "http://127.0.0.1:4301",
183 "http://127.0.0.1:4301",
184 ]);
185 expect(runtimeMock.state.closeCalls).toEqual([]);
186
187 yield* advanceIdleClock;
188
189 expect(runtimeMock.state.closeCalls).toEqual(["http://127.0.0.1:4301"]);
190 }),
191 ).pipe(Effect.provide(TestClock.layer())),
192 );
193
194 it.effect("starts a new server after the warm server idles out", () =>
195 withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) =>
196 Effect.gen(function* () {
197 yield* textGeneration.generateCommitMessage({
198 cwd: process.cwd(),
199 branch: "feature/opencode-reuse",
200 stagedSummary: "M README.md",
201 stagedPatch: "diff --git a/README.md b/README.md",
202 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
203 });
204
205 yield* advanceIdleClock;
206
207 yield* textGeneration.generateCommitMessage({
208 cwd: process.cwd(),
209 branch: "feature/opencode-reuse",
210 stagedSummary: "M README.md",
211 stagedPatch: "diff --git a/README.md b/README.md",
212 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
213 });
214
215 expect(runtimeMock.state.startCalls).toEqual(["fake-opencode", "fake-opencode"]);
216 expect(runtimeMock.state.promptUrls).toEqual([
217 "http://127.0.0.1:4301",
218 "http://127.0.0.1:4302",
219 ]);
220 expect(runtimeMock.state.closeCalls).toEqual(["http://127.0.0.1:4301"]);
221 }),
222 ).pipe(Effect.provide(TestClock.layer())),
223 );
224
225 it.effect("returns a typed empty-output error when OpenCode returns no text parts", () =>
226 withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) =>
227 Effect.gen(function* () {
228 runtimeMock.state.promptResult = { data: {} };
229
230 const error = yield* textGeneration
231 .generateCommitMessage({
232 cwd: process.cwd(),
233 branch: "feature/opencode-reuse",
234 stagedSummary: "M README.md",
235 stagedPatch: "diff --git a/README.md b/README.md",
236 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
237 })
238 .pipe(Effect.flip);
239
240 expect(error.message).toContain("OpenCode returned empty output.");
241 }),
242 ),
243 );
244
245 it.effect("parses JSON returned as plain text output", () =>
246 withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) =>
247 Effect.gen(function* () {
248 runtimeMock.state.promptResult = {
249 data: {
250 parts: [
251 {
252 type: "text",
253 text: 'Here is the result:\n{"subject":"Tighten OpenCode parsing","body":"Handle JSON text output locally."}',
254 },
255 ],
256 },
257 };
258
259 const result = yield* textGeneration.generateCommitMessage({
260 cwd: process.cwd(),
261 branch: "feature/opencode-reuse",
262 stagedSummary: "M README.md",
263 stagedPatch: "diff --git a/README.md b/README.md",
264 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
265 });
266
267 expect(result).toEqual({
268 subject: "Tighten OpenCode parsing",
269 body: "Handle JSON text output locally.",
270 });
271 }),
272 ),
273 );
274
275 it.effect("surfaces the upstream OpenCode structured-output error message", () =>
276 withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) =>
277 Effect.gen(function* () {
278 runtimeMock.state.promptResult = {
279 data: {
280 info: {
281 error: {
282 name: "StructuredOutputError",
283 data: {
284 message: "Model did not produce structured output",
285 retries: 2,
286 },
287 },
288 },
289 },
290 };
291
292 const error = yield* textGeneration
293 .generateCommitMessage({
294 cwd: process.cwd(),
295 branch: "feature/opencode-reuse",
296 stagedSummary: "M README.md",
297 stagedPatch: "diff --git a/README.md b/README.md",
298 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
299 })
300 .pipe(Effect.flip);
301
302 expect(error.message).toContain("Model did not produce structured output");
303 }),
304 ),
305 );
306});
307
308it.layer(OpenCodeTextGenerationExistingServerTestLayer)(
309 "OpenCodeTextGenerationLive with configured server URL",
310 (it) => {
311 it.effect("reuses a configured OpenCode server URL without spawning or applying idle TTL", () =>
312 withOpenCodeTextGeneration(EXISTING_SERVER_OPENCODE_SETTINGS, (textGeneration) =>
313 Effect.gen(function* () {
314 yield* textGeneration.generateCommitMessage({
315 cwd: process.cwd(),
316 branch: "feature/opencode-reuse",
317 stagedSummary: "M README.md",
318 stagedPatch: "diff --git a/README.md b/README.md",
319 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
320 });
321 yield* textGeneration.generateCommitMessage({
322 cwd: process.cwd(),
323 branch: "feature/opencode-reuse",
324 stagedSummary: "M README.md",
325 stagedPatch: "diff --git a/README.md b/README.md",
326 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
327 });
328
329 expect(runtimeMock.state.startCalls).toEqual([]);
330 expect(runtimeMock.state.promptUrls).toEqual([
331 "http://127.0.0.1:9999",
332 "http://127.0.0.1:9999",
333 ]);
334 expect(runtimeMock.state.authHeaders).toEqual([
335 `Basic ${btoa("opencode:secret-password")}`,
336 `Basic ${btoa("opencode:secret-password")}`,
337 ]);
338
339 yield* advanceIdleClock;
340
341 expect(runtimeMock.state.closeCalls).toEqual([]);
342 }),
343 ).pipe(Effect.provide(TestClock.layer())),
344 );
345 },
346);
apps/server/src/git/Layers/OpenCodeTextGeneration.ts deleted-464
......@@ -1,464 +0,0 @@
1import { Effect, Exit, Fiber, Schema, Scope } from "effect";
2import * as Semaphore from "effect/Semaphore";
3
4import {
5 TextGenerationError,
6 type ChatAttachment,
7 type ModelSelection,
8 type OpenCodeSettings,
9} from "@t3tools/contracts";
10import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
11import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";
12
13import { ServerConfig } from "../../config.ts";
14import { resolveAttachmentPath } from "../../attachmentStore.ts";
15import {
16 buildBranchNamePrompt,
17 buildCommitMessagePrompt,
18 buildPrContentPrompt,
19 buildThreadTitlePrompt,
20} from "../Prompts.ts";
21import { type TextGenerationShape } from "../Services/TextGeneration.ts";
22import {
23 extractJsonObject,
24 sanitizeCommitSubject,
25 sanitizePrTitle,
26 sanitizeThreadTitle,
27} from "../Utils.ts";
28import {
29 OpenCodeRuntime,
30 type OpenCodeServerConnection,
31 type OpenCodeServerProcess,
32 openCodeRuntimeErrorDetail,
33 parseOpenCodeModelSlug,
34 toOpenCodeFileParts,
35} from "../../provider/opencodeRuntime.ts";
36
37const OPENCODE_TEXT_GENERATION_IDLE_TTL = "30 seconds";
38
39function getOpenCodePromptErrorMessage(error: unknown): string | null {
40 if (!error || typeof error !== "object") {
41 return null;
42 }
43
44 const message =
45 "data" in error &&
46 error.data &&
47 typeof error.data === "object" &&
48 "message" in error.data &&
49 typeof error.data.message === "string"
50 ? error.data.message.trim()
51 : "";
52 if (message.length > 0) {
53 return message;
54 }
55
56 if ("name" in error && typeof error.name === "string") {
57 const name = error.name.trim();
58 return name.length > 0 ? name : null;
59 }
60
61 return null;
62}
63
64function getOpenCodeTextResponse(parts: ReadonlyArray<unknown> | undefined): string {
65 return (parts ?? [])
66 .flatMap((part) => {
67 if (!part || typeof part !== "object") {
68 return [];
69 }
70 if (!("type" in part) || part.type !== "text") {
71 return [];
72 }
73 if (!("text" in part) || typeof part.text !== "string") {
74 return [];
75 }
76 return [part.text];
77 })
78 .join("")
79 .trim();
80}
81
82interface SharedOpenCodeTextGenerationServerState {
83 server: OpenCodeServerProcess | null;
84 /**
85 * The scope that owns the shared server's lifetime. Closing this scope
86 * terminates the OpenCode child process and interrupts any fibers the
87 * runtime forked during startup. We don't hold a `close()` function on
88 * the server handle anymore — the scope is the only lifecycle handle.
89 */
90 serverScope: Scope.Closeable | null;
91 binaryPath: string | null;
92 activeRequests: number;
93 idleCloseFiber: Fiber.Fiber<void, never> | null;
94}
95
96export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration")(function* (
97 openCodeSettings: OpenCodeSettings,
98 environment: NodeJS.ProcessEnv = process.env,
99) {
100 const serverConfig = yield* ServerConfig;
101 const openCodeRuntime = yield* OpenCodeRuntime;
102 const idleFiberScope = yield* Effect.acquireRelease(Scope.make(), (scope) =>
103 Scope.close(scope, Exit.void),
104 );
105 const sharedServerMutex = yield* Semaphore.make(1);
106 const sharedServerState: SharedOpenCodeTextGenerationServerState = {
107 server: null,
108 serverScope: null,
109 binaryPath: null,
110 activeRequests: 0,
111 idleCloseFiber: null,
112 };
113
114 const closeSharedServer = Effect.fn("closeSharedServer")(function* () {
115 const scope = sharedServerState.serverScope;
116 sharedServerState.server = null;
117 sharedServerState.serverScope = null;
118 sharedServerState.binaryPath = null;
119 if (scope !== null) {
120 yield* Scope.close(scope, Exit.void).pipe(Effect.ignore);
121 }
122 });
123
124 const cancelIdleCloseFiber = Effect.fn("cancelIdleCloseFiber")(function* () {
125 const idleCloseFiber = sharedServerState.idleCloseFiber;
126 sharedServerState.idleCloseFiber = null;
127 if (idleCloseFiber !== null) {
128 yield* Fiber.interrupt(idleCloseFiber).pipe(Effect.ignore);
129 }
130 });
131
132 const scheduleIdleClose = Effect.fn("scheduleIdleClose")(function* (
133 server: OpenCodeServerProcess,
134 ) {
135 yield* cancelIdleCloseFiber();
136 const fiber = yield* Effect.sleep(OPENCODE_TEXT_GENERATION_IDLE_TTL).pipe(
137 Effect.andThen(
138 sharedServerMutex.withPermit(
139 Effect.gen(function* () {
140 if (sharedServerState.server !== server || sharedServerState.activeRequests > 0) {
141 return;
142 }
143 sharedServerState.idleCloseFiber = null;
144 yield* closeSharedServer();
145 }),
146 ),
147 ),
148 Effect.forkIn(idleFiberScope),
149 );
150 sharedServerState.idleCloseFiber = fiber;
151 });
152
153 const acquireSharedServer = (input: {
154 readonly binaryPath: string;
155 readonly operation:
156 | "generateCommitMessage"
157 | "generatePrContent"
158 | "generateBranchName"
159 | "generateThreadTitle";
160 }) =>
161 sharedServerMutex.withPermit(
162 Effect.gen(function* () {
163 yield* cancelIdleCloseFiber();
164
165 const existingServer = sharedServerState.server;
166 if (existingServer !== null) {
167 if (
168 sharedServerState.binaryPath !== input.binaryPath &&
169 sharedServerState.activeRequests === 0
170 ) {
171 yield* closeSharedServer();
172 } else {
173 if (sharedServerState.binaryPath !== input.binaryPath) {
174 yield* Effect.logWarning(
175 "OpenCode shared server binary path mismatch: requested " +
176 input.binaryPath +
177 " but active server uses " +
178 sharedServerState.binaryPath +
179 "; reusing existing server because there are active requests",
180 );
181 }
182 sharedServerState.activeRequests += 1;
183 return existingServer;
184 }
185 }
186
187 // Create a fresh scope that owns this shared server. The runtime
188 // will attach its child-process and fiber finalizers to this scope;
189 // closing it kills the server and interrupts those fibers.
190 //
191 // The `Scope.make` / spawn / record-or-close transitions run inside
192 // `uninterruptibleMask` so an interrupt arriving between any two
193 // steps can't orphan the scope (and the child process attached to
194 // it) before we either close it on failure or hand ownership to
195 // `sharedServerState`. `restore` keeps the actual spawn
196 // interruptible; an interrupt during the spawn is captured by
197 // `Effect.exit` and drives us through the failure branch that
198 // closes the fresh scope.
199 return yield* Effect.uninterruptibleMask((restore) =>
200 Effect.gen(function* () {
201 const serverScope = yield* Scope.make();
202 const startedExit = yield* Effect.exit(
203 restore(
204 openCodeRuntime
205 .startOpenCodeServerProcess({
206 binaryPath: input.binaryPath,
207 environment,
208 })
209 .pipe(
210 Effect.provideService(Scope.Scope, serverScope),
211 Effect.mapError(
212 (cause) =>
213 new TextGenerationError({
214 operation: input.operation,
215 detail: openCodeRuntimeErrorDetail(cause),
216 cause,
217 }),
218 ),
219 ),
220 ),
221 );
222 if (startedExit._tag === "Failure") {
223 yield* Scope.close(serverScope, Exit.void).pipe(Effect.ignore);
224 return yield* Effect.failCause(startedExit.cause);
225 }
226
227 const server = startedExit.value;
228 sharedServerState.server = server;
229 sharedServerState.serverScope = serverScope;
230 sharedServerState.binaryPath = input.binaryPath;
231 sharedServerState.activeRequests = 1;
232 return server;
233 }),
234 );
235 }),
236 );
237
238 const releaseSharedServer = (server: OpenCodeServerProcess) =>
239 sharedServerMutex.withPermit(
240 Effect.gen(function* () {
241 if (sharedServerState.server !== server) {
242 return;
243 }
244 sharedServerState.activeRequests = Math.max(0, sharedServerState.activeRequests - 1);
245 if (sharedServerState.activeRequests === 0) {
246 yield* scheduleIdleClose(server);
247 }
248 }),
249 );
250
251 // Module-level finalizer: on layer shutdown, cancel the idle close fiber
252 // and close the shared server scope. Consumers therefore cannot leak
253 // the shared OpenCode server by forgetting to call anything.
254 yield* Effect.addFinalizer(() =>
255 sharedServerMutex.withPermit(
256 Effect.gen(function* () {
257 yield* cancelIdleCloseFiber();
258 sharedServerState.activeRequests = 0;
259 yield* closeSharedServer();
260 }),
261 ),
262 );
263
264 const runOpenCodeJson = Effect.fn("runOpenCodeJson")(function* <S extends Schema.Top>(input: {
265 readonly operation:
266 | "generateCommitMessage"
267 | "generatePrContent"
268 | "generateBranchName"
269 | "generateThreadTitle";
270 readonly cwd: string;
271 readonly prompt: string;
272 readonly outputSchemaJson: S;
273 readonly modelSelection: ModelSelection;
274 readonly attachments?: ReadonlyArray<ChatAttachment> | undefined;
275 }) {
276 const parsedModel = parseOpenCodeModelSlug(input.modelSelection.model);
277 if (!parsedModel) {
278 return yield* new TextGenerationError({
279 operation: input.operation,
280 detail: "OpenCode model selection must use the 'provider/model' format.",
281 });
282 }
283
284 const fileParts = toOpenCodeFileParts({
285 attachments: input.attachments,
286 resolveAttachmentPath: (attachment) =>
287 resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }),
288 });
289
290 const runAgainstServer = (server: Pick<OpenCodeServerConnection, "url">) =>
291 Effect.tryPromise({
292 try: async () => {
293 const client = openCodeRuntime.createOpenCodeSdkClient({
294 baseUrl: server.url,
295 directory: input.cwd,
296 ...(openCodeSettings.serverUrl.length > 0 && openCodeSettings.serverPassword
297 ? { serverPassword: openCodeSettings.serverPassword }
298 : {}),
299 });
300 const session = await client.session.create({
301 title: `T3 Code ${input.operation}`,
302 permission: [{ permission: "*", pattern: "*", action: "deny" }],
303 });
304 if (!session.data) {
305 throw new Error("OpenCode session.create returned no session payload.");
306 }
307 const selectedAgent = getModelSelectionStringOptionValue(input.modelSelection, "agent");
308 const selectedVariant = getModelSelectionStringOptionValue(
309 input.modelSelection,
310 "variant",
311 );
312
313 const result = await client.session.prompt({
314 sessionID: session.data.id,
315 model: parsedModel,
316 ...(selectedAgent ? { agent: selectedAgent } : {}),
317 ...(selectedVariant ? { variant: selectedVariant } : {}),
318 parts: [{ type: "text", text: input.prompt }, ...fileParts],
319 });
320 const info = result.data?.info;
321 const errorMessage = getOpenCodePromptErrorMessage(info?.error);
322 if (errorMessage) {
323 throw new Error(errorMessage);
324 }
325 const rawText = getOpenCodeTextResponse(result.data?.parts);
326 if (rawText.length === 0) {
327 throw new Error("OpenCode returned empty output.");
328 }
329 return rawText;
330 },
331 catch: (cause) =>
332 new TextGenerationError({
333 operation: input.operation,
334 detail: openCodeRuntimeErrorDetail(cause),
335 cause,
336 }),
337 });
338
339 const rawOutput =
340 openCodeSettings.serverUrl.length > 0
341 ? yield* runAgainstServer({ url: openCodeSettings.serverUrl })
342 : yield* Effect.acquireUseRelease(
343 acquireSharedServer({
344 binaryPath: openCodeSettings.binaryPath,
345 operation: input.operation,
346 }),
347 runAgainstServer,
348 releaseSharedServer,
349 );
350
351 return yield* Schema.decodeEffect(Schema.fromJsonString(input.outputSchemaJson))(
352 extractJsonObject(rawOutput),
353 ).pipe(
354 Effect.catchTag("SchemaError", (cause) =>
355 Effect.fail(
356 new TextGenerationError({
357 operation: input.operation,
358 detail: "OpenCode returned invalid structured output.",
359 cause,
360 }),
361 ),
362 ),
363 );
364 });
365
366 const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn(
367 "OpenCodeTextGeneration.generateCommitMessage",
368 )(function* (input) {
369 const { prompt, outputSchema } = buildCommitMessagePrompt({
370 branch: input.branch,
371 stagedSummary: input.stagedSummary,
372 stagedPatch: input.stagedPatch,
373 includeBranch: input.includeBranch === true,
374 });
375 const generated = yield* runOpenCodeJson({
376 operation: "generateCommitMessage",
377 cwd: input.cwd,
378 prompt,
379 outputSchemaJson: outputSchema,
380 modelSelection: input.modelSelection,
381 });
382
383 return {
384 subject: sanitizeCommitSubject(generated.subject),
385 body: generated.body.trim(),
386 ...("branch" in generated && typeof generated.branch === "string"
387 ? { branch: sanitizeFeatureBranchName(generated.branch) }
388 : {}),
389 };
390 });
391
392 const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn(
393 "OpenCodeTextGeneration.generatePrContent",
394 )(function* (input) {
395 const { prompt, outputSchema } = buildPrContentPrompt({
396 baseBranch: input.baseBranch,
397 headBranch: input.headBranch,
398 commitSummary: input.commitSummary,
399 diffSummary: input.diffSummary,
400 diffPatch: input.diffPatch,
401 });
402 const generated = yield* runOpenCodeJson({
403 operation: "generatePrContent",
404 cwd: input.cwd,
405 prompt,
406 outputSchemaJson: outputSchema,
407 modelSelection: input.modelSelection,
408 });
409
410 return {
411 title: sanitizePrTitle(generated.title),
412 body: generated.body.trim(),
413 };
414 });
415
416 const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn(
417 "OpenCodeTextGeneration.generateBranchName",
418 )(function* (input) {
419 const { prompt, outputSchema } = buildBranchNamePrompt({
420 message: input.message,
421 attachments: input.attachments,
422 });
423 const generated = yield* runOpenCodeJson({
424 operation: "generateBranchName",
425 cwd: input.cwd,
426 prompt,
427 outputSchemaJson: outputSchema,
428 modelSelection: input.modelSelection,
429 attachments: input.attachments,
430 });
431
432 return {
433 branch: sanitizeBranchFragment(generated.branch),
434 };
435 });
436
437 const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn(
438 "OpenCodeTextGeneration.generateThreadTitle",
439 )(function* (input) {
440 const { prompt, outputSchema } = buildThreadTitlePrompt({
441 message: input.message,
442 attachments: input.attachments,
443 });
444 const generated = yield* runOpenCodeJson({
445 operation: "generateThreadTitle",
446 cwd: input.cwd,
447 prompt,
448 outputSchemaJson: outputSchema,
449 modelSelection: input.modelSelection,
450 attachments: input.attachments,
451 });
452
453 return {
454 title: sanitizeThreadTitle(generated.title),
455 };
456 });
457
458 return {
459 generateCommitMessage,
460 generatePrContent,
461 generateBranchName,
462 generateThreadTitle,
463 } satisfies TextGenerationShape;
464});
apps/server/src/git/Layers/TextGenerationLive.test.ts deleted-117
......@@ -1,117 +0,0 @@
1import { it } from "@effect/vitest";
2import { Effect, PubSub, Result, Stream } from "effect";
3import { describe, expect } from "vitest";
4
5import { ProviderInstanceId } from "@t3tools/contracts";
6import { createModelSelection } from "@t3tools/shared/model";
7
8import type { ProviderInstance } from "../../provider/ProviderDriver.ts";
9import type { ProviderInstanceRegistryShape } from "../../provider/Services/ProviderInstanceRegistry.ts";
10import type { TextGenerationShape } from "../Services/TextGeneration.ts";
11
12import { makeTextGenerationFromRegistry } from "./TextGenerationLive.ts";
13
14const makeStubTextGeneration = (overrides: Partial<TextGenerationShape>): TextGenerationShape => ({
15 generateCommitMessage: () =>
16 Effect.die("generateCommitMessage stub not configured for this test"),
17 generatePrContent: () => Effect.die("generatePrContent stub not configured for this test"),
18 generateBranchName: () => Effect.die("generateBranchName stub not configured for this test"),
19 generateThreadTitle: () => Effect.die("generateThreadTitle stub not configured for this test"),
20 ...overrides,
21});
22
23const makeStubInstance = (
24 instanceId: ProviderInstanceId,
25 textGeneration: TextGenerationShape,
26): ProviderInstance =>
27 ({
28 instanceId,
29 driverKind: instanceId as unknown as ProviderInstance["driverKind"],
30 continuationIdentity: {
31 driverKind: instanceId as unknown as ProviderInstance["driverKind"],
32 continuationKey: `${instanceId}:test`,
33 },
34 displayName: undefined,
35 enabled: true,
36 snapshot: {} as ProviderInstance["snapshot"],
37 adapter: {} as ProviderInstance["adapter"],
38 textGeneration,
39 }) satisfies ProviderInstance;
40
41const makeStubRegistry = (
42 instances: ReadonlyArray<ProviderInstance>,
43): ProviderInstanceRegistryShape => {
44 const byId = new Map(instances.map((instance) => [instance.instanceId, instance] as const));
45 return {
46 getInstance: (id) => Effect.succeed(byId.get(id)),
47 listInstances: Effect.succeed(instances),
48 listUnavailable: Effect.succeed([]),
49 streamChanges: Stream.empty,
50 // Tests never drive changes through this stub; acquire a throwaway
51 // subscription on an unused PubSub so the shape is satisfied.
52 subscribeChanges: Effect.flatMap(PubSub.unbounded<void>(), (pubsub) =>
53 PubSub.subscribe(pubsub),
54 ),
55 };
56};
57
58describe("makeTextGenerationFromRegistry", () => {
59 it.effect("delegates to the matching instance's textGeneration closure", () =>
60 Effect.gen(function* () {
61 const personalId = ProviderInstanceId.make("codex_personal");
62 const personalCalls: string[] = [];
63 const personal = makeStubInstance(
64 personalId,
65 makeStubTextGeneration({
66 generateBranchName: (input) => {
67 personalCalls.push(input.message);
68 return Effect.succeed({ branch: "personal-branch" });
69 },
70 }),
71 );
72
73 const workId = ProviderInstanceId.make("codex_work");
74 const work = makeStubInstance(
75 workId,
76 makeStubTextGeneration({
77 generateBranchName: () => Effect.succeed({ branch: "work-branch" }),
78 }),
79 );
80
81 const tg = makeTextGenerationFromRegistry(makeStubRegistry([personal, work]));
82
83 const result = yield* tg.generateBranchName({
84 cwd: process.cwd(),
85 message: "Refactor the routing layer",
86 modelSelection: createModelSelection(ProviderInstanceId.make("codex_personal"), "gpt-5"),
87 });
88
89 expect(result.branch).toBe("personal-branch");
90 expect(personalCalls).toEqual(["Refactor the routing layer"]);
91 }),
92 );
93
94 it.effect("fails with TextGenerationError when the instance is unknown", () =>
95 Effect.gen(function* () {
96 const tg = makeTextGenerationFromRegistry(makeStubRegistry([]));
97
98 const result = yield* tg
99 .generateBranchName({
100 cwd: process.cwd(),
101 message: "anything",
102 modelSelection: createModelSelection(
103 ProviderInstanceId.make("missing_instance"),
104 "gpt-5",
105 ),
106 })
107 .pipe(Effect.result);
108
109 expect(Result.isFailure(result)).toBe(true);
110 if (Result.isFailure(result)) {
111 expect(result.failure._tag).toBe("TextGenerationError");
112 expect(result.failure.operation).toBe("generateBranchName");
113 expect(result.failure.detail).toContain("missing_instance");
114 }
115 }),
116 );
117});
apps/server/src/git/Layers/TextGenerationLive.ts deleted-101
......@@ -1,101 +0,0 @@
1/**
2 * TextGenerationLive — registry-backed implementation of the `TextGeneration`
3 * service tag.
4 *
5 * The `TextGeneration` tag is kept as a thin facade over
6 * `ProviderInstanceRegistry`. Every op pulls `modelSelection.instanceId`,
7 * looks up the matching `ProviderInstance`, and delegates to that instance's
8 * own `textGeneration` closure (built by its driver's `create()`).
9 *
10 * There is deliberately no per-driver dispatch here — the registry already
11 * knows which driver backs each instance, and each `ProviderInstance`
12 * carries the fully-bound `TextGenerationShape` produced by its driver.
13 * That means:
14 *
15 * - Multiple instances of the same driver (e.g. `codex_personal`,
16 * `codex_work`) each get their own text-generation closure bound to
17 * their own settings — the routing is by instance, not by driver.
18 * - Unknown or disabled instances surface a `TextGenerationError` with
19 * the missing `instanceId`, instead of silently falling back to a
20 * default.
21 *
22 * This replaces the old `RoutingTextGenerationLive`, which tried to route
23 * by driver-kind and misused `modelSelection.instanceId` as a driver-id
24 * literal.
25 *
26 * @module git/Layers/TextGenerationLive
27 */
28import { Effect, Layer } from "effect";
29
30import { TextGenerationError } from "@t3tools/contracts";
31import type { ProviderInstanceId } from "@t3tools/contracts";
32
33import {
34 ProviderInstanceRegistry,
35 type ProviderInstanceRegistryShape,
36} from "../../provider/Services/ProviderInstanceRegistry.ts";
37import type { ProviderInstance } from "../../provider/ProviderDriver.ts";
38import { TextGeneration, type TextGenerationShape } from "../Services/TextGeneration.ts";
39
40type TextGenerationOp =
41 | "generateCommitMessage"
42 | "generatePrContent"
43 | "generateBranchName"
44 | "generateThreadTitle";
45
46const resolveInstance = (
47 registry: ProviderInstanceRegistryShape,
48 operation: TextGenerationOp,
49 instanceId: ProviderInstanceId,
50): Effect.Effect<ProviderInstance["textGeneration"], TextGenerationError> =>
51 registry.getInstance(instanceId).pipe(
52 Effect.flatMap((instance) =>
53 instance
54 ? Effect.succeed(instance.textGeneration)
55 : Effect.fail(
56 new TextGenerationError({
57 operation,
58 detail: `No provider instance registered for id '${instanceId}'.`,
59 }),
60 ),
61 ),
62 );
63
64/**
65 * Build a `TextGenerationShape` that routes every call through the
66 * registry. Exposed separately from the Layer so tests can construct it
67 * against a stub registry without layering gymnastics.
68 */
69export const makeTextGenerationFromRegistry = (
70 registry: ProviderInstanceRegistryShape,
71): TextGenerationShape => ({
72 generateCommitMessage: (input) =>
73 resolveInstance(registry, "generateCommitMessage", input.modelSelection.instanceId).pipe(
74 Effect.flatMap((tg) => tg.generateCommitMessage(input)),
75 ),
76 generatePrContent: (input) =>
77 resolveInstance(registry, "generatePrContent", input.modelSelection.instanceId).pipe(
78 Effect.flatMap((tg) => tg.generatePrContent(input)),
79 ),
80 generateBranchName: (input) =>
81 resolveInstance(registry, "generateBranchName", input.modelSelection.instanceId).pipe(
82 Effect.flatMap((tg) => tg.generateBranchName(input)),
83 ),
84 generateThreadTitle: (input) =>
85 resolveInstance(registry, "generateThreadTitle", input.modelSelection.instanceId).pipe(
86 Effect.flatMap((tg) => tg.generateThreadTitle(input)),
87 ),
88});
89
90/**
91 * `TextGeneration` Layer wired to the `ProviderInstanceRegistry`. The rest
92 * of the server keeps using `yield* TextGeneration` — only the underlying
93 * wiring changed from kind-based routing to instance-based routing.
94 */
95export const TextGenerationLive = Layer.effect(
96 TextGeneration,
97 Effect.gen(function* () {
98 const registry = yield* ProviderInstanceRegistry;
99 return makeTextGenerationFromRegistry(registry);
100 }),
101);
apps/server/src/git/Prompts.test.ts deleted-193
......@@ -1,193 +0,0 @@
1import { describe, expect, it } from "vitest";
2
3import {
4 buildBranchNamePrompt,
5 buildCommitMessagePrompt,
6 buildPrContentPrompt,
7 buildThreadTitlePrompt,
8} from "./Prompts.ts";
9import { normalizeCliError, sanitizeThreadTitle } from "./Utils.ts";
10import { TextGenerationError } from "@t3tools/contracts";
11
12describe("buildCommitMessagePrompt", () => {
13 it("includes staged patch and summary in the prompt", () => {
14 const result = buildCommitMessagePrompt({
15 branch: "main",
16 stagedSummary: "M README.md",
17 stagedPatch: "diff --git a/README.md b/README.md\n+hello",
18 includeBranch: false,
19 });
20
21 expect(result.prompt).toContain("Staged files:");
22 expect(result.prompt).toContain("M README.md");
23 expect(result.prompt).toContain("Staged patch:");
24 expect(result.prompt).toContain("diff --git a/README.md b/README.md");
25 expect(result.prompt).toContain("Branch: main");
26 // Should NOT include the branch generation instruction
27 expect(result.prompt).not.toContain("branch must be a short semantic git branch fragment");
28 });
29
30 it("includes branch generation instruction when includeBranch is true", () => {
31 const result = buildCommitMessagePrompt({
32 branch: "feature/foo",
33 stagedSummary: "M README.md",
34 stagedPatch: "diff",
35 includeBranch: true,
36 });
37
38 expect(result.prompt).toContain("branch must be a short semantic git branch fragment");
39 expect(result.prompt).toContain("Return a JSON object with keys: subject, body, branch.");
40 });
41
42 it("shows (detached) when branch is null", () => {
43 const result = buildCommitMessagePrompt({
44 branch: null,
45 stagedSummary: "M a.ts",
46 stagedPatch: "diff",
47 includeBranch: false,
48 });
49
50 expect(result.prompt).toContain("Branch: (detached)");
51 });
52});
53
54describe("buildPrContentPrompt", () => {
55 it("includes branch names, commits, and diff in the prompt", () => {
56 const result = buildPrContentPrompt({
57 baseBranch: "main",
58 headBranch: "feature/auth",
59 commitSummary: "feat: add login page",
60 diffSummary: "3 files changed",
61 diffPatch: "diff --git a/auth.ts b/auth.ts\n+export function login()",
62 });
63
64 expect(result.prompt).toContain("Base branch: main");
65 expect(result.prompt).toContain("Head branch: feature/auth");
66 expect(result.prompt).toContain("Commits:");
67 expect(result.prompt).toContain("feat: add login page");
68 expect(result.prompt).toContain("Diff stat:");
69 expect(result.prompt).toContain("3 files changed");
70 expect(result.prompt).toContain("Diff patch:");
71 expect(result.prompt).toContain("export function login()");
72 });
73});
74
75describe("buildBranchNamePrompt", () => {
76 it("includes the user message in the prompt", () => {
77 const result = buildBranchNamePrompt({
78 message: "Fix the login timeout bug",
79 });
80
81 expect(result.prompt).toContain("User message:");
82 expect(result.prompt).toContain("Fix the login timeout bug");
83 expect(result.prompt).not.toContain("Attachment metadata:");
84 });
85
86 it("includes attachment metadata when attachments are provided", () => {
87 const result = buildBranchNamePrompt({
88 message: "Fix the layout from screenshot",
89 attachments: [
90 {
91 type: "image" as const,
92 id: "att-123",
93 name: "screenshot.png",
94 mimeType: "image/png",
95 sizeBytes: 12345,
96 },
97 ],
98 });
99
100 expect(result.prompt).toContain("Attachment metadata:");
101 expect(result.prompt).toContain("screenshot.png");
102 expect(result.prompt).toContain("image/png");
103 expect(result.prompt).toContain("12345 bytes");
104 });
105});
106
107describe("buildThreadTitlePrompt", () => {
108 it("includes the user message in the prompt", () => {
109 const result = buildThreadTitlePrompt({
110 message: "Investigate reconnect regressions after session restore",
111 });
112
113 expect(result.prompt).toContain("User message:");
114 expect(result.prompt).toContain("Investigate reconnect regressions after session restore");
115 expect(result.prompt).not.toContain("Attachment metadata:");
116 });
117
118 it("includes attachment metadata when attachments are provided", () => {
119 const result = buildThreadTitlePrompt({
120 message: "Name this thread from the screenshot",
121 attachments: [
122 {
123 type: "image" as const,
124 id: "att-456",
125 name: "thread.png",
126 mimeType: "image/png",
127 sizeBytes: 67890,
128 },
129 ],
130 });
131
132 expect(result.prompt).toContain("Attachment metadata:");
133 expect(result.prompt).toContain("thread.png");
134 expect(result.prompt).toContain("image/png");
135 expect(result.prompt).toContain("67890 bytes");
136 });
137});
138
139describe("sanitizeThreadTitle", () => {
140 it("truncates long titles with the shared sidebar-safe limit", () => {
141 expect(
142 sanitizeThreadTitle(
143 ' "Reconnect failures after restart because the session state does not recover" ',
144 ),
145 ).toBe("Reconnect failures after restart because the se...");
146 });
147});
148
149describe("normalizeCliError", () => {
150 it("detects 'Command not found' and includes CLI name in the message", () => {
151 const error = normalizeCliError(
152 "claude",
153 "generateCommitMessage",
154 new Error("Command not found: claude"),
155 "Something went wrong",
156 );
157
158 expect(error).toBeInstanceOf(TextGenerationError);
159 expect(error.detail).toContain("Claude CLI");
160 expect(error.detail).toContain("not available on PATH");
161 });
162
163 it("uses the CLI name from the first argument for codex", () => {
164 const error = normalizeCliError(
165 "codex",
166 "generateBranchName",
167 new Error("Command not found: codex"),
168 "Something went wrong",
169 );
170
171 expect(error).toBeInstanceOf(TextGenerationError);
172 expect(error.detail).toContain("Codex CLI");
173 expect(error.detail).toContain("not available on PATH");
174 });
175
176 it("returns the error as-is if it is already a TextGenerationError", () => {
177 const existing = new TextGenerationError({
178 operation: "generatePrContent",
179 detail: "Already wrapped",
180 });
181
182 const result = normalizeCliError("claude", "generatePrContent", existing, "fallback");
183
184 expect(result).toBe(existing);
185 });
186
187 it("wraps unknown non-Error values with the fallback message", () => {
188 const result = normalizeCliError("codex", "generateCommitMessage", "string error", "fallback");
189
190 expect(result).toBeInstanceOf(TextGenerationError);
191 expect(result.detail).toBe("fallback");
192 });
193});
apps/server/src/git/Prompts.ts deleted-202
......@@ -1,202 +0,0 @@
1/**
2 * Shared prompt builders for text generation providers.
3 *
4 * Extracts the prompt construction logic that is identical across
5 * Codex, Claude, and any future CLI-based text generation backends.
6 *
7 * @module textGenerationPrompts
8 */
9import { Schema } from "effect";
10import type { ChatAttachment } from "@t3tools/contracts";
11
12import { limitSection } from "./Utils.ts";
13
14// ---------------------------------------------------------------------------
15// Commit message
16// ---------------------------------------------------------------------------
17
18export interface CommitMessagePromptInput {
19 branch: string | null;
20 stagedSummary: string;
21 stagedPatch: string;
22 includeBranch: boolean;
23}
24
25export function buildCommitMessagePrompt(input: CommitMessagePromptInput) {
26 const wantsBranch = input.includeBranch;
27
28 const prompt = [
29 "You write concise git commit messages.",
30 wantsBranch
31 ? "Return a JSON object with keys: subject, body, branch."
32 : "Return a JSON object with keys: subject, body.",
33 "Rules:",
34 "- subject must be imperative, <= 72 chars, and no trailing period",
35 "- body can be empty string or short bullet points",
36 ...(wantsBranch
37 ? ["- branch must be a short semantic git branch fragment for this change"]
38 : []),
39 "- capture the primary user-visible or developer-visible change",
40 "",
41 `Branch: ${input.branch ?? "(detached)"}`,
42 "",
43 "Staged files:",
44 limitSection(input.stagedSummary, 6_000),
45 "",
46 "Staged patch:",
47 limitSection(input.stagedPatch, 40_000),
48 ].join("\n");
49
50 if (wantsBranch) {
51 return {
52 prompt,
53 outputSchema: Schema.Struct({
54 subject: Schema.String,
55 body: Schema.String,
56 branch: Schema.String,
57 }),
58 };
59 }
60
61 return {
62 prompt,
63 outputSchema: Schema.Struct({
64 subject: Schema.String,
65 body: Schema.String,
66 }),
67 };
68}
69
70// ---------------------------------------------------------------------------
71// PR content
72// ---------------------------------------------------------------------------
73
74export interface PrContentPromptInput {
75 baseBranch: string;
76 headBranch: string;
77 commitSummary: string;
78 diffSummary: string;
79 diffPatch: string;
80}
81
82export function buildPrContentPrompt(input: PrContentPromptInput) {
83 const prompt = [
84 "You write GitHub pull request content.",
85 "Return a JSON object with keys: title, body.",
86 "Rules:",
87 "- title should be concise and specific",
88 "- body must be markdown and include headings '## Summary' and '## Testing'",
89 "- under Summary, provide short bullet points",
90 "- under Testing, include bullet points with concrete checks or 'Not run' where appropriate",
91 "",
92 `Base branch: ${input.baseBranch}`,
93 `Head branch: ${input.headBranch}`,
94 "",
95 "Commits:",
96 limitSection(input.commitSummary, 12_000),
97 "",
98 "Diff stat:",
99 limitSection(input.diffSummary, 12_000),
100 "",
101 "Diff patch:",
102 limitSection(input.diffPatch, 40_000),
103 ].join("\n");
104
105 const outputSchema = Schema.Struct({
106 title: Schema.String,
107 body: Schema.String,
108 });
109
110 return { prompt, outputSchema };
111}
112
113// ---------------------------------------------------------------------------
114// Branch name
115// ---------------------------------------------------------------------------
116
117export interface BranchNamePromptInput {
118 message: string;
119 attachments?: ReadonlyArray<ChatAttachment> | undefined;
120}
121
122interface PromptFromMessageInput {
123 instruction: string;
124 responseShape: string;
125 rules: ReadonlyArray<string>;
126 message: string;
127 attachments?: ReadonlyArray<ChatAttachment> | undefined;
128}
129
130function buildPromptFromMessage(input: PromptFromMessageInput): string {
131 const attachmentLines = (input.attachments ?? []).map(
132 (attachment) => `- ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes)`,
133 );
134
135 const promptSections = [
136 input.instruction,
137 input.responseShape,
138 "Rules:",
139 ...input.rules.map((rule) => `- ${rule}`),
140 "",
141 "User message:",
142 limitSection(input.message, 8_000),
143 ];
144 if (attachmentLines.length > 0) {
145 promptSections.push(
146 "",
147 "Attachment metadata:",
148 limitSection(attachmentLines.join("\n"), 4_000),
149 );
150 }
151
152 return promptSections.join("\n");
153}
154
155export function buildBranchNamePrompt(input: BranchNamePromptInput) {
156 const prompt = buildPromptFromMessage({
157 instruction: "You generate concise git branch names.",
158 responseShape: "Return a JSON object with key: branch.",
159 rules: [
160 "Branch should describe the requested work from the user message.",
161 "Keep it short and specific (2-6 words).",
162 "Use plain words only, no issue prefixes and no punctuation-heavy text.",
163 "If images are attached, use them as primary context for visual/UI issues.",
164 ],
165 message: input.message,
166 attachments: input.attachments,
167 });
168 const outputSchema = Schema.Struct({
169 branch: Schema.String,
170 });
171
172 return { prompt, outputSchema };
173}
174
175// ---------------------------------------------------------------------------
176// Thread title
177// ---------------------------------------------------------------------------
178
179export interface ThreadTitlePromptInput {
180 message: string;
181 attachments?: ReadonlyArray<ChatAttachment> | undefined;
182}
183
184export function buildThreadTitlePrompt(input: ThreadTitlePromptInput) {
185 const prompt = buildPromptFromMessage({
186 instruction: "You write concise thread titles for coding conversations.",
187 responseShape: "Return a JSON object with key: title.",
188 rules: [
189 "Title should summarize the user's request, not restate it verbatim.",
190 "Keep it short and specific (3-8 words).",
191 "Avoid quotes, filler, prefixes, and trailing punctuation.",
192 "If images are attached, use them as primary context for visual/UI issues.",
193 ],
194 message: input.message,
195 attachments: input.attachments,
196 });
197 const outputSchema = Schema.Struct({
198 title: Schema.String,
199 });
200
201 return { prompt, outputSchema };
202}
apps/server/src/git/Services/GitCore.ts deleted-313
......@@ -1,313 +0,0 @@
1/**
2 * GitCore - Effect service contract for low-level Git operations.
3 *
4 * Wraps core repository primitives used by higher-level orchestration
5 * services and WebSocket routes.
6 *
7 * @module GitCore
8 */
9import { Context } from "effect";
10import type { Effect } from "effect";
11import type {
12 GitCheckoutInput,
13 GitCheckoutResult,
14 GitCreateBranchInput,
15 GitCreateBranchResult,
16 GitCreateWorktreeInput,
17 GitCreateWorktreeResult,
18 GitInitInput,
19 GitListBranchesInput,
20 GitListBranchesResult,
21 GitPullResult,
22 GitRemoveWorktreeInput,
23 GitStatusInput,
24 GitStatusResult,
25} from "@t3tools/contracts";
26
27import type { GitCommandError } from "@t3tools/contracts";
28
29export interface ExecuteGitInput {
30 readonly operation: string;
31 readonly cwd: string;
32 readonly args: ReadonlyArray<string>;
33 readonly stdin?: string;
34 readonly env?: NodeJS.ProcessEnv;
35 readonly allowNonZeroExit?: boolean;
36 readonly timeoutMs?: number;
37 readonly maxOutputBytes?: number;
38 readonly truncateOutputAtMaxBytes?: boolean;
39 readonly progress?: ExecuteGitProgress;
40}
41
42export interface ExecuteGitResult {
43 readonly code: number;
44 readonly stdout: string;
45 readonly stderr: string;
46 readonly stdoutTruncated: boolean;
47 readonly stderrTruncated: boolean;
48}
49
50export interface GitStatusDetails extends Omit<GitStatusResult, "pr"> {
51 upstreamRef: string | null;
52}
53
54export interface GitPreparedCommitContext {
55 stagedSummary: string;
56 stagedPatch: string;
57}
58
59export interface ExecuteGitProgress {
60 readonly onStdoutLine?: (line: string) => Effect.Effect<void, never>;
61 readonly onStderrLine?: (line: string) => Effect.Effect<void, never>;
62 readonly onHookStarted?: (hookName: string) => Effect.Effect<void, never>;
63 readonly onHookFinished?: (input: {
64 hookName: string;
65 exitCode: number | null;
66 durationMs: number | null;
67 }) => Effect.Effect<void, never>;
68}
69
70export interface GitCommitProgress {
71 readonly onOutputLine?: (input: {
72 stream: "stdout" | "stderr";
73 text: string;
74 }) => Effect.Effect<void, never>;
75 readonly onHookStarted?: (hookName: string) => Effect.Effect<void, never>;
76 readonly onHookFinished?: (input: {
77 hookName: string;
78 exitCode: number | null;
79 durationMs: number | null;
80 }) => Effect.Effect<void, never>;
81}
82
83export interface GitCommitOptions {
84 readonly timeoutMs?: number;
85 readonly progress?: GitCommitProgress;
86}
87
88export interface GitPushResult {
89 status: "pushed" | "skipped_up_to_date";
90 branch: string;
91 upstreamBranch?: string | undefined;
92 setUpstream?: boolean | undefined;
93}
94
95export interface GitRangeContext {
96 commitSummary: string;
97 diffSummary: string;
98 diffPatch: string;
99}
100
101export interface GitListWorkspaceFilesResult {
102 readonly paths: ReadonlyArray<string>;
103 readonly truncated: boolean;
104}
105
106export interface GitRenameBranchInput {
107 cwd: string;
108 oldBranch: string;
109 newBranch: string;
110}
111
112export interface GitRenameBranchResult {
113 branch: string;
114}
115
116export interface GitFetchPullRequestBranchInput {
117 cwd: string;
118 prNumber: number;
119 branch: string;
120}
121
122export interface GitEnsureRemoteInput {
123 cwd: string;
124 preferredName: string;
125 url: string;
126}
127
128export interface GitFetchRemoteBranchInput {
129 cwd: string;
130 remoteName: string;
131 remoteBranch: string;
132 localBranch: string;
133}
134
135export interface GitSetBranchUpstreamInput {
136 cwd: string;
137 branch: string;
138 remoteName: string;
139 remoteBranch: string;
140}
141
142/**
143 * GitCoreShape - Service API for low-level Git repository interactions.
144 */
145export interface GitCoreShape {
146 /**
147 * Execute a raw Git command.
148 */
149 readonly execute: (input: ExecuteGitInput) => Effect.Effect<ExecuteGitResult, GitCommandError>;
150
151 /**
152 * Read Git status for a repository.
153 */
154 readonly status: (input: GitStatusInput) => Effect.Effect<GitStatusResult, GitCommandError>;
155
156 /**
157 * Read detailed working tree / branch status for a repository.
158 */
159 readonly statusDetails: (cwd: string) => Effect.Effect<GitStatusDetails, GitCommandError>;
160
161 /**
162 * Read detailed working tree / branch status without refreshing remote tracking refs.
163 */
164 readonly statusDetailsLocal: (cwd: string) => Effect.Effect<GitStatusDetails, GitCommandError>;
165
166 /**
167 * Build staged change context for commit generation.
168 */
169 readonly prepareCommitContext: (
170 cwd: string,
171 filePaths?: readonly string[],
172 ) => Effect.Effect<GitPreparedCommitContext | null, GitCommandError>;
173
174 /**
175 * Create a commit with provided subject/body.
176 */
177 readonly commit: (
178 cwd: string,
179 subject: string,
180 body: string,
181 options?: GitCommitOptions,
182 ) => Effect.Effect<{ commitSha: string }, GitCommandError>;
183
184 /**
185 * Push current branch, setting upstream if needed.
186 */
187 readonly pushCurrentBranch: (
188 cwd: string,
189 fallbackBranch: string | null,
190 ) => Effect.Effect<GitPushResult, GitCommandError>;
191
192 /**
193 * Collect commit/diff context between base branch and current HEAD.
194 */
195 readonly readRangeContext: (
196 cwd: string,
197 baseBranch: string,
198 ) => Effect.Effect<GitRangeContext, GitCommandError>;
199
200 /**
201 * Read a Git config value from the local repository.
202 */
203 readonly readConfigValue: (
204 cwd: string,
205 key: string,
206 ) => Effect.Effect<string | null, GitCommandError>;
207
208 /**
209 * Determine whether the provided cwd is inside a git work tree.
210 */
211 readonly isInsideWorkTree: (cwd: string) => Effect.Effect<boolean, GitCommandError>;
212
213 /**
214 * List tracked and untracked workspace file paths relative to cwd.
215 */
216 readonly listWorkspaceFiles: (
217 cwd: string,
218 ) => Effect.Effect<GitListWorkspaceFilesResult, GitCommandError>;
219
220 /**
221 * Remove gitignored paths from a relative path list.
222 */
223 readonly filterIgnoredPaths: (
224 cwd: string,
225 relativePaths: ReadonlyArray<string>,
226 ) => Effect.Effect<ReadonlyArray<string>, GitCommandError>;
227
228 /**
229 * List local + remote branches and branch metadata.
230 */
231 readonly listBranches: (
232 input: GitListBranchesInput,
233 ) => Effect.Effect<GitListBranchesResult, GitCommandError>;
234
235 /**
236 * Pull current branch from upstream using fast-forward only.
237 */
238 readonly pullCurrentBranch: (cwd: string) => Effect.Effect<GitPullResult, GitCommandError>;
239
240 /**
241 * Create a worktree and branch from a base branch.
242 */
243 readonly createWorktree: (
244 input: GitCreateWorktreeInput,
245 ) => Effect.Effect<GitCreateWorktreeResult, GitCommandError>;
246
247 /**
248 * Materialize a GitHub pull request head as a local branch without switching checkout.
249 */
250 readonly fetchPullRequestBranch: (
251 input: GitFetchPullRequestBranchInput,
252 ) => Effect.Effect<void, GitCommandError>;
253
254 /**
255 * Ensure a named remote exists for the provided URL, returning the reused or created remote name.
256 */
257 readonly ensureRemote: (input: GitEnsureRemoteInput) => Effect.Effect<string, GitCommandError>;
258
259 /**
260 * Fetch a remote branch into a local branch without checkout.
261 */
262 readonly fetchRemoteBranch: (
263 input: GitFetchRemoteBranchInput,
264 ) => Effect.Effect<void, GitCommandError>;
265
266 /**
267 * Set the upstream tracking branch for a local branch.
268 */
269 readonly setBranchUpstream: (
270 input: GitSetBranchUpstreamInput,
271 ) => Effect.Effect<void, GitCommandError>;
272
273 /**
274 * Remove an existing worktree.
275 */
276 readonly removeWorktree: (input: GitRemoveWorktreeInput) => Effect.Effect<void, GitCommandError>;
277
278 /**
279 * Rename an existing local branch.
280 */
281 readonly renameBranch: (
282 input: GitRenameBranchInput,
283 ) => Effect.Effect<GitRenameBranchResult, GitCommandError>;
284
285 /**
286 * Create a local branch.
287 */
288 readonly createBranch: (
289 input: GitCreateBranchInput,
290 ) => Effect.Effect<GitCreateBranchResult, GitCommandError>;
291
292 /**
293 * Checkout an existing branch and refresh its upstream metadata in background.
294 */
295 readonly checkoutBranch: (
296 input: GitCheckoutInput,
297 ) => Effect.Effect<GitCheckoutResult, GitCommandError>;
298
299 /**
300 * Initialize a repository in the provided directory.
301 */
302 readonly initRepo: (input: GitInitInput) => Effect.Effect<void, GitCommandError>;
303
304 /**
305 * List local branch names (short format).
306 */
307 readonly listLocalBranchNames: (cwd: string) => Effect.Effect<string[], GitCommandError>;
308}
309
310/**
311 * GitCore - Service tag for low-level Git repository operations.
312 */
313export class GitCore extends Context.Service<GitCore, GitCoreShape>()("t3/git/Services/GitCore") {}
apps/server/src/git/Services/GitHubCli.ts deleted-103
......@@ -1,103 +0,0 @@
1/**
2 * GitHubCli - Effect service contract for `gh` process interactions.
3 *
4 * Provides thin command execution helpers used by Git workflow orchestration.
5 *
6 * @module GitHubCli
7 */
8import { Context } from "effect";
9import type { Effect } from "effect";
10
11import type { ProcessRunResult } from "../../processRunner.ts";
12import type { GitHubCliError } from "@t3tools/contracts";
13
14export interface GitHubPullRequestSummary {
15 readonly number: number;
16 readonly title: string;
17 readonly url: string;
18 readonly baseRefName: string;
19 readonly headRefName: string;
20 readonly state?: "open" | "closed" | "merged";
21 readonly isCrossRepository?: boolean;
22 readonly headRepositoryNameWithOwner?: string | null;
23 readonly headRepositoryOwnerLogin?: string | null;
24}
25
26export interface GitHubRepositoryCloneUrls {
27 readonly nameWithOwner: string;
28 readonly url: string;
29 readonly sshUrl: string;
30}
31
32/**
33 * GitHubCliShape - Service API for executing GitHub CLI commands.
34 */
35export interface GitHubCliShape {
36 /**
37 * Execute a GitHub CLI command and return full process output.
38 */
39 readonly execute: (input: {
40 readonly cwd: string;
41 readonly args: ReadonlyArray<string>;
42 readonly timeoutMs?: number;
43 }) => Effect.Effect<ProcessRunResult, GitHubCliError>;
44
45 /**
46 * List open pull requests for a head branch.
47 */
48 readonly listOpenPullRequests: (input: {
49 readonly cwd: string;
50 readonly headSelector: string;
51 readonly limit?: number;
52 }) => Effect.Effect<ReadonlyArray<GitHubPullRequestSummary>, GitHubCliError>;
53
54 /**
55 * Resolve a pull request by URL, number, or branch-ish identifier.
56 */
57 readonly getPullRequest: (input: {
58 readonly cwd: string;
59 readonly reference: string;
60 }) => Effect.Effect<GitHubPullRequestSummary, GitHubCliError>;
61
62 /**
63 * Resolve clone URLs for a GitHub repository.
64 */
65 readonly getRepositoryCloneUrls: (input: {
66 readonly cwd: string;
67 readonly repository: string;
68 }) => Effect.Effect<GitHubRepositoryCloneUrls, GitHubCliError>;
69
70 /**
71 * Create a pull request from branch context and body file.
72 */
73 readonly createPullRequest: (input: {
74 readonly cwd: string;
75 readonly baseBranch: string;
76 readonly headSelector: string;
77 readonly title: string;
78 readonly bodyFile: string;
79 }) => Effect.Effect<void, GitHubCliError>;
80
81 /**
82 * Resolve repository default branch through GitHub metadata.
83 */
84 readonly getDefaultBranch: (input: {
85 readonly cwd: string;
86 }) => Effect.Effect<string | null, GitHubCliError>;
87
88 /**
89 * Checkout a pull request into the current repository worktree.
90 */
91 readonly checkoutPullRequest: (input: {
92 readonly cwd: string;
93 readonly reference: string;
94 readonly force?: boolean;
95 }) => Effect.Effect<void, GitHubCliError>;
96}
97
98/**
99 * GitHubCli - Service tag for GitHub CLI process execution.
100 */
101export class GitHubCli extends Context.Service<GitHubCli, GitHubCliShape>()(
102 "t3/git/Services/GitHubCli",
103) {}
apps/server/src/git/Services/GitManager.ts deleted-104
......@@ -1,104 +0,0 @@
1/**
2 * GitManager - Effect service contract for stacked Git workflows.
3 *
4 * Orchestrates status inspection and commit/push/PR flows by composing
5 * lower-level Git and external tool services.
6 *
7 * @module GitManager
8 */
9import {
10 GitActionProgressEvent,
11 GitPreparePullRequestThreadInput,
12 GitPreparePullRequestThreadResult,
13 GitPullRequestRefInput,
14 GitResolvePullRequestResult,
15 GitRunStackedActionInput,
16 GitRunStackedActionResult,
17 GitStatusLocalResult,
18 GitStatusRemoteResult,
19 GitStatusInput,
20 GitStatusResult,
21} from "@t3tools/contracts";
22import { Context } from "effect";
23import type { Effect } from "effect";
24import type { GitManagerServiceError } from "@t3tools/contracts";
25
26export interface GitActionProgressReporter {
27 readonly publish: (event: GitActionProgressEvent) => Effect.Effect<void, never>;
28}
29
30export interface GitRunStackedActionOptions {
31 readonly actionId?: string;
32 readonly progressReporter?: GitActionProgressReporter;
33}
34
35/**
36 * GitManagerShape - Service API for high-level Git workflow actions.
37 */
38export interface GitManagerShape {
39 /**
40 * Read current repository Git status plus open PR metadata when available.
41 */
42 readonly status: (
43 input: GitStatusInput,
44 ) => Effect.Effect<GitStatusResult, GitManagerServiceError>;
45
46 /**
47 * Read local repository status without remote hosting enrichment.
48 */
49 readonly localStatus: (
50 input: GitStatusInput,
51 ) => Effect.Effect<GitStatusLocalResult, GitManagerServiceError>;
52
53 /**
54 * Read remote tracking / PR status for a repository.
55 */
56 readonly remoteStatus: (
57 input: GitStatusInput,
58 ) => Effect.Effect<GitStatusRemoteResult | null, GitManagerServiceError>;
59
60 /**
61 * Clear any cached local status snapshot for a repository.
62 */
63 readonly invalidateLocalStatus: (cwd: string) => Effect.Effect<void, never>;
64
65 /**
66 * Clear any cached remote status snapshot for a repository.
67 */
68 readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect<void, never>;
69
70 /**
71 * Clear any cached status snapshot for a repository so the next read is fresh.
72 */
73 readonly invalidateStatus: (cwd: string) => Effect.Effect<void, never>;
74
75 /**
76 * Resolve a pull request by URL/number against the current repository.
77 */
78 readonly resolvePullRequest: (
79 input: GitPullRequestRefInput,
80 ) => Effect.Effect<GitResolvePullRequestResult, GitManagerServiceError>;
81
82 /**
83 * Prepare a new thread workspace from a pull request in local or worktree mode.
84 */
85 readonly preparePullRequestThread: (
86 input: GitPreparePullRequestThreadInput,
87 ) => Effect.Effect<GitPreparePullRequestThreadResult, GitManagerServiceError>;
88
89 /**
90 * Run a Git action (`commit`, `push`, `create_pr`, `commit_push`, `commit_push_pr`).
91 * When `featureBranch` is set, creates and checks out a feature branch first.
92 */
93 readonly runStackedAction: (
94 input: GitRunStackedActionInput,
95 options?: GitRunStackedActionOptions,
96 ) => Effect.Effect<GitRunStackedActionResult, GitManagerServiceError>;
97}
98
99/**
100 * GitManager - Service tag for stacked Git workflow orchestration.
101 */
102export class GitManager extends Context.Service<GitManager, GitManagerShape>()(
103 "t3/git/Services/GitManager",
104) {}
apps/server/src/git/Services/GitStatusBroadcaster.ts deleted-27
......@@ -1,27 +0,0 @@
1import { Context } from "effect";
2import type { Effect, Stream } from "effect";
3import type {
4 GitManagerServiceError,
5 GitStatusInput,
6 GitStatusLocalResult,
7 GitStatusResult,
8 GitStatusStreamEvent,
9} from "@t3tools/contracts";
10
11export interface GitStatusBroadcasterShape {
12 readonly getStatus: (
13 input: GitStatusInput,
14 ) => Effect.Effect<GitStatusResult, GitManagerServiceError>;
15 readonly refreshLocalStatus: (
16 cwd: string,
17 ) => Effect.Effect<GitStatusLocalResult, GitManagerServiceError>;
18 readonly refreshStatus: (cwd: string) => Effect.Effect<GitStatusResult, GitManagerServiceError>;
19 readonly streamStatus: (
20 input: GitStatusInput,
21 ) => Stream.Stream<GitStatusStreamEvent, GitManagerServiceError>;
22}
23
24export class GitStatusBroadcaster extends Context.Service<
25 GitStatusBroadcaster,
26 GitStatusBroadcasterShape
27>()("t3/git/Services/GitStatusBroadcaster") {}
apps/server/src/git/Services/TextGeneration.ts deleted-123
......@@ -1,123 +0,0 @@
1/**
2 * TextGeneration - Effect service contract for AI-generated Git content.
3 *
4 * Generates commit messages and pull request titles/bodies from repository
5 * context prepared by Git services.
6 *
7 * @module TextGeneration
8 */
9import { Context } from "effect";
10import type { Effect } from "effect";
11import type { ChatAttachment, ModelSelection } from "@t3tools/contracts";
12
13import type { TextGenerationError } from "@t3tools/contracts";
14
15/** Providers that support git text generation (commit messages, PR content, branch names). */
16export type TextGenerationProvider = "codex" | "claudeAgent" | "cursor" | "opencode";
17
18export interface CommitMessageGenerationInput {
19 cwd: string;
20 branch: string | null;
21 stagedSummary: string;
22 stagedPatch: string;
23 /** When true, the model also returns a semantic branch name for the change. */
24 includeBranch?: boolean;
25 /** What model and provider to use for generation. */
26 modelSelection: ModelSelection;
27}
28
29export interface CommitMessageGenerationResult {
30 subject: string;
31 body: string;
32 /** Only present when `includeBranch` was set on the input. */
33 branch?: string | undefined;
34}
35
36export interface PrContentGenerationInput {
37 cwd: string;
38 baseBranch: string;
39 headBranch: string;
40 commitSummary: string;
41 diffSummary: string;
42 diffPatch: string;
43 /** What model and provider to use for generation. */
44 modelSelection: ModelSelection;
45}
46
47export interface PrContentGenerationResult {
48 title: string;
49 body: string;
50}
51
52export interface BranchNameGenerationInput {
53 cwd: string;
54 message: string;
55 attachments?: ReadonlyArray<ChatAttachment> | undefined;
56 /** What model and provider to use for generation. */
57 modelSelection: ModelSelection;
58}
59
60export interface BranchNameGenerationResult {
61 branch: string;
62}
63
64export interface ThreadTitleGenerationInput {
65 cwd: string;
66 message: string;
67 attachments?: ReadonlyArray<ChatAttachment> | undefined;
68 /** What model and provider to use for generation. */
69 modelSelection: ModelSelection;
70}
71
72export interface ThreadTitleGenerationResult {
73 title: string;
74}
75
76export interface TextGenerationService {
77 generateCommitMessage(
78 input: CommitMessageGenerationInput,
79 ): Promise<CommitMessageGenerationResult>;
80 generatePrContent(input: PrContentGenerationInput): Promise<PrContentGenerationResult>;
81 generateBranchName(input: BranchNameGenerationInput): Promise<BranchNameGenerationResult>;
82 generateThreadTitle(input: ThreadTitleGenerationInput): Promise<ThreadTitleGenerationResult>;
83}
84
85/**
86 * TextGenerationShape - Service API for commit/PR text generation.
87 */
88export interface TextGenerationShape {
89 /**
90 * Generate a commit message from staged change context.
91 */
92 readonly generateCommitMessage: (
93 input: CommitMessageGenerationInput,
94 ) => Effect.Effect<CommitMessageGenerationResult, TextGenerationError>;
95
96 /**
97 * Generate pull request title/body from branch and diff context.
98 */
99 readonly generatePrContent: (
100 input: PrContentGenerationInput,
101 ) => Effect.Effect<PrContentGenerationResult, TextGenerationError>;
102
103 /**
104 * Generate a concise branch name from a user message.
105 */
106 readonly generateBranchName: (
107 input: BranchNameGenerationInput,
108 ) => Effect.Effect<BranchNameGenerationResult, TextGenerationError>;
109
110 /**
111 * Generate a concise thread title from a user's first message.
112 */
113 readonly generateThreadTitle: (
114 input: ThreadTitleGenerationInput,
115 ) => Effect.Effect<ThreadTitleGenerationResult, TextGenerationError>;
116}
117
118/**
119 * TextGeneration - Service tag for commit and PR text generation.
120 */
121export class TextGeneration extends Context.Service<TextGeneration, TextGenerationShape>()(
122 "t3/git/Services/TextGeneration",
123) {}
apps/server/src/git/Utils.ts-165
......@@ -1,171 +1,6 @@
1/**
2 * Shared utilities for text generation layers (Codex, Claude, etc.).
3 *
4 * @module textGenerationUtils
5 */
6import { Schema } from "effect";
7
8import { TextGenerationError } from "@t3tools/contracts";
9
101import { existsSync } from "node:fs";
112import { join } from "node:path";
123
134export function isGitRepository(cwd: string): boolean {
145 return existsSync(join(cwd, ".git"));
156}
16
17/** Convert an Effect Schema to a flat JSON Schema object, inlining `$defs` when present. */
18export function toJsonSchemaObject(schema: Schema.Top): unknown {
19 const document = Schema.toJsonSchemaDocument(schema);
20 if (document.definitions && Object.keys(document.definitions).length > 0) {
21 return { ...document.schema, $defs: document.definitions };
22 }
23 return document.schema;
24}
25
26/** Truncate a text section to `maxChars`, appending a `[truncated]` marker when needed. */
27export function limitSection(value: string, maxChars: number): string {
28 if (value.length <= maxChars) return value;
29 const truncated = value.slice(0, maxChars);
30 return `${truncated}\n\n[truncated]`;
31}
32
33export function extractJsonObject(raw: string): string {
34 const trimmed = raw.trim();
35 if (trimmed.length === 0) {
36 return trimmed;
37 }
38
39 const start = trimmed.indexOf("{");
40 if (start < 0) {
41 return trimmed;
42 }
43
44 let depth = 0;
45 let inString = false;
46 let escaping = false;
47 for (let index = start; index < trimmed.length; index += 1) {
48 const char = trimmed[index];
49 if (inString) {
50 if (escaping) {
51 escaping = false;
52 } else if (char === "\\") {
53 escaping = true;
54 } else if (char === '"') {
55 inString = false;
56 }
57 continue;
58 }
59
60 if (char === '"') {
61 inString = true;
62 continue;
63 }
64
65 if (char === "{") {
66 depth += 1;
67 continue;
68 }
69
70 if (char === "}") {
71 depth -= 1;
72 if (depth === 0) {
73 return trimmed.slice(start, index + 1);
74 }
75 }
76 }
77
78 return trimmed.slice(start);
79}
80
81/** Normalise a raw commit subject to imperative-mood, ≤72 chars, no trailing period. */
82export function sanitizeCommitSubject(raw: string): string {
83 const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? "";
84 const withoutTrailingPeriod = singleLine.replace(/[.]+$/g, "").trim();
85 if (withoutTrailingPeriod.length === 0) {
86 return "Update project files";
87 }
88
89 if (withoutTrailingPeriod.length <= 72) {
90 return withoutTrailingPeriod;
91 }
92 return withoutTrailingPeriod.slice(0, 72).trimEnd();
93}
94
95/** Normalise a raw PR title to a single line with a sensible fallback. */
96export function sanitizePrTitle(raw: string): string {
97 const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? "";
98 if (singleLine.length > 0) {
99 return singleLine;
100 }
101 return "Update project changes";
102}
103
104/** Normalise a raw thread title to a compact single-line sidebar-safe label. */
105export function sanitizeThreadTitle(raw: string): string {
106 const normalized = raw
107 .trim()
108 .split(/\r?\n/g)[0]
109 ?.trim()
110 .replace(/^['"`]+|['"`]+$/g, "")
111 .trim()
112 .replace(/\s+/g, " ");
113
114 if (!normalized || normalized.trim().length === 0) {
115 return "New thread";
116 }
117
118 if (normalized.length <= 50) {
119 return normalized;
120 }
121
122 return `${normalized.slice(0, 47).trimEnd()}...`;
123}
124
125/** CLI name to human-readable label, e.g. "codex" → "Codex CLI (`codex`)" */
126function cliLabel(cliName: string): string {
127 const capitalized = cliName.charAt(0).toUpperCase() + cliName.slice(1);
128 return `${capitalized} CLI (\`${cliName}\`)`;
129}
130
131/**
132 * Normalize an unknown error from a CLI text generation process into a
133 * typed `TextGenerationError`. Parameterized by CLI name so both Codex
134 * and Claude (and future providers) can share the same logic.
135 */
136export function normalizeCliError(
137 cliName: string,
138 operation: string,
139 error: unknown,
140 fallback: string,
141): TextGenerationError {
142 if (Schema.is(TextGenerationError)(error)) {
143 return error;
144 }
145
146 if (error instanceof Error) {
147 const lower = error.message.toLowerCase();
148 if (
149 error.message.includes(`Command not found: ${cliName}`) ||
150 lower.includes(`spawn ${cliName}`) ||
151 lower.includes("enoent")
152 ) {
153 return new TextGenerationError({
154 operation,
155 detail: `${cliLabel(cliName)} is required but not available on PATH.`,
156 cause: error,
157 });
158 }
159 return new TextGenerationError({
160 operation,
161 detail: `${fallback}: ${error.message}`,
162 cause: error,
163 });
164 }
165
166 return new TextGenerationError({
167 operation,
168 detail: fallback,
169 cause: error,
170 });
171}
apps/server/src/git/githubPullRequests.ts+4-5
......@@ -1,4 +1,4 @@
1import { Cause, Exit, Result, Schema } from "effect";
1import { Cause, DateTime, Exit, Option, Result, Schema } from "effect";
22import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts";
33import { decodeJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson";
44
......@@ -9,7 +9,7 @@ export interface NormalizedGitHubPullRequestRecord {
99 readonly baseRefName: string;
1010 readonly headRefName: string;
1111 readonly state: "open" | "closed" | "merged";
12 readonly updatedAt: string | null;
12 readonly updatedAt: Option.Option<DateTime.Utc>;
1313 readonly isCrossRepository?: boolean;
1414 readonly headRepositoryNameWithOwner?: string | null;
1515 readonly headRepositoryOwnerLogin?: string | null;
......@@ -23,7 +23,7 @@ const GitHubPullRequestSchema = Schema.Struct({
2323 headRefName: TrimmedNonEmptyString,
2424 state: Schema.optional(Schema.NullOr(Schema.String)),
2525 mergedAt: Schema.optional(Schema.NullOr(Schema.String)),
26 updatedAt: Schema.optional(Schema.NullOr(Schema.String)),
26 updatedAt: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)),
2727 isCrossRepository: Schema.optional(Schema.Boolean),
2828 headRepository: Schema.optional(
2929 Schema.NullOr(
......@@ -80,8 +80,7 @@ function normalizeGitHubPullRequestRecord(
8080 baseRefName: raw.baseRefName,
8181 headRefName: raw.headRefName,
8282 state: normalizeGitHubPullRequestState(raw),
83 updatedAt:
84 typeof raw.updatedAt === "string" && raw.updatedAt.trim().length > 0 ? raw.updatedAt : null,
83 updatedAt: raw.updatedAt ?? Option.none(),
8584 ...(typeof raw.isCrossRepository === "boolean"
8685 ? { isCrossRepository: raw.isCrossRepository }
8786 : {}),
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+16-10
......@@ -24,8 +24,9 @@ import { afterEach, describe, expect, it, vi } from "vitest";
2424
2525import { CheckpointStoreLive } from "../../checkpointing/Layers/CheckpointStore.ts";
2626import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts";
27import { GitCoreLive } from "../../git/Layers/GitCore.ts";
28import { GitStatusBroadcaster } from "../../git/Services/GitStatusBroadcaster.ts";
27import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts";
28import * as VcsProcess from "../../vcs/VcsProcess.ts";
29import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts";
2930import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts";
3031import { CheckpointReactorLive } from "./CheckpointReactor.ts";
3132import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
......@@ -282,7 +283,7 @@ describe("CheckpointReactor", () => {
282283 const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), {
283284 prefix: "t3-checkpoint-reactor-test-",
284285 });
285 const gitStatusBroadcasterLayer = Layer.succeed(GitStatusBroadcaster, {
286 const vcsStatusBroadcasterLayer = Layer.succeed(VcsStatusBroadcaster, {
286287 getStatus: () => Effect.die("getStatus should not be called in this test"),
287288 refreshLocalStatus: (cwd: string) =>
288289 Effect.sync(() => {
......@@ -290,9 +291,9 @@ describe("CheckpointReactor", () => {
290291 }).pipe(
291292 Effect.as({
292293 isRepo: true,
293 hasOriginRemote: false,
294 isDefaultBranch: true,
295 branch: "main",
294 hasPrimaryRemote: false,
295 isDefaultRef: true,
296 refName: "main",
296297 hasWorkingTreeChanges: false,
297298 workingTree: { files: [], insertions: 0, deletions: 0 },
298299 }),
......@@ -305,11 +306,16 @@ describe("CheckpointReactor", () => {
305306 Layer.provideMerge(orchestrationLayer),
306307 Layer.provideMerge(RuntimeReceiptBusLive),
307308 Layer.provideMerge(Layer.succeed(ProviderService, provider.service)),
308 Layer.provideMerge(gitStatusBroadcasterLayer),
309 Layer.provideMerge(CheckpointStoreLive),
310 Layer.provideMerge(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))),
309 Layer.provideMerge(vcsStatusBroadcasterLayer),
310 Layer.provideMerge(CheckpointStoreLive.pipe(Layer.provide(VcsDriverRegistry.layer))),
311 Layer.provideMerge(
312 WorkspaceEntriesLive.pipe(
313 Layer.provide(WorkspacePathsLive),
314 Layer.provideMerge(VcsDriverRegistry.layer),
315 ),
316 ),
311317 Layer.provideMerge(WorkspacePathsLive),
312 Layer.provideMerge(GitCoreLive),
318 Layer.provideMerge(VcsProcess.layer),
313319 Layer.provideMerge(ServerConfigLayer),
314320 Layer.provideMerge(NodeServices.layer),
315321 );
apps/server/src/orchestration/Layers/CheckpointReactor.ts+3-3
......@@ -24,7 +24,7 @@ import { RuntimeReceiptBus } from "../Services/RuntimeReceiptBus.ts";
2424import type { CheckpointStoreError } from "../../checkpointing/Errors.ts";
2525import type { OrchestrationDispatchError } from "../Errors.ts";
2626import { isGitRepository } from "../../git/Utils.ts";
27import { GitStatusBroadcaster } from "../../git/Services/GitStatusBroadcaster.ts";
27import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts";
2828import { WorkspaceEntries } from "../../workspace/Services/WorkspaceEntries.ts";
2929
3030type ReactorInput =
......@@ -70,7 +70,7 @@ const make = Effect.gen(function* () {
7070 const checkpointStore = yield* CheckpointStore;
7171 const receiptBus = yield* RuntimeReceiptBus;
7272 const workspaceEntries = yield* WorkspaceEntries;
73 const gitStatusBroadcaster = yield* GitStatusBroadcaster;
73 const vcsStatusBroadcaster = yield* VcsStatusBroadcaster;
7474
7575 const appendRevertFailureActivity = (input: {
7676 readonly threadId: ThreadId;
......@@ -508,7 +508,7 @@ const make = Effect.gen(function* () {
508508 return;
509509 }
510510
511 yield* gitStatusBroadcaster.refreshLocalStatus(sessionRuntime.value.cwd).pipe(
511 yield* vcsStatusBroadcaster.refreshLocalStatus(sessionRuntime.value.cwd).pipe(
512512 Effect.catch((error) =>
513513 Effect.logWarning("failed to refresh local git status after turn completion", {
514514 threadId: event.threadId,
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+13-12
......@@ -33,12 +33,7 @@ import {
3333 ProviderService,
3434 type ProviderServiceShape,
3535} from "../../provider/Services/ProviderService.ts";
36import { GitCore, type GitCoreShape } from "../../git/Services/GitCore.ts";
37import {
38 GitStatusBroadcaster,
39 type GitStatusBroadcasterShape,
40} from "../../git/Services/GitStatusBroadcaster.ts";
41import { TextGeneration, type TextGenerationShape } from "../../git/Services/TextGeneration.ts";
36import { TextGeneration, type TextGenerationShape } from "../../textGeneration/TextGeneration.ts";
4237import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts";
4338import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
4439import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
......@@ -52,6 +47,8 @@ import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
5247import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts";
5348import * as NodeServices from "@effect/platform-node/NodeServices";
5449import { ServerSettingsService } from "../../serverSettings.ts";
50import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts";
51import { GitWorkflowService, type GitWorkflowServiceShape } from "../../git/GitWorkflowService.ts";
5552
5653const asProjectId = (value: string): ProjectId => ProjectId.make(value);
5754const asApprovalRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.make(value);
......@@ -243,9 +240,9 @@ describe("ProviderCommandReactor", () => {
243240 const refreshStatus = vi.fn((_: string) =>
244241 Effect.succeed({
245242 isRepo: true,
246 hasOriginRemote: true,
247 isDefaultBranch: false,
248 branch: "renamed-branch",
243 hasPrimaryRemote: true,
244 isDefaultRef: false,
245 refName: "renamed-branch",
249246 hasWorkingTreeChanges: false,
250247 workingTree: {
251248 files: [],
......@@ -324,15 +321,19 @@ describe("ProviderCommandReactor", () => {
324321 const layer = ProviderCommandReactorLive.pipe(
325322 Layer.provideMerge(orchestrationLayer),
326323 Layer.provideMerge(Layer.succeed(ProviderService, service)),
327 Layer.provideMerge(Layer.succeed(GitCore, { renameBranch } as unknown as GitCoreShape)),
328324 Layer.provideMerge(
329 Layer.succeed(GitStatusBroadcaster, {
325 Layer.mock(GitWorkflowService)({
326 renameBranch,
327 } satisfies Partial<GitWorkflowServiceShape>),
328 ),
329 Layer.provideMerge(
330 Layer.succeed(VcsStatusBroadcaster, {
330331 getStatus: () => Effect.die("getStatus should not be called in this test"),
331332 refreshLocalStatus: () =>
332333 Effect.die("refreshLocalStatus should not be called in this test"),
333334 refreshStatus,
334335 streamStatus: () => Stream.die("streamStatus should not be called in this test"),
335 } satisfies GitStatusBroadcasterShape),
336 }),
336337 ),
337338 Layer.provideMerge(
338339 Layer.mock(TextGeneration, {
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+7-7
......@@ -16,12 +16,10 @@ import { Cache, Cause, Duration, Effect, Equal, Layer, Option, Schema, Stream }
1616import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";
1717
1818import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts";
19import { GitCore } from "../../git/Services/GitCore.ts";
20import { GitStatusBroadcaster } from "../../git/Services/GitStatusBroadcaster.ts";
2119import { increment, orchestrationEventsProcessedTotal } from "../../observability/Metrics.ts";
2220import { ProviderAdapterRequestError } from "../../provider/Errors.ts";
2321import type { ProviderServiceError } from "../../provider/Errors.ts";
24import { TextGeneration } from "../../git/Services/TextGeneration.ts";
22import { TextGeneration } from "../../textGeneration/TextGeneration.ts";
2523import { ProviderService } from "../../provider/Services/ProviderService.ts";
2624import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
2725import {
......@@ -29,6 +27,8 @@ import {
2927 type ProviderCommandReactorShape,
3028} from "../Services/ProviderCommandReactor.ts";
3129import { ServerSettingsService } from "../../serverSettings.ts";
30import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts";
31import { GitWorkflowService } from "../../git/GitWorkflowService.ts";
3232
3333type ProviderIntentEvent = Extract<
3434 OrchestrationEvent,
......@@ -168,8 +168,8 @@ function buildGeneratedWorktreeBranchName(raw: string): string {
168168const make = Effect.gen(function* () {
169169 const orchestrationEngine = yield* OrchestrationEngineService;
170170 const providerService = yield* ProviderService;
171 const git = yield* GitCore;
172 const gitStatusBroadcaster = yield* GitStatusBroadcaster;
171 const gitWorkflow = yield* GitWorkflowService;
172 const vcsStatusBroadcaster = yield* VcsStatusBroadcaster;
173173 const textGeneration = yield* TextGeneration;
174174 const serverSettingsService = yield* ServerSettingsService;
175175 const handledTurnStartKeys = yield* Cache.make<string, true>({
......@@ -587,7 +587,7 @@ const make = Effect.gen(function* () {
587587 const targetBranch = buildGeneratedWorktreeBranchName(generated.branch);
588588 if (targetBranch === oldBranch) return;
589589
590 const renamed = yield* git.renameBranch({ cwd, oldBranch, newBranch: targetBranch });
590 const renamed = yield* gitWorkflow.renameBranch({ cwd, oldBranch, newBranch: targetBranch });
591591 yield* orchestrationEngine.dispatch({
592592 type: "thread.meta.update",
593593 commandId: serverCommandId("worktree-branch-rename"),
......@@ -595,7 +595,7 @@ const make = Effect.gen(function* () {
595595 branch: renamed.branch,
596596 worktreePath: cwd,
597597 });
598 yield* gitStatusBroadcaster.refreshStatus(cwd).pipe(Effect.ignoreCause({ log: true }));
598 yield* vcsStatusBroadcaster.refreshStatus(cwd).pipe(Effect.ignoreCause({ log: true }));
599599 }).pipe(
600600 Effect.catchCause((cause) =>
601601 Effect.logWarning("provider command reactor failed to generate or rename worktree branch", {
apps/server/src/project/Layers/RepositoryIdentityResolver.ts+6-3
......@@ -1,6 +1,9 @@
11import type { RepositoryIdentity } from "@t3tools/contracts";
22import { Cache, Duration, Effect, Exit, Layer } from "effect";
3import { detectGitHostingProviderFromRemoteUrl, normalizeGitRemoteUrl } from "@t3tools/shared/git";
3import {
4 detectSourceControlProviderFromGitRemoteUrl,
5 normalizeGitRemoteUrl,
6} from "@t3tools/shared/git";
47
58import { runProcess } from "../../processRunner.ts";
69import {
......@@ -45,7 +48,7 @@ function buildRepositoryIdentity(input: {
4548 readonly rootPath: string;
4649}): RepositoryIdentity {
4750 const canonicalKey = normalizeGitRemoteUrl(input.remoteUrl);
48 const hostingProvider = detectGitHostingProviderFromRemoteUrl(input.remoteUrl);
51 const sourceControlProvider = detectSourceControlProviderFromGitRemoteUrl(input.remoteUrl);
4952 const repositoryPath = canonicalKey.split("/").slice(1).join("/");
5053 const repositoryPathSegments = repositoryPath.split("/").filter((segment) => segment.length > 0);
5154 const [owner] = repositoryPathSegments;
......@@ -60,7 +63,7 @@ function buildRepositoryIdentity(input: {
6063 },
6164 rootPath: input.rootPath,
6265 ...(repositoryPath ? { displayName: repositoryPath } : {}),
63 ...(hostingProvider ? { provider: hostingProvider.kind } : {}),
66 ...(sourceControlProvider ? { provider: sourceControlProvider.kind } : {}),
6467 ...(owner ? { owner } : {}),
6568 ...(repositoryName ? { name: repositoryName } : {}),
6669 };
apps/server/src/provider/Drivers/ClaudeDriver.ts+1-1
......@@ -16,7 +16,7 @@ import { ClaudeSettings, ProviderDriverKind, type ServerProvider } from "@t3tool
1616import { Cache, Duration, Effect, FileSystem, Path, Schema, Stream } from "effect";
1717import { ChildProcessSpawner } from "effect/unstable/process";
1818
19import { makeClaudeTextGeneration } from "../../git/Layers/ClaudeTextGeneration.ts";
19import { makeClaudeTextGeneration } from "../../textGeneration/ClaudeTextGeneration.ts";
2020import { ServerConfig } from "../../config.ts";
2121import { ProviderDriverError } from "../Errors.ts";
2222import { makeClaudeAdapter } from "../Layers/ClaudeAdapter.ts";
apps/server/src/provider/Drivers/CodexDriver.ts+1-1
......@@ -25,7 +25,7 @@ import { CodexSettings, ProviderDriverKind, type ServerProvider } from "@t3tools
2525import { Duration, Effect, FileSystem, Path, Schema, Stream } from "effect";
2626import { ChildProcessSpawner } from "effect/unstable/process";
2727
28import { makeCodexTextGeneration } from "../../git/Layers/CodexTextGeneration.ts";
28import { makeCodexTextGeneration } from "../../textGeneration/CodexTextGeneration.ts";
2929import { ServerConfig } from "../../config.ts";
3030import { ProviderDriverError } from "../Errors.ts";
3131import { makeCodexAdapter } from "../Layers/CodexAdapter.ts";
apps/server/src/provider/Drivers/CursorDriver.ts+1-1
......@@ -17,7 +17,7 @@ import { Duration, Effect, FileSystem, Path, Schema, Stream } from "effect";
1717import { ChildProcessSpawner } from "effect/unstable/process";
1818
1919import { ServerConfig } from "../../config.ts";
20import { makeCursorTextGeneration } from "../../git/Layers/CursorTextGeneration.ts";
20import { makeCursorTextGeneration } from "../../textGeneration/CursorTextGeneration.ts";
2121import { ProviderDriverError } from "../Errors.ts";
2222import { makeCursorAdapter } from "../Layers/CursorAdapter.ts";
2323import {
apps/server/src/provider/Drivers/OpenCodeDriver.ts+1-1
......@@ -16,7 +16,7 @@ import { OpenCodeSettings, ProviderDriverKind, type ServerProvider } from "@t3to
1616import { Duration, Effect, FileSystem, Path, Schema, Stream } from "effect";
1717import { ChildProcessSpawner } from "effect/unstable/process";
1818
19import { makeOpenCodeTextGeneration } from "../../git/Layers/OpenCodeTextGeneration.ts";
19import { makeOpenCodeTextGeneration } from "../../textGeneration/OpenCodeTextGeneration.ts";
2020import { ServerConfig } from "../../config.ts";
2121import { ProviderDriverError } from "../Errors.ts";
2222import { makeOpenCodeAdapter } from "../Layers/OpenCodeAdapter.ts";
apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts+1-1
......@@ -14,7 +14,7 @@ import type { OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts";
1414import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts";
1515import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts";
1616import type { ProviderInstance } from "../ProviderDriver.ts";
17import type { TextGenerationShape } from "../../git/Services/TextGeneration.ts";
17import type { TextGenerationShape } from "../../textGeneration/TextGeneration.ts";
1818import { ProviderAdapterRegistryLive } from "./ProviderAdapterRegistry.ts";
1919import * as NodeServices from "@effect/platform-node/NodeServices";
2020
apps/server/src/provider/ProviderDriver.ts+1-1
......@@ -28,7 +28,7 @@ import type {
2828} from "@t3tools/contracts";
2929import type { Effect, Schema, Scope } from "effect";
3030
31import type { TextGenerationShape } from "../git/Services/TextGeneration.ts";
31import type { TextGenerationShape } from "../textGeneration/TextGeneration.ts";
3232import type { ProviderAdapterError, ProviderDriverError } from "./Errors.ts";
3333import type { ProviderAdapterShape } from "./Services/ProviderAdapter.ts";
3434import type { ServerProviderShape } from "./Services/ServerProvider.ts";
apps/server/src/server.test.ts+242-125
......@@ -29,6 +29,7 @@ import { assert, it } from "@effect/vitest";
2929import { assertFailure, assertInclude, assertTrue } from "@effect/vitest/utils";
3030import {
3131 Deferred,
32 DateTime,
3233 Duration,
3334 Effect,
3435 FileSystem,
......@@ -38,6 +39,7 @@ import {
3839 Path,
3940 Stream,
4041} from "effect";
42import { ChildProcessSpawner } from "effect/unstable/process";
4143import {
4244 FetchHttpClient,
4345 HttpBody,
......@@ -50,6 +52,8 @@ import { RpcClient, RpcSerialization } from "effect/unstable/rpc";
5052import * as Socket from "effect/unstable/socket/Socket";
5153import { vi } from "vitest";
5254
55const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z");
56
5357import type { ServerConfigShape } from "./config.ts";
5458import { deriveServerPaths, ServerConfig } from "./config.ts";
5559import { makeRoutesLayer } from "./server.ts";
......@@ -58,13 +62,7 @@ import {
5862 CheckpointDiffQuery,
5963 type CheckpointDiffQueryShape,
6064} from "./checkpointing/Services/CheckpointDiffQuery.ts";
61import { GitCore, type GitCoreShape } from "./git/Services/GitCore.ts";
62import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts";
63import { GitStatusBroadcasterLive } from "./git/Layers/GitStatusBroadcaster.ts";
64import {
65 GitStatusBroadcaster,
66 type GitStatusBroadcasterShape,
67} from "./git/Services/GitStatusBroadcaster.ts";
65import { GitManager, type GitManagerShape } from "./git/GitManager.ts";
6866import { Keybindings, type KeybindingsShape } from "./keybindings.ts";
6967import { Open, type OpenShape } from "./open.ts";
7068import {
......@@ -105,6 +103,20 @@ import {
105103import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries.ts";
106104import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts";
107105import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts";
106import * as GitVcsDriver from "./vcs/GitVcsDriver.ts";
107import type { VcsDriverShape } from "./vcs/VcsDriver.ts";
108import {
109 VcsStatusBroadcaster,
110 type VcsStatusBroadcasterShape,
111 layer as VcsStatusBroadcasterLayer,
112} from "./vcs/VcsStatusBroadcaster.ts";
113import {
114 VcsDriverRegistry,
115 type VcsDriverRegistryShape,
116 type VcsDriverHandle,
117} from "./vcs/VcsDriverRegistry.ts";
118import { layer as VcsProvisioningServiceLayer } from "./vcs/VcsProvisioningService.ts";
119import { layer as GitWorkflowServiceLayer } from "./git/GitWorkflowService.ts";
108120import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts";
109121import { ServerAuthLive } from "./auth/Layers/ServerAuth.ts";
110122
......@@ -195,16 +207,6 @@ const makeDefaultOrchestrationThreadShell = (
195207 };
196208};
197209
198const workspaceAndProjectServicesLayer = Layer.mergeAll(
199 WorkspacePathsLive,
200 WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive)),
201 WorkspaceFileSystemLive.pipe(
202 Layer.provide(WorkspacePathsLive),
203 Layer.provide(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))),
204 ),
205 ProjectFaviconResolverLive,
206);
207
208210const browserOtlpTracingLayer = Layer.mergeAll(
209211 FetchHttpClient.layer,
210212 OtlpSerialization.layerJson,
......@@ -324,9 +326,11 @@ const buildAppUnderTest = (options?: {
324326 providerRegistry?: Partial<ProviderRegistryShape>;
325327 serverSettings?: Partial<ServerSettingsShape>;
326328 open?: Partial<OpenShape>;
327 gitCore?: Partial<GitCoreShape>;
329 vcsDriver?: Partial<VcsDriverShape>;
330 vcsDriverRegistry?: Partial<VcsDriverRegistryShape>;
331 gitVcsDriver?: Partial<GitVcsDriver.GitVcsDriverShape>;
328332 gitManager?: Partial<GitManagerShape>;
329 gitStatusBroadcaster?: Partial<GitStatusBroadcasterShape>;
333 vcsStatusBroadcaster?: Partial<VcsStatusBroadcasterShape>;
330334 projectSetupScriptRunner?: Partial<ProjectSetupScriptRunnerShape>;
331335 terminalManager?: Partial<TerminalManagerShape>;
332336 orchestrationEngine?: Partial<OrchestrationEngineShape>;
......@@ -372,22 +376,110 @@ const buildAppUnderTest = (options?: {
372376 ...options?.config,
373377 };
374378 const layerConfig = Layer.succeed(ServerConfig, config);
375 const gitCoreLayer = Layer.mock(GitCore)({
379 const defaultVcsDriver: VcsDriverShape = {
380 capabilities: {
381 kind: "git",
382 supportsWorktrees: true,
383 supportsBookmarks: false,
384 supportsAtomicSnapshot: false,
385 supportsPushDefaultRemote: true,
386 ignoreClassifier: "native",
387 },
388 execute: () =>
389 Effect.succeed({
390 exitCode: ChildProcessSpawner.ExitCode(0),
391 stdout: "",
392 stderr: "",
393 stdoutTruncated: false,
394 stderrTruncated: false,
395 }),
396 detectRepository: () => Effect.succeed(null),
376397 isInsideWorkTree: () => Effect.succeed(false),
377398 listWorkspaceFiles: () =>
378399 Effect.succeed({
379400 paths: [],
380401 truncated: false,
402 freshness: {
403 source: "live-local",
404 observedAt: TEST_EPOCH,
405 expiresAt: Option.none(),
406 },
407 }),
408 listRemotes: () =>
409 Effect.succeed({
410 remotes: [],
411 freshness: {
412 source: "live-local",
413 observedAt: TEST_EPOCH,
414 expiresAt: Option.none(),
415 },
381416 }),
382417 filterIgnoredPaths: (_cwd, relativePaths) => Effect.succeed(relativePaths),
383 ...options?.layers?.gitCore,
418 initRepository: () => Effect.void,
419 ...options?.layers?.vcsDriver,
420 };
421 const vcsDriverRegistryLayer = Layer.mock(VcsDriverRegistry)({
422 get: () => Effect.succeed(defaultVcsDriver),
423 detect: (input) =>
424 defaultVcsDriver.detectRepository(input.cwd).pipe(
425 Effect.flatMap((repository) =>
426 repository
427 ? Effect.succeed(repository)
428 : defaultVcsDriver.isInsideWorkTree(input.cwd).pipe(
429 Effect.map((isInsideWorkTree) =>
430 isInsideWorkTree
431 ? {
432 kind: "git" as const,
433 rootPath: input.cwd,
434 metadataPath: null,
435 freshness: {
436 source: "live-local" as const,
437 observedAt: TEST_EPOCH,
438 expiresAt: Option.none(),
439 },
440 }
441 : null,
442 ),
443 ),
444 ),
445 Effect.map((repository) =>
446 repository
447 ? ({
448 kind: repository.kind,
449 repository,
450 driver: defaultVcsDriver,
451 } satisfies VcsDriverHandle)
452 : null,
453 ),
454 ),
455 resolve: (input) =>
456 Effect.succeed({
457 kind:
458 input.requestedKind === "auto" || !input.requestedKind ? "git" : input.requestedKind,
459 repository: {
460 kind:
461 input.requestedKind === "auto" || !input.requestedKind ? "git" : input.requestedKind,
462 rootPath: input.cwd,
463 metadataPath: null,
464 freshness: {
465 source: "live-local",
466 observedAt: TEST_EPOCH,
467 expiresAt: Option.none(),
468 },
469 },
470 driver: defaultVcsDriver,
471 }),
472 ...options?.layers?.vcsDriverRegistry,
473 });
474 const gitVcsDriverLayer = Layer.mock(GitVcsDriver.GitVcsDriver)({
475 ...options?.layers?.gitVcsDriver,
384476 });
385477 const gitManagerLayer = Layer.mock(GitManager)({
386478 ...options?.layers?.gitManager,
387479 });
388480 const workspaceEntriesLayer = WorkspaceEntriesLive.pipe(
389481 Layer.provide(WorkspacePathsLive),
390 Layer.provideMerge(gitCoreLayer),
482 Layer.provideMerge(vcsDriverRegistryLayer),
391483 );
392484 const workspaceAndProjectServicesLayer = Layer.mergeAll(
393485 WorkspacePathsLive,
......@@ -398,11 +490,19 @@ const buildAppUnderTest = (options?: {
398490 ),
399491 ProjectFaviconResolverLive,
400492 );
401 const gitStatusBroadcasterLayer = options?.layers?.gitStatusBroadcaster
402 ? Layer.mock(GitStatusBroadcaster)({
403 ...options.layers.gitStatusBroadcaster,
493 const gitWorkflowLayer = GitWorkflowServiceLayer.pipe(
494 Layer.provideMerge(vcsDriverRegistryLayer),
495 Layer.provideMerge(gitVcsDriverLayer),
496 Layer.provideMerge(gitManagerLayer),
497 );
498 const vcsProvisioningLayer = VcsProvisioningServiceLayer.pipe(
499 Layer.provide(vcsDriverRegistryLayer),
500 );
501 const vcsStatusBroadcasterLayer = options?.layers?.vcsStatusBroadcaster
502 ? Layer.mock(VcsStatusBroadcaster)({
503 ...options.layers.vcsStatusBroadcaster,
404504 })
405 : GitStatusBroadcasterLive.pipe(Layer.provide(gitManagerLayer));
505 : VcsStatusBroadcasterLayer.pipe(Layer.provide(gitWorkflowLayer));
406506
407507 const servedRoutesLayer = HttpRouter.serve(makeRoutesLayer, {
408508 disableListenLog: true,
......@@ -441,9 +541,11 @@ const buildAppUnderTest = (options?: {
441541 ...options?.layers?.open,
442542 }),
443543 ),
444 Layer.provide(gitCoreLayer),
445544 Layer.provide(gitManagerLayer),
446 Layer.provideMerge(gitStatusBroadcasterLayer),
545 Layer.provide(gitVcsDriverLayer),
546 Layer.provide(gitWorkflowLayer),
547 Layer.provide(vcsProvisioningLayer),
548 Layer.provideMerge(vcsStatusBroadcasterLayer),
447549 Layer.provide(
448550 Layer.mock(ProjectSetupScriptRunner)({
449551 runForThread: () => Effect.succeed({ status: "no-script" as const }),
......@@ -2068,12 +2170,17 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
20682170
20692171 yield* buildAppUnderTest({
20702172 layers: {
2071 gitCore: {
2173 vcsDriver: {
20722174 isInsideWorkTree: () => Effect.succeed(true),
20732175 listWorkspaceFiles: () =>
20742176 Effect.succeed({
20752177 paths: ["src/tracked.ts"],
20762178 truncated: false,
2179 freshness: {
2180 source: "live-local",
2181 observedAt: TEST_EPOCH,
2182 expiresAt: Option.none(),
2183 },
20772184 }),
20782185 filterIgnoredPaths: (_cwd, relativePaths) =>
20792186 Effect.succeed(
......@@ -2273,9 +2380,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
22732380 localStatus: () =>
22742381 Effect.succeed({
22752382 isRepo: true,
2276 hasOriginRemote: true,
2277 isDefaultBranch: true,
2278 branch: "main",
2383 hasPrimaryRemote: true,
2384 isDefaultRef: true,
2385 refName: "main",
22792386 hasWorkingTreeChanges: false,
22802387 workingTree: { files: [], insertions: 0, deletions: 0 },
22812388 }),
......@@ -2289,9 +2396,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
22892396 status: () =>
22902397 Effect.succeed({
22912398 isRepo: true,
2292 hasOriginRemote: true,
2293 isDefaultBranch: true,
2294 branch: "main",
2399 hasPrimaryRemote: true,
2400 isDefaultRef: true,
2401 refName: "main",
22952402 hasWorkingTreeChanges: false,
22962403 workingTree: { files: [], insertions: 0, deletions: 0 },
22972404 hasUpstream: true,
......@@ -2372,16 +2479,16 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
23722479 worktreePath: null,
23732480 }),
23742481 },
2375 gitCore: {
2482 gitVcsDriver: {
23762483 pullCurrentBranch: () =>
23772484 Effect.succeed({
23782485 status: "pulled",
2379 branch: "main",
2380 upstreamBranch: "origin/main",
2486 refName: "main",
2487 upstreamRef: "origin/main",
23812488 }),
2382 listBranches: () =>
2489 listRefs: () =>
23832490 Effect.succeed({
2384 branches: [
2491 refs: [
23852492 {
23862493 name: "main",
23872494 current: true,
......@@ -2390,18 +2497,20 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
23902497 },
23912498 ],
23922499 isRepo: true,
2393 hasOriginRemote: true,
2500 hasPrimaryRemote: true,
23942501 nextCursor: null,
23952502 totalCount: 1,
23962503 }),
23972504 createWorktree: () =>
23982505 Effect.succeed({
2399 worktree: { path: "/tmp/wt", branch: "feature/demo" },
2506 worktree: { path: "/tmp/wt", refName: "feature/demo" },
24002507 }),
24012508 removeWorktree: () => Effect.void,
2402 createBranch: (input) => Effect.succeed({ branch: input.branch }),
2403 checkoutBranch: (input) => Effect.succeed({ branch: input.branch }),
2404 initRepo: () => Effect.void,
2509 createRef: (input) => Effect.succeed({ refName: input.refName }),
2510 switchRef: (input) => Effect.succeed({ refName: input.refName }),
2511 },
2512 vcsDriver: {
2513 isInsideWorkTree: () => Effect.succeed(true),
24052514 },
24062515 },
24072516 });
......@@ -2409,13 +2518,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
24092518 const wsUrl = yield* getWsServerUrl("/ws");
24102519
24112520 const pull = yield* Effect.scoped(
2412 withWsRpcClient(wsUrl, (client) => client[WS_METHODS.gitPull]({ cwd: "/tmp/repo" })),
2521 withWsRpcClient(wsUrl, (client) => client[WS_METHODS.vcsPull]({ cwd: "/tmp/repo" })),
24132522 );
24142523 assert.equal(pull.status, "pulled");
24152524
24162525 const refreshedStatus = yield* Effect.scoped(
24172526 withWsRpcClient(wsUrl, (client) =>
2418 client[WS_METHODS.gitRefreshStatus]({ cwd: "/tmp/repo" }),
2527 client[WS_METHODS.vcsRefreshStatus]({ cwd: "/tmp/repo" }),
24192528 ),
24202529 );
24212530 assert.equal(refreshedStatus.isRepo, true);
......@@ -2459,27 +2568,25 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
24592568 );
24602569 assert.equal(prepared.branch, "feature/demo");
24612570
2462 const branches = yield* Effect.scoped(
2463 withWsRpcClient(wsUrl, (client) =>
2464 client[WS_METHODS.gitListBranches]({ cwd: "/tmp/repo" }),
2465 ),
2571 const refs = yield* Effect.scoped(
2572 withWsRpcClient(wsUrl, (client) => client[WS_METHODS.vcsListRefs]({ cwd: "/tmp/repo" })),
24662573 );
2467 assert.equal(branches.branches[0]?.name, "main");
2574 assert.equal(refs.refs[0]?.name, "main");
24682575
24692576 const worktree = yield* Effect.scoped(
24702577 withWsRpcClient(wsUrl, (client) =>
2471 client[WS_METHODS.gitCreateWorktree]({
2578 client[WS_METHODS.vcsCreateWorktree]({
24722579 cwd: "/tmp/repo",
2473 branch: "main",
2580 refName: "main",
24742581 path: null,
24752582 }),
24762583 ),
24772584 );
2478 assert.equal(worktree.worktree.branch, "feature/demo");
2585 assert.equal(worktree.worktree.refName, "feature/demo");
24792586
24802587 yield* Effect.scoped(
24812588 withWsRpcClient(wsUrl, (client) =>
2482 client[WS_METHODS.gitRemoveWorktree]({
2589 client[WS_METHODS.vcsRemoveWorktree]({
24832590 cwd: "/tmp/repo",
24842591 path: "/tmp/wt",
24852592 }),
......@@ -2488,25 +2595,25 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
24882595
24892596 yield* Effect.scoped(
24902597 withWsRpcClient(wsUrl, (client) =>
2491 client[WS_METHODS.gitCreateBranch]({
2598 client[WS_METHODS.vcsCreateRef]({
24922599 cwd: "/tmp/repo",
2493 branch: "feature/new",
2600 refName: "feature/new",
24942601 }),
24952602 ),
24962603 );
24972604
24982605 yield* Effect.scoped(
24992606 withWsRpcClient(wsUrl, (client) =>
2500 client[WS_METHODS.gitCheckout]({
2607 client[WS_METHODS.vcsSwitchRef]({
25012608 cwd: "/tmp/repo",
2502 branch: "main",
2609 refName: "main",
25032610 }),
25042611 ),
25052612 );
25062613
25072614 yield* Effect.scoped(
25082615 withWsRpcClient(wsUrl, (client) =>
2509 client[WS_METHODS.gitInit]({
2616 client[WS_METHODS.vcsInit]({
25102617 cwd: "/tmp/repo",
25112618 }),
25122619 ),
......@@ -2526,7 +2633,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
25262633 let statusCalls = 0;
25272634 yield* buildAppUnderTest({
25282635 layers: {
2529 gitCore: {
2636 gitVcsDriver: {
25302637 pullCurrentBranch: () => Effect.fail(gitError),
25312638 },
25322639 gitManager: {
......@@ -2545,9 +2652,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
25452652 localStatus: () =>
25462653 Effect.succeed({
25472654 isRepo: true,
2548 hasOriginRemote: true,
2549 isDefaultBranch: true,
2550 branch: "main",
2655 hasPrimaryRemote: true,
2656 isDefaultRef: true,
2657 refName: "main",
25512658 hasWorkingTreeChanges: true,
25522659 workingTree: { files: [], insertions: 0, deletions: 0 },
25532660 }),
......@@ -2566,9 +2673,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
25662673 statusCalls += 1;
25672674 return {
25682675 isRepo: true,
2569 hasOriginRemote: true,
2570 isDefaultBranch: true,
2571 branch: "main",
2676 hasPrimaryRemote: true,
2677 isDefaultRef: true,
2678 refName: "main",
25722679 hasWorkingTreeChanges: true,
25732680 workingTree: { files: [], insertions: 0, deletions: 0 },
25742681 hasUpstream: true,
......@@ -2583,7 +2690,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
25832690
25842691 const wsUrl = yield* getWsServerUrl("/ws");
25852692 const result = yield* Effect.scoped(
2586 withWsRpcClient(wsUrl, (client) => client[WS_METHODS.gitPull]({ cwd: "/tmp/repo" })).pipe(
2693 withWsRpcClient(wsUrl, (client) => client[WS_METHODS.vcsPull]({ cwd: "/tmp/repo" })).pipe(
25872694 Effect.result,
25882695 ),
25892696 );
......@@ -2622,9 +2729,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
26222729 localStatus: () =>
26232730 Effect.succeed({
26242731 isRepo: true,
2625 hasOriginRemote: true,
2626 isDefaultBranch: false,
2627 branch: "feature/demo",
2732 hasPrimaryRemote: true,
2733 isDefaultRef: false,
2734 refName: "feature/demo",
26282735 hasWorkingTreeChanges: true,
26292736 workingTree: { files: [], insertions: 0, deletions: 0 },
26302737 }),
......@@ -2643,9 +2750,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
26432750 statusCalls += 1;
26442751 return {
26452752 isRepo: true,
2646 hasOriginRemote: true,
2647 isDefaultBranch: false,
2648 branch: "feature/demo",
2753 hasPrimaryRemote: true,
2754 isDefaultRef: false,
2755 refName: "feature/demo",
26492756 hasWorkingTreeChanges: true,
26502757 workingTree: { files: [], insertions: 0, deletions: 0 },
26512758 hasUpstream: true,
......@@ -2680,12 +2787,12 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
26802787 Effect.gen(function* () {
26812788 yield* buildAppUnderTest({
26822789 layers: {
2683 gitCore: {
2790 gitVcsDriver: {
26842791 pullCurrentBranch: () =>
26852792 Effect.succeed({
26862793 status: "pulled" as const,
2687 branch: "main",
2688 upstreamBranch: "origin/main",
2794 refName: "main",
2795 upstreamRef: "origin/main",
26892796 }),
26902797 },
26912798 gitManager: {
......@@ -2694,9 +2801,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
26942801 localStatus: () =>
26952802 Effect.succeed({
26962803 isRepo: true,
2697 hasOriginRemote: true,
2698 isDefaultBranch: true,
2699 branch: "main",
2804 hasPrimaryRemote: true,
2805 isDefaultRef: true,
2806 refName: "main",
27002807 hasWorkingTreeChanges: false,
27012808 workingTree: { files: [], insertions: 0, deletions: 0 },
27022809 }),
......@@ -2716,7 +2823,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
27162823 const wsUrl = yield* getWsServerUrl("/ws");
27172824 const startedAt = Date.now();
27182825 const result = yield* Effect.scoped(
2719 withWsRpcClient(wsUrl, (client) => client[WS_METHODS.gitPull]({ cwd: "/tmp/repo" })),
2826 withWsRpcClient(wsUrl, (client) => client[WS_METHODS.vcsPull]({ cwd: "/tmp/repo" })),
27202827 );
27212828 const elapsedMs = Date.now() - startedAt;
27222829
......@@ -2731,15 +2838,18 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
27312838 Effect.gen(function* () {
27322839 yield* buildAppUnderTest({
27332840 layers: {
2841 vcsDriver: {
2842 isInsideWorkTree: () => Effect.succeed(true),
2843 },
27342844 gitManager: {
27352845 invalidateLocalStatus: () => Effect.void,
27362846 invalidateRemoteStatus: () => Effect.void,
27372847 localStatus: () =>
27382848 Effect.succeed({
27392849 isRepo: true,
2740 hasOriginRemote: true,
2741 isDefaultBranch: false,
2742 branch: "feature/demo",
2850 hasPrimaryRemote: true,
2851 isDefaultRef: false,
2852 refName: "feature/demo",
27432853 hasWorkingTreeChanges: false,
27442854 workingTree: { files: [], insertions: 0, deletions: 0 },
27452855 }),
......@@ -2804,6 +2914,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
28042914
28052915 yield* buildAppUnderTest({
28062916 layers: {
2917 vcsDriver: {
2918 isInsideWorkTree: () => Effect.succeed(true),
2919 },
28072920 gitManager: {
28082921 invalidateLocalStatus: () => Effect.void,
28092922 invalidateRemoteStatus: () => Effect.void,
......@@ -2813,9 +2926,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
28132926 Effect.andThen(
28142927 Effect.succeed({
28152928 isRepo: true,
2816 hasOriginRemote: true,
2817 isDefaultBranch: false,
2818 branch: "feature/demo",
2929 hasPrimaryRemote: true,
2930 isDefaultRef: false,
2931 refName: "feature/demo",
28192932 hasWorkingTreeChanges: false,
28202933 workingTree: { files: [], insertions: 0, deletions: 0 },
28212934 }),
......@@ -3479,9 +3592,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
34793592 const refreshStatus = vi.fn((_: string) =>
34803593 Effect.succeed({
34813594 isRepo: true,
3482 hasOriginRemote: true,
3483 isDefaultBranch: false,
3484 branch: "t3code/bootstrap-branch",
3595 hasPrimaryRemote: true,
3596 isDefaultRef: false,
3597 refName: "t3code/bootstrap-refName",
34853598 hasWorkingTreeChanges: false,
34863599 workingTree: {
34873600 files: [],
......@@ -3494,13 +3607,14 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
34943607 pr: null,
34953608 }),
34963609 );
3497 const createWorktree = vi.fn((_: Parameters<GitCoreShape["createWorktree"]>[0]) =>
3498 Effect.succeed({
3499 worktree: {
3500 branch: "t3code/bootstrap-branch",
3501 path: "/tmp/bootstrap-worktree",
3502 },
3503 }),
3610 const createWorktree = vi.fn(
3611 (_: Parameters<GitVcsDriver.GitVcsDriverShape["createWorktree"]>[0]) =>
3612 Effect.succeed({
3613 worktree: {
3614 refName: "t3code/bootstrap-refName",
3615 path: "/tmp/bootstrap-worktree",
3616 },
3617 }),
35043618 );
35053619 const runForThread = vi.fn(
35063620 (_: Parameters<ProjectSetupScriptRunnerShape["runForThread"]>[0]) =>
......@@ -3515,10 +3629,10 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
35153629
35163630 yield* buildAppUnderTest({
35173631 layers: {
3518 gitCore: {
3632 gitVcsDriver: {
35193633 createWorktree,
35203634 },
3521 gitStatusBroadcaster: {
3635 vcsStatusBroadcaster: {
35223636 refreshStatus,
35233637 },
35243638 orchestrationEngine: {
......@@ -3566,7 +3680,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
35663680 prepareWorktree: {
35673681 projectCwd: "/tmp/project",
35683682 baseBranch: "main",
3569 branch: "t3code/bootstrap-branch",
3683 branch: "t3code/bootstrap-refName",
35703684 },
35713685 runSetupScript: true,
35723686 },
......@@ -3588,8 +3702,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
35883702 );
35893703 assert.deepEqual(createWorktree.mock.calls[0]?.[0], {
35903704 cwd: "/tmp/project",
3591 branch: "main",
3592 newBranch: "t3code/bootstrap-branch",
3705 refName: "main",
3706 newRefName: "t3code/bootstrap-refName",
35933707 path: null,
35943708 });
35953709 assert.deepEqual(runForThread.mock.calls[0]?.[0], {
......@@ -3619,13 +3733,14 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
36193733 it.effect("records setup-script failures without aborting bootstrap turn start", () =>
36203734 Effect.gen(function* () {
36213735 const dispatchedCommands: Array<OrchestrationCommand> = [];
3622 const createWorktree = vi.fn((_: Parameters<GitCoreShape["createWorktree"]>[0]) =>
3623 Effect.succeed({
3624 worktree: {
3625 branch: "t3code/bootstrap-branch",
3626 path: "/tmp/bootstrap-worktree",
3627 },
3628 }),
3736 const createWorktree = vi.fn(
3737 (_: Parameters<GitVcsDriver.GitVcsDriverShape["createWorktree"]>[0]) =>
3738 Effect.succeed({
3739 worktree: {
3740 refName: "t3code/bootstrap-refName",
3741 path: "/tmp/bootstrap-worktree",
3742 },
3743 }),
36293744 );
36303745 const runForThread = vi.fn(
36313746 (_: Parameters<ProjectSetupScriptRunnerShape["runForThread"]>[0]) =>
......@@ -3634,7 +3749,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
36343749
36353750 yield* buildAppUnderTest({
36363751 layers: {
3637 gitCore: {
3752 gitVcsDriver: {
36383753 createWorktree,
36393754 },
36403755 orchestrationEngine: {
......@@ -3682,7 +3797,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
36823797 prepareWorktree: {
36833798 projectCwd: "/tmp/project",
36843799 baseBranch: "main",
3685 branch: "t3code/bootstrap-branch",
3800 branch: "t3code/bootstrap-refName",
36863801 },
36873802 runSetupScript: true,
36883803 },
......@@ -3712,13 +3827,14 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
37123827 it.effect("does not misattribute setup activity dispatch failures as setup launch failures", () =>
37133828 Effect.gen(function* () {
37143829 const dispatchedCommands: Array<OrchestrationCommand> = [];
3715 const createWorktree = vi.fn((_: Parameters<GitCoreShape["createWorktree"]>[0]) =>
3716 Effect.succeed({
3717 worktree: {
3718 branch: "t3code/bootstrap-branch",
3719 path: "/tmp/bootstrap-worktree",
3720 },
3721 }),
3830 const createWorktree = vi.fn(
3831 (_: Parameters<GitVcsDriver.GitVcsDriverShape["createWorktree"]>[0]) =>
3832 Effect.succeed({
3833 worktree: {
3834 refName: "t3code/bootstrap-refName",
3835 path: "/tmp/bootstrap-worktree",
3836 },
3837 }),
37223838 );
37233839 const runForThread = vi.fn(
37243840 (_: Parameters<ProjectSetupScriptRunnerShape["runForThread"]>[0]) =>
......@@ -3734,7 +3850,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
37343850
37353851 yield* buildAppUnderTest({
37363852 layers: {
3737 gitCore: {
3853 gitVcsDriver: {
37383854 createWorktree,
37393855 },
37403856 orchestrationEngine: {
......@@ -3798,7 +3914,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
37983914 prepareWorktree: {
37993915 projectCwd: "/tmp/project",
38003916 baseBranch: "main",
3801 branch: "t3code/bootstrap-branch",
3917 branch: "t3code/bootstrap-refName",
38023918 },
38033919 runSetupScript: true,
38043920 },
......@@ -3830,13 +3946,14 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
38303946 it.effect("cleans up created bootstrap threads when worktree creation defects", () =>
38313947 Effect.gen(function* () {
38323948 const dispatchedCommands: Array<OrchestrationCommand> = [];
3833 const createWorktree = vi.fn((_: Parameters<GitCoreShape["createWorktree"]>[0]) =>
3834 Effect.die(new Error("worktree exploded")),
3949 const createWorktree = vi.fn(
3950 (_: Parameters<GitVcsDriver.GitVcsDriverShape["createWorktree"]>[0]) =>
3951 Effect.die(new Error("worktree exploded")),
38353952 );
38363953
38373954 yield* buildAppUnderTest({
38383955 layers: {
3839 gitCore: {
3956 gitVcsDriver: {
38403957 createWorktree,
38413958 },
38423959 orchestrationEngine: {
......@@ -3881,7 +3998,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
38813998 prepareWorktree: {
38823999 projectCwd: "/tmp/project",
38834000 baseBranch: "main",
3884 branch: "t3code/bootstrap-branch",
4001 branch: "t3code/bootstrap-refName",
38854002 },
38864003 runSetupScript: false,
38874004 },
apps/server/src/server.ts+49-18
......@@ -25,13 +25,11 @@ import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReap
2525import { OpenCodeRuntimeLive } from "./provider/opencodeRuntime.ts";
2626import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery.ts";
2727import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore.ts";
28import { GitCoreLive } from "./git/Layers/GitCore.ts";
29import { GitHubCliLive } from "./git/Layers/GitHubCli.ts";
30import { GitStatusBroadcasterLive } from "./git/Layers/GitStatusBroadcaster.ts";
31import { TextGenerationLive } from "./git/Layers/TextGenerationLive.ts";
28import * as GitHubCli from "./sourceControl/GitHubCli.ts";
29import * as TextGeneration from "./textGeneration/TextGeneration.ts";
3230import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/ProviderInstanceRegistryHydration.ts";
3331import { TerminalManagerLive } from "./terminal/Layers/Manager.ts";
34import { GitManagerLive } from "./git/Layers/GitManager.ts";
32import * as GitManager from "./git/GitManager.ts";
3533import { KeybindingsLive } from "./keybindings.ts";
3634import { ServerRuntimeStartup, ServerRuntimeStartupLive } from "./serverRuntimeStartup.ts";
3735import { OrchestrationReactorLive } from "./orchestration/Layers/OrchestrationReactor.ts";
......@@ -47,6 +45,14 @@ import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdent
4745import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries.ts";
4846import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts";
4947import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts";
48import * as GitVcsDriver from "./vcs/GitVcsDriver.ts";
49import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts";
50import * as VcsProjectConfig from "./vcs/VcsProjectConfig.ts";
51import * as VcsProcess from "./vcs/VcsProcess.ts";
52import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts";
53import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts";
54import * as GitWorkflowService from "./git/GitWorkflowService.ts";
55import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts";
5056import { ProjectSetupScriptRunnerLive } from "./project/Layers/ProjectSetupScriptRunner.ts";
5157import { ObservabilityLive } from "./observability/Layers/Observability.ts";
5258import { ServerEnvironmentLive } from "./environment/Layers/ServerEnvironment.ts";
......@@ -133,11 +139,6 @@ const ReactorLayerLive = Layer.empty.pipe(
133139 Layer.provideMerge(RuntimeReceiptBusLive),
134140);
135141
136const CheckpointingLayerLive = Layer.empty.pipe(
137 Layer.provideMerge(CheckpointDiffQueryLive),
138 Layer.provideMerge(CheckpointStoreLive),
139);
140
141142const ProviderSessionDirectoryLayerLive = ProviderSessionDirectoryLive.pipe(
142143 Layer.provide(ProviderSessionRuntimeRepositoryLive),
143144);
......@@ -155,24 +156,50 @@ const ProviderLayerLive = ProviderServiceLive.pipe(
155156
156157const PersistenceLayerLive = Layer.empty.pipe(Layer.provideMerge(SqlitePersistenceLayerLive));
157158
158const GitManagerLayerLive = GitManagerLive.pipe(
159const VcsDriverRegistryLayerLive = VcsDriverRegistry.layer.pipe(
160 Layer.provide(VcsProjectConfig.layer),
161);
162
163const GitManagerLayerLive = GitManager.layer.pipe(
159164 Layer.provideMerge(ProjectSetupScriptRunnerLive),
160 Layer.provideMerge(GitCoreLive),
161 Layer.provideMerge(GitHubCliLive),
162 Layer.provideMerge(TextGenerationLive),
165 Layer.provideMerge(GitVcsDriver.layer),
166 Layer.provideMerge(
167 SourceControlProviderRegistry.layer.pipe(
168 Layer.provide(GitHubCli.layer),
169 Layer.provideMerge(VcsDriverRegistryLayerLive),
170 ),
171 ),
172 Layer.provideMerge(TextGeneration.layer),
163173);
164174
165175const GitLayerLive = Layer.empty.pipe(
166176 Layer.provideMerge(GitManagerLayerLive),
167 Layer.provideMerge(GitStatusBroadcasterLive.pipe(Layer.provide(GitManagerLayerLive))),
168 Layer.provideMerge(GitCoreLive),
177 Layer.provideMerge(GitVcsDriver.layer),
178);
179
180const GitWorkflowLayerLive = GitWorkflowService.layer.pipe(
181 Layer.provideMerge(VcsDriverRegistryLayerLive),
182 Layer.provideMerge(GitLayerLive),
183);
184
185const VcsLayerLive = Layer.empty.pipe(
186 Layer.provideMerge(VcsProjectConfig.layer),
187 Layer.provideMerge(VcsDriverRegistryLayerLive),
188 Layer.provideMerge(VcsProvisioningService.layer.pipe(Layer.provide(VcsDriverRegistryLayerLive))),
189 Layer.provideMerge(GitWorkflowLayerLive),
190 Layer.provideMerge(VcsStatusBroadcaster.layer.pipe(Layer.provide(GitWorkflowLayerLive))),
191);
192
193const CheckpointingLayerLive = Layer.empty.pipe(
194 Layer.provideMerge(CheckpointDiffQueryLive),
195 Layer.provideMerge(CheckpointStoreLive.pipe(Layer.provide(VcsDriverRegistryLayerLive))),
169196);
170197
171198const TerminalLayerLive = TerminalManagerLive.pipe(Layer.provide(PtyAdapterLive));
172199
173200const WorkspaceEntriesLayerLive = WorkspaceEntriesLive.pipe(
174201 Layer.provide(WorkspacePathsLive),
175 Layer.provideMerge(GitCoreLive),
202 Layer.provideMerge(VcsDriverRegistryLayerLive),
176203);
177204
178205const WorkspaceFileSystemLayerLive = WorkspaceFileSystemLive.pipe(
......@@ -196,10 +223,11 @@ const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe(
196223 Layer.provideMerge(OrchestrationLayerLive),
197224);
198225
199const RuntimeDependenciesLive = ReactorLayerLive.pipe(
226const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(
200227 // Core Services
201228 Layer.provideMerge(CheckpointingLayerLive),
202229 Layer.provideMerge(GitLayerLive),
230 Layer.provideMerge(VcsLayerLive),
203231 Layer.provideMerge(ProviderRuntimeLayerLive),
204232 Layer.provideMerge(TerminalLayerLive),
205233 Layer.provideMerge(PersistenceLayerLive),
......@@ -229,7 +257,9 @@ const RuntimeDependenciesLive = ReactorLayerLive.pipe(
229257 Layer.provideMerge(RepositoryIdentityResolverLive),
230258 Layer.provideMerge(ServerEnvironmentLive),
231259 Layer.provideMerge(AuthLayerLive),
260);
232261
262const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe(
233263 // Misc.
234264 Layer.provideMerge(AnalyticsServiceLayerLive),
235265 Layer.provideMerge(OpenLive),
......@@ -310,6 +340,7 @@ export const makeServerLayer = Layer.unwrap(
310340 Layer.provideMerge(HttpServerLive),
311341 Layer.provide(ObservabilityLive),
312342 Layer.provideMerge(FetchHttpClient.layer),
343 Layer.provideMerge(VcsProcess.layer),
313344 Layer.provideMerge(PlatformServicesLive),
314345 );
315346 }),
apps/server/src/sourceControl/GitHubCli.test.ts created+240
......@@ -0,0 +1,240 @@
1import { assert, it } from "@effect/vitest";
2import { Effect, Layer } from "effect";
3import { ChildProcessSpawner } from "effect/unstable/process";
4import { VcsProcessExitError, type VcsError } from "@t3tools/contracts";
5import { afterEach, describe, expect, vi } from "vitest";
6
7import { VcsProcess, type VcsProcessInput, type VcsProcessOutput } from "../vcs/VcsProcess.ts";
8import * as GitHubCli from "./GitHubCli.ts";
9
10const processOutput = (stdout: string): VcsProcessOutput => ({
11 exitCode: ChildProcessSpawner.ExitCode(0),
12 stdout,
13 stderr: "",
14 stdoutTruncated: false,
15 stderrTruncated: false,
16});
17
18const mockRun = vi.fn<(input: VcsProcessInput) => Effect.Effect<VcsProcessOutput, VcsError>>();
19
20const layer = GitHubCli.layer.pipe(
21 Layer.provide(
22 Layer.mock(VcsProcess)({
23 run: mockRun,
24 }),
25 ),
26);
27
28afterEach(() => {
29 mockRun.mockReset();
30});
31
32describe("GitHubCli.layer", () => {
33 it.effect("parses pull request view output", () =>
34 Effect.gen(function* () {
35 mockRun.mockReturnValueOnce(
36 Effect.succeed(
37 processOutput(
38 JSON.stringify({
39 number: 42,
40 title: "Add PR thread creation",
41 url: "https://github.com/pingdotgg/codething-mvp/pull/42",
42 baseRefName: "main",
43 headRefName: "feature/pr-threads",
44 state: "OPEN",
45 mergedAt: null,
46 isCrossRepository: true,
47 headRepository: {
48 nameWithOwner: "octocat/codething-mvp",
49 },
50 headRepositoryOwner: {
51 login: "octocat",
52 },
53 }),
54 ),
55 ),
56 );
57
58 const gh = yield* GitHubCli.GitHubCli;
59 const result = yield* gh.getPullRequest({
60 cwd: "/repo",
61 reference: "#42",
62 });
63
64 assert.deepStrictEqual(result, {
65 number: 42,
66 title: "Add PR thread creation",
67 url: "https://github.com/pingdotgg/codething-mvp/pull/42",
68 baseRefName: "main",
69 headRefName: "feature/pr-threads",
70 state: "open",
71 isCrossRepository: true,
72 headRepositoryNameWithOwner: "octocat/codething-mvp",
73 headRepositoryOwnerLogin: "octocat",
74 });
75 expect(mockRun).toHaveBeenCalledWith({
76 operation: "GitHubCli.execute",
77 command: "gh",
78 args: [
79 "pr",
80 "view",
81 "#42",
82 "--json",
83 "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner",
84 ],
85 cwd: "/repo",
86 timeoutMs: 30_000,
87 });
88 }).pipe(Effect.provide(layer)),
89 );
90
91 it.effect("trims pull request fields decoded from gh json", () =>
92 Effect.gen(function* () {
93 mockRun.mockReturnValueOnce(
94 Effect.succeed(
95 processOutput(
96 JSON.stringify({
97 number: 42,
98 title: " Add PR thread creation \n",
99 url: " https://github.com/pingdotgg/codething-mvp/pull/42 ",
100 baseRefName: " main ",
101 headRefName: "\tfeature/pr-threads\t",
102 state: "OPEN",
103 mergedAt: null,
104 isCrossRepository: true,
105 headRepository: {
106 nameWithOwner: " octocat/codething-mvp ",
107 },
108 headRepositoryOwner: {
109 login: " octocat ",
110 },
111 }),
112 ),
113 ),
114 );
115
116 const gh = yield* GitHubCli.GitHubCli;
117 const result = yield* gh.getPullRequest({
118 cwd: "/repo",
119 reference: "#42",
120 });
121
122 assert.deepStrictEqual(result, {
123 number: 42,
124 title: "Add PR thread creation",
125 url: "https://github.com/pingdotgg/codething-mvp/pull/42",
126 baseRefName: "main",
127 headRefName: "feature/pr-threads",
128 state: "open",
129 isCrossRepository: true,
130 headRepositoryNameWithOwner: "octocat/codething-mvp",
131 headRepositoryOwnerLogin: "octocat",
132 });
133 }).pipe(Effect.provide(layer)),
134 );
135
136 it.effect("skips invalid entries when parsing pr lists", () =>
137 Effect.gen(function* () {
138 mockRun.mockReturnValueOnce(
139 Effect.succeed(
140 processOutput(
141 JSON.stringify([
142 {
143 number: 0,
144 title: "invalid",
145 url: "https://github.com/pingdotgg/codething-mvp/pull/0",
146 baseRefName: "main",
147 headRefName: "feature/invalid",
148 },
149 {
150 number: 43,
151 title: " Valid PR ",
152 url: " https://github.com/pingdotgg/codething-mvp/pull/43 ",
153 baseRefName: " main ",
154 headRefName: " feature/pr-list ",
155 headRepository: {
156 nameWithOwner: " ",
157 },
158 headRepositoryOwner: {
159 login: " ",
160 },
161 },
162 ]),
163 ),
164 ),
165 );
166
167 const gh = yield* GitHubCli.GitHubCli;
168 const result = yield* gh.listOpenPullRequests({
169 cwd: "/repo",
170 headSelector: "feature/pr-list",
171 });
172
173 assert.deepStrictEqual(result, [
174 {
175 number: 43,
176 title: "Valid PR",
177 url: "https://github.com/pingdotgg/codething-mvp/pull/43",
178 baseRefName: "main",
179 headRefName: "feature/pr-list",
180 state: "open",
181 },
182 ]);
183 }).pipe(Effect.provide(layer)),
184 );
185
186 it.effect("reads repository clone URLs", () =>
187 Effect.gen(function* () {
188 mockRun.mockReturnValueOnce(
189 Effect.succeed(
190 processOutput(
191 JSON.stringify({
192 nameWithOwner: "octocat/codething-mvp",
193 url: "https://github.com/octocat/codething-mvp",
194 sshUrl: "git@github.com:octocat/codething-mvp.git",
195 }),
196 ),
197 ),
198 );
199
200 const gh = yield* GitHubCli.GitHubCli;
201 const result = yield* gh.getRepositoryCloneUrls({
202 cwd: "/repo",
203 repository: "octocat/codething-mvp",
204 });
205
206 assert.deepStrictEqual(result, {
207 nameWithOwner: "octocat/codething-mvp",
208 url: "https://github.com/octocat/codething-mvp",
209 sshUrl: "git@github.com:octocat/codething-mvp.git",
210 });
211 }).pipe(Effect.provide(layer)),
212 );
213
214 it.effect("surfaces a friendly error when the pull request is not found", () =>
215 Effect.gen(function* () {
216 mockRun.mockReturnValueOnce(
217 Effect.fail(
218 new VcsProcessExitError({
219 operation: "GitHubCli.execute",
220 command: "gh pr view",
221 cwd: "/repo",
222 exitCode: 1,
223 detail:
224 "GraphQL: Could not resolve to a PullRequest with the number of 4888. (repository.pullRequest)",
225 }),
226 ),
227 );
228
229 const gh = yield* GitHubCli.GitHubCli;
230 const error = yield* gh
231 .getPullRequest({
232 cwd: "/repo",
233 reference: "4888",
234 })
235 .pipe(Effect.flip);
236
237 assert.equal(error.message.includes("Pull request not found"), true);
238 }).pipe(Effect.provide(layer)),
239 );
240});
apps/server/src/sourceControl/GitHubCli.ts created+308
......@@ -0,0 +1,308 @@
1import { Context, Effect, Layer, Result, Schema, SchemaIssue } from "effect";
2
3import { GitHubCliError, TrimmedNonEmptyString, type VcsError } from "@t3tools/contracts";
4
5import { VcsProcess, type VcsProcessOutput } from "../vcs/VcsProcess.ts";
6import {
7 decodeGitHubPullRequestJson,
8 decodeGitHubPullRequestListJson,
9 formatGitHubJsonDecodeError,
10} from "../git/githubPullRequests.ts";
11
12const DEFAULT_TIMEOUT_MS = 30_000;
13
14export interface GitHubPullRequestSummary {
15 readonly number: number;
16 readonly title: string;
17 readonly url: string;
18 readonly baseRefName: string;
19 readonly headRefName: string;
20 readonly state?: "open" | "closed" | "merged";
21 readonly isCrossRepository?: boolean;
22 readonly headRepositoryNameWithOwner?: string | null;
23 readonly headRepositoryOwnerLogin?: string | null;
24}
25
26export interface GitHubRepositoryCloneUrls {
27 readonly nameWithOwner: string;
28 readonly url: string;
29 readonly sshUrl: string;
30}
31
32export interface GitHubCliShape {
33 readonly execute: (input: {
34 readonly cwd: string;
35 readonly args: ReadonlyArray<string>;
36 readonly timeoutMs?: number;
37 }) => Effect.Effect<VcsProcessOutput, GitHubCliError>;
38
39 readonly listOpenPullRequests: (input: {
40 readonly cwd: string;
41 readonly headSelector: string;
42 readonly limit?: number;
43 }) => Effect.Effect<ReadonlyArray<GitHubPullRequestSummary>, GitHubCliError>;
44
45 readonly getPullRequest: (input: {
46 readonly cwd: string;
47 readonly reference: string;
48 }) => Effect.Effect<GitHubPullRequestSummary, GitHubCliError>;
49
50 readonly getRepositoryCloneUrls: (input: {
51 readonly cwd: string;
52 readonly repository: string;
53 }) => Effect.Effect<GitHubRepositoryCloneUrls, GitHubCliError>;
54
55 readonly createPullRequest: (input: {
56 readonly cwd: string;
57 readonly baseBranch: string;
58 readonly headSelector: string;
59 readonly title: string;
60 readonly bodyFile: string;
61 }) => Effect.Effect<void, GitHubCliError>;
62
63 readonly getDefaultBranch: (input: {
64 readonly cwd: string;
65 }) => Effect.Effect<string | null, GitHubCliError>;
66
67 readonly checkoutPullRequest: (input: {
68 readonly cwd: string;
69 readonly reference: string;
70 readonly force?: boolean;
71 }) => Effect.Effect<void, GitHubCliError>;
72}
73
74export class GitHubCli extends Context.Service<GitHubCli, GitHubCliShape>()(
75 "t3/source-control/GitHubCli",
76) {}
77
78function errorText(error: VcsError | unknown): string {
79 if (typeof error === "object" && error !== null) {
80 const tag = "_tag" in error && typeof error._tag === "string" ? error._tag : "";
81 const detail = "detail" in error && typeof error.detail === "string" ? error.detail : "";
82 const message = "message" in error && typeof error.message === "string" ? error.message : "";
83 return [tag, detail, message].filter(Boolean).join("\n");
84 }
85
86 return String(error);
87}
88
89function normalizeGitHubCliError(
90 operation: "execute" | "stdout",
91 error: VcsError | unknown,
92): GitHubCliError {
93 const text = errorText(error);
94 const lower = text.toLowerCase();
95
96 if (lower.includes("command not found: gh") || lower.includes("enoent")) {
97 return new GitHubCliError({
98 operation,
99 detail: "GitHub CLI (`gh`) is required but not available on PATH.",
100 cause: error,
101 });
102 }
103
104 if (
105 lower.includes("authentication failed") ||
106 lower.includes("not logged in") ||
107 lower.includes("gh auth login") ||
108 lower.includes("no oauth token")
109 ) {
110 return new GitHubCliError({
111 operation,
112 detail: "GitHub CLI is not authenticated. Run `gh auth login` and retry.",
113 cause: error,
114 });
115 }
116
117 if (
118 lower.includes("could not resolve to a pullrequest") ||
119 lower.includes("repository.pullrequest") ||
120 lower.includes("no pull requests found for branch") ||
121 lower.includes("pull request not found")
122 ) {
123 return new GitHubCliError({
124 operation,
125 detail: "Pull request not found. Check the PR number or URL and try again.",
126 cause: error,
127 });
128 }
129
130 return new GitHubCliError({
131 operation,
132 detail: text,
133 cause: error,
134 });
135}
136
137const RawGitHubRepositoryCloneUrlsSchema = Schema.Struct({
138 nameWithOwner: TrimmedNonEmptyString,
139 url: TrimmedNonEmptyString,
140 sshUrl: TrimmedNonEmptyString,
141});
142
143function normalizeRepositoryCloneUrls(
144 raw: Schema.Schema.Type<typeof RawGitHubRepositoryCloneUrlsSchema>,
145): GitHubRepositoryCloneUrls {
146 return {
147 nameWithOwner: raw.nameWithOwner,
148 url: raw.url,
149 sshUrl: raw.sshUrl,
150 };
151}
152
153function decodeGitHubJson<S extends Schema.Top>(
154 raw: string,
155 schema: S,
156 operation: "listOpenPullRequests" | "getPullRequest" | "getRepositoryCloneUrls",
157 invalidDetail: string,
158): Effect.Effect<S["Type"], GitHubCliError, S["DecodingServices"]> {
159 return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe(
160 Effect.mapError(
161 (error) =>
162 new GitHubCliError({
163 operation,
164 detail: `${invalidDetail}: ${SchemaIssue.makeFormatterDefault()(error.issue)}`,
165 cause: error,
166 }),
167 ),
168 );
169}
170
171export const make = Effect.fn("makeGitHubCli")(function* () {
172 const process = yield* VcsProcess;
173
174 const execute: GitHubCliShape["execute"] = (input) =>
175 process
176 .run({
177 operation: "GitHubCli.execute",
178 command: "gh",
179 args: input.args,
180 cwd: input.cwd,
181 timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS,
182 })
183 .pipe(Effect.mapError((error) => normalizeGitHubCliError("execute", error)));
184
185 return GitHubCli.of({
186 execute,
187 listOpenPullRequests: (input) =>
188 execute({
189 cwd: input.cwd,
190 args: [
191 "pr",
192 "list",
193 "--head",
194 input.headSelector,
195 "--state",
196 "open",
197 "--limit",
198 String(input.limit ?? 1),
199 "--json",
200 "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner",
201 ],
202 }).pipe(
203 Effect.map((result) => result.stdout.trim()),
204 Effect.flatMap((raw) =>
205 raw.length === 0
206 ? Effect.succeed([])
207 : Effect.sync(() => decodeGitHubPullRequestListJson(raw)).pipe(
208 Effect.flatMap((decoded) => {
209 if (!Result.isSuccess(decoded)) {
210 return Effect.fail(
211 new GitHubCliError({
212 operation: "listOpenPullRequests",
213 detail: `GitHub CLI returned invalid PR list JSON: ${formatGitHubJsonDecodeError(decoded.failure)}`,
214 cause: decoded.failure,
215 }),
216 );
217 }
218
219 return Effect.succeed(
220 decoded.success.map(({ updatedAt: _updatedAt, ...summary }) => summary),
221 );
222 }),
223 ),
224 ),
225 ),
226 getPullRequest: (input) =>
227 execute({
228 cwd: input.cwd,
229 args: [
230 "pr",
231 "view",
232 input.reference,
233 "--json",
234 "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner",
235 ],
236 }).pipe(
237 Effect.map((result) => result.stdout.trim()),
238 Effect.flatMap((raw) =>
239 Effect.sync(() => decodeGitHubPullRequestJson(raw)).pipe(
240 Effect.flatMap((decoded) => {
241 if (!Result.isSuccess(decoded)) {
242 return Effect.fail(
243 new GitHubCliError({
244 operation: "getPullRequest",
245 detail: `GitHub CLI returned invalid pull request JSON: ${formatGitHubJsonDecodeError(decoded.failure)}`,
246 cause: decoded.failure,
247 }),
248 );
249 }
250
251 return Effect.succeed(
252 (({ updatedAt: _updatedAt, ...summary }) => summary)(decoded.success),
253 );
254 }),
255 ),
256 ),
257 ),
258 getRepositoryCloneUrls: (input) =>
259 execute({
260 cwd: input.cwd,
261 args: ["repo", "view", input.repository, "--json", "nameWithOwner,url,sshUrl"],
262 }).pipe(
263 Effect.map((result) => result.stdout.trim()),
264 Effect.flatMap((raw) =>
265 decodeGitHubJson(
266 raw,
267 RawGitHubRepositoryCloneUrlsSchema,
268 "getRepositoryCloneUrls",
269 "GitHub CLI returned invalid repository JSON.",
270 ),
271 ),
272 Effect.map(normalizeRepositoryCloneUrls),
273 ),
274 createPullRequest: (input) =>
275 execute({
276 cwd: input.cwd,
277 args: [
278 "pr",
279 "create",
280 "--base",
281 input.baseBranch,
282 "--head",
283 input.headSelector,
284 "--title",
285 input.title,
286 "--body-file",
287 input.bodyFile,
288 ],
289 }).pipe(Effect.asVoid),
290 getDefaultBranch: (input) =>
291 execute({
292 cwd: input.cwd,
293 args: ["repo", "view", "--json", "defaultBranchRef", "--jq", ".defaultBranchRef.name"],
294 }).pipe(
295 Effect.map((value) => {
296 const trimmed = value.stdout.trim();
297 return trimmed.length > 0 ? trimmed : null;
298 }),
299 ),
300 checkoutPullRequest: (input) =>
301 execute({
302 cwd: input.cwd,
303 args: ["pr", "checkout", input.reference, ...(input.force ? ["--force"] : [])],
304 }).pipe(Effect.asVoid),
305 });
306});
307
308export const layer = Layer.effect(GitHubCli, make());
apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts created+154
......@@ -0,0 +1,154 @@
1import { assert, it } from "@effect/vitest";
2import { DateTime, Effect, Layer, Option } from "effect";
3import { ChildProcessSpawner } from "effect/unstable/process";
4
5import { GitHubCli, type GitHubCliShape } from "./GitHubCli.ts";
6import type { VcsProcessOutput } from "../vcs/VcsProcess.ts";
7import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts";
8
9const processResult = (stdout: string): VcsProcessOutput => ({
10 exitCode: ChildProcessSpawner.ExitCode(0),
11 stdout,
12 stderr: "",
13 stdoutTruncated: false,
14 stderrTruncated: false,
15});
16
17function makeProvider(github: Partial<GitHubCliShape>) {
18 return GitHubSourceControlProvider.make().pipe(Effect.provide(Layer.mock(GitHubCli)(github)));
19}
20
21it.effect("maps GitHub PR summaries into provider-neutral change requests", () =>
22 Effect.gen(function* () {
23 const provider = yield* makeProvider({
24 getPullRequest: () =>
25 Effect.succeed({
26 number: 42,
27 title: "Add GitHub provider",
28 url: "https://github.com/pingdotgg/t3code/pull/42",
29 baseRefName: "main",
30 headRefName: "feature/source-control",
31 state: "open",
32 isCrossRepository: true,
33 headRepositoryNameWithOwner: "fork/t3code",
34 headRepositoryOwnerLogin: "fork",
35 }),
36 });
37
38 const changeRequest = yield* provider.getChangeRequest({
39 cwd: "/repo",
40 reference: "42",
41 });
42
43 assert.deepStrictEqual(changeRequest, {
44 provider: "github",
45 number: 42,
46 title: "Add GitHub provider",
47 url: "https://github.com/pingdotgg/t3code/pull/42",
48 baseRefName: "main",
49 headRefName: "feature/source-control",
50 state: "open",
51 updatedAt: Option.none(),
52 isCrossRepository: true,
53 headRepositoryNameWithOwner: "fork/t3code",
54 headRepositoryOwnerLogin: "fork",
55 });
56 }),
57);
58
59it.effect("uses gh json listing for non-open change request state queries", () =>
60 Effect.gen(function* () {
61 let executeArgs: ReadonlyArray<string> = [];
62 const provider = yield* makeProvider({
63 execute: (input) => {
64 executeArgs = input.args;
65 return Effect.succeed(
66 processResult(
67 JSON.stringify([
68 {
69 number: 7,
70 title: "Merged work",
71 url: "https://github.com/pingdotgg/t3code/pull/7",
72 baseRefName: "main",
73 headRefName: "feature/merged",
74 state: "merged",
75 updatedAt: "2026-01-02T00:00:00.000Z",
76 },
77 ]),
78 ),
79 );
80 },
81 });
82
83 const changeRequests = yield* provider.listChangeRequests({
84 cwd: "/repo",
85 headSelector: "feature/merged",
86 state: "all",
87 limit: 10,
88 });
89
90 assert.deepStrictEqual(executeArgs, [
91 "pr",
92 "list",
93 "--head",
94 "feature/merged",
95 "--state",
96 "all",
97 "--limit",
98 "10",
99 "--json",
100 "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",
101 ]);
102 assert.strictEqual(changeRequests[0]?.provider, "github");
103 assert.strictEqual(changeRequests[0]?.state, "merged");
104 assert.deepStrictEqual(
105 changeRequests[0]?.updatedAt,
106 Option.some(DateTime.makeUnsafe("2026-01-02T00:00:00.000Z")),
107 );
108 }),
109);
110
111it.effect("treats empty non-open change request listing output as no results", () =>
112 Effect.gen(function* () {
113 const provider = yield* makeProvider({
114 execute: () => Effect.succeed(processResult("")),
115 });
116
117 const changeRequests = yield* provider.listChangeRequests({
118 cwd: "/repo",
119 headSelector: "feature/empty",
120 state: "all",
121 limit: 10,
122 });
123
124 assert.deepStrictEqual(changeRequests, []);
125 }),
126);
127
128it.effect("creates GitHub PRs through provider-neutral input names", () =>
129 Effect.gen(function* () {
130 let createInput: Parameters<GitHubCliShape["createPullRequest"]>[0] | null = null;
131 const provider = yield* makeProvider({
132 createPullRequest: (input) => {
133 createInput = input;
134 return Effect.void;
135 },
136 });
137
138 yield* provider.createChangeRequest({
139 cwd: "/repo",
140 baseRefName: "main",
141 headSelector: "owner:feature/provider",
142 title: "Provider PR",
143 bodyFile: "/tmp/body.md",
144 });
145
146 assert.deepStrictEqual(createInput, {
147 cwd: "/repo",
148 baseBranch: "main",
149 headSelector: "owner:feature/provider",
150 title: "Provider PR",
151 bodyFile: "/tmp/body.md",
152 });
153 }),
154);
apps/server/src/sourceControl/GitHubSourceControlProvider.ts created+145
......@@ -0,0 +1,145 @@
1import { Effect, Layer, Option, Result, Schema } from "effect";
2import {
3 SourceControlProviderError,
4 type ChangeRequest,
5 type ChangeRequestState,
6 type GitHubCliError,
7} from "@t3tools/contracts";
8
9import { GitHubCli, type GitHubPullRequestSummary } from "./GitHubCli.ts";
10import { decodeGitHubPullRequestListJson } from "../git/githubPullRequests.ts";
11import { SourceControlProvider, type SourceControlProviderShape } from "./SourceControlProvider.ts";
12
13function providerError(operation: string, cause: GitHubCliError): SourceControlProviderError {
14 return new SourceControlProviderError({
15 provider: "github",
16 operation,
17 detail: cause.detail,
18 cause,
19 });
20}
21
22function toChangeRequest(summary: GitHubPullRequestSummary): ChangeRequest {
23 return {
24 provider: "github",
25 number: summary.number,
26 title: summary.title,
27 url: summary.url,
28 baseRefName: summary.baseRefName,
29 headRefName: summary.headRefName,
30 state: summary.state ?? "open",
31 updatedAt: Option.none(),
32 ...(summary.isCrossRepository !== undefined
33 ? { isCrossRepository: summary.isCrossRepository }
34 : {}),
35 ...(summary.headRepositoryNameWithOwner !== undefined
36 ? { headRepositoryNameWithOwner: summary.headRepositoryNameWithOwner }
37 : {}),
38 ...(summary.headRepositoryOwnerLogin !== undefined
39 ? { headRepositoryOwnerLogin: summary.headRepositoryOwnerLogin }
40 : {}),
41 };
42}
43
44export const make = Effect.fn("makeGitHubSourceControlProvider")(function* () {
45 const github = yield* GitHubCli;
46
47 const listChangeRequests: SourceControlProviderShape["listChangeRequests"] = (input) => {
48 if (input.state === "open") {
49 return github
50 .listOpenPullRequests({
51 cwd: input.cwd,
52 headSelector: input.headSelector,
53 ...(input.limit !== undefined ? { limit: input.limit } : {}),
54 })
55 .pipe(
56 Effect.map((items) => items.map(toChangeRequest)),
57 Effect.mapError((error) => providerError("listChangeRequests", error)),
58 );
59 }
60
61 const stateArg: ChangeRequestState | "all" = input.state;
62 return github
63 .execute({
64 cwd: input.cwd,
65 args: [
66 "pr",
67 "list",
68 "--head",
69 input.headSelector,
70 "--state",
71 stateArg,
72 "--limit",
73 String(input.limit ?? 20),
74 "--json",
75 "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",
76 ],
77 })
78 .pipe(
79 Effect.flatMap((result) => {
80 const raw = result.stdout.trim();
81 if (raw.length === 0) {
82 return Effect.succeed([]);
83 }
84 return Effect.sync(() => decodeGitHubPullRequestListJson(raw)).pipe(
85 Effect.flatMap((decoded) =>
86 Result.isSuccess(decoded)
87 ? Effect.succeed(
88 decoded.success.map((item) => ({
89 ...toChangeRequest(item),
90 updatedAt: item.updatedAt,
91 })),
92 )
93 : Effect.fail(
94 new SourceControlProviderError({
95 provider: "github",
96 operation: "listChangeRequests",
97 detail: "GitHub CLI returned invalid change request JSON.",
98 cause: decoded.failure,
99 }),
100 ),
101 ),
102 );
103 }),
104 Effect.mapError((error) =>
105 Schema.is(SourceControlProviderError)(error)
106 ? error
107 : providerError("listChangeRequests", error),
108 ),
109 );
110 };
111
112 return SourceControlProvider.of({
113 kind: "github",
114 listChangeRequests,
115 getChangeRequest: (input) =>
116 github.getPullRequest(input).pipe(
117 Effect.map(toChangeRequest),
118 Effect.mapError((error) => providerError("getChangeRequest", error)),
119 ),
120 createChangeRequest: (input) =>
121 github
122 .createPullRequest({
123 cwd: input.cwd,
124 baseBranch: input.baseRefName,
125 headSelector: input.headSelector,
126 title: input.title,
127 bodyFile: input.bodyFile,
128 })
129 .pipe(Effect.mapError((error) => providerError("createChangeRequest", error))),
130 getRepositoryCloneUrls: (input) =>
131 github
132 .getRepositoryCloneUrls(input)
133 .pipe(Effect.mapError((error) => providerError("getRepositoryCloneUrls", error))),
134 getDefaultBranch: (input) =>
135 github
136 .getDefaultBranch(input)
137 .pipe(Effect.mapError((error) => providerError("getDefaultBranch", error))),
138 checkoutChangeRequest: (input) =>
139 github
140 .checkoutPullRequest(input)
141 .pipe(Effect.mapError((error) => providerError("checkoutChangeRequest", error))),
142 });
143});
144
145export const layer = Layer.effect(SourceControlProvider, make());
apps/server/src/sourceControl/SourceControlDiscovery.test.ts created+214
......@@ -0,0 +1,214 @@
1import { assert, it } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { Effect, Layer, Option } from "effect";
4import { ChildProcessSpawner } from "effect/unstable/process";
5import { VcsProcessSpawnError } from "@t3tools/contracts";
6
7import { ServerConfig } from "../config.ts";
8import * as VcsProcess from "../vcs/VcsProcess.ts";
9import { SourceControlDiscovery, layer } from "./SourceControlDiscovery.ts";
10
11const processOutput = (
12 stdout: string,
13 options?: {
14 readonly stderr?: string;
15 readonly exitCode?: ChildProcessSpawner.ExitCode;
16 },
17): VcsProcess.VcsProcessOutput => ({
18 exitCode: options?.exitCode ?? ChildProcessSpawner.ExitCode(0),
19 stdout,
20 stderr: options?.stderr ?? "",
21 stdoutTruncated: false,
22 stderrTruncated: false,
23});
24
25it.effect("reports implemented tools separately from locally available CLIs", () => {
26 const testLayer = layer.pipe(
27 Layer.provide(
28 ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-discovery-" }),
29 ),
30 Layer.provide(
31 Layer.mock(VcsProcess.VcsProcess)({
32 run: (input) => {
33 if (input.command === "git") {
34 return Effect.succeed(processOutput("git version 2.51.0\n"));
35 }
36 if (input.command === "gh" && input.args[0] === "--version") {
37 return Effect.succeed(processOutput("gh version 2.83.0\n"));
38 }
39 if (input.command === "gh" && input.args.join(" ") === "auth status") {
40 return Effect.succeed(
41 processOutput(`github.com
42Logged in to github.com account juliusmarminge (keyring)
43- Active account: true
44- Git operations protocol: ssh
45- Token: gho_************************************
46- Token scopes: 'admin:public_key', 'gist', 'read:org', 'repo'
47`),
48 );
49 }
50 return Effect.fail(
51 new VcsProcessSpawnError({
52 operation: input.operation,
53 command: input.command,
54 cwd: input.cwd,
55 cause: new Error(`${input.command} not found`),
56 }),
57 );
58 },
59 }),
60 ),
61 Layer.provideMerge(NodeServices.layer),
62 );
63
64 return Effect.gen(function* () {
65 const discovery = yield* SourceControlDiscovery;
66 const result = yield* discovery.discover;
67
68 assert.deepStrictEqual(
69 result.versionControlSystems.map((item) => ({
70 kind: item.kind,
71 implemented: item.implemented,
72 status: item.status,
73 })),
74 [
75 { kind: "git", implemented: true, status: "available" },
76 { kind: "jj", implemented: false, status: "missing" },
77 ],
78 );
79 assert.deepStrictEqual(
80 result.sourceControlProviders.map((item) => ({
81 kind: item.kind,
82 implemented: item.implemented,
83 status: item.status,
84 auth: item.auth.status,
85 account: item.auth.account,
86 })),
87 [
88 {
89 kind: "github",
90 implemented: true,
91 status: "available",
92 auth: "authenticated",
93 account: Option.some("juliusmarminge"),
94 },
95 {
96 kind: "gitlab",
97 implemented: false,
98 status: "missing",
99 auth: "unknown",
100 account: Option.none(),
101 },
102 {
103 kind: "azure-devops",
104 implemented: false,
105 status: "missing",
106 auth: "unknown",
107 account: Option.none(),
108 },
109 {
110 kind: "bitbucket",
111 implemented: false,
112 status: "missing",
113 auth: "unknown",
114 account: Option.none(),
115 },
116 ],
117 );
118 }).pipe(Effect.provide(testLayer));
119});
120
121it.effect("probes provider authentication without exposing token details", () => {
122 const testLayer = layer.pipe(
123 Layer.provide(
124 ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-auth-discovery-" }),
125 ),
126 Layer.provide(
127 Layer.mock(VcsProcess.VcsProcess)({
128 run: (input) => {
129 if (input.args[0] === "--version") {
130 return Effect.succeed(processOutput(`${input.command} version test\n`));
131 }
132 if (input.command === "gh" && input.args.join(" ") === "auth status") {
133 return Effect.succeed(
134 processOutput(`github.com
135Logged in to github.com account octocat (keyring)
136- Token: gho_************************************
137- Token scopes: 'repo'
138`),
139 );
140 }
141 if (input.command === "glab" && input.args.join(" ") === "auth status") {
142 return Effect.succeed(
143 processOutput(`gitlab.com
144Logged in to gitlab.com as gitlab-user
145`),
146 );
147 }
148 if (
149 input.command === "az" &&
150 input.args.join(" ") === "account show --query user.name -o tsv"
151 ) {
152 return Effect.succeed(processOutput("azure-user@example.com\n"));
153 }
154 if (input.command === "bb" && input.args.join(" ") === "auth status") {
155 return Effect.succeed(
156 processOutput(`bitbucket.org
157Logged in as bitbucket-user
158`),
159 );
160 }
161 return Effect.fail(
162 new VcsProcessSpawnError({
163 operation: input.operation,
164 command: input.command,
165 cwd: input.cwd,
166 cause: new Error(`${input.command} not found`),
167 }),
168 );
169 },
170 }),
171 ),
172 Layer.provideMerge(NodeServices.layer),
173 );
174
175 return Effect.gen(function* () {
176 const discovery = yield* SourceControlDiscovery;
177 const result = yield* discovery.discover;
178
179 assert.deepStrictEqual(
180 result.sourceControlProviders.map((item) => ({
181 kind: item.kind,
182 auth: item.auth.status,
183 account: item.auth.account,
184 detail: item.auth.detail,
185 })),
186 [
187 {
188 kind: "github",
189 auth: "authenticated",
190 account: Option.some("octocat"),
191 detail: Option.none(),
192 },
193 {
194 kind: "gitlab",
195 auth: "authenticated",
196 account: Option.some("gitlab-user"),
197 detail: Option.none(),
198 },
199 {
200 kind: "azure-devops",
201 auth: "authenticated",
202 account: Option.some("azure-user@example.com"),
203 detail: Option.none(),
204 },
205 {
206 kind: "bitbucket",
207 auth: "authenticated",
208 account: Option.some("bitbucket-user"),
209 detail: Option.none(),
210 },
211 ],
212 );
213 }).pipe(Effect.provide(testLayer));
214});
apps/server/src/sourceControl/SourceControlDiscovery.ts created+412
......@@ -0,0 +1,412 @@
1import {
2 type SourceControlProviderAuth,
3 type SourceControlDiscoveryResult,
4 type SourceControlProviderDiscoveryItem,
5 type SourceControlProviderKind,
6 type VcsDiscoveryItem,
7 type VcsDriverKind,
8} from "@t3tools/contracts";
9import { Context, Effect, Layer, Option } from "effect";
10
11import { ServerConfig } from "../config.ts";
12import * as VcsProcess from "../vcs/VcsProcess.ts";
13
14interface DiscoveryProbe {
15 readonly label: string;
16 readonly executable: string;
17 readonly versionArgs: ReadonlyArray<string>;
18 readonly implemented: boolean;
19 readonly installHint: string;
20}
21
22type VcsProbe = DiscoveryProbe & {
23 readonly kind: VcsDriverKind;
24};
25
26type ProviderProbe = DiscoveryProbe & {
27 readonly kind: SourceControlProviderKind;
28 readonly authArgs: ReadonlyArray<string>;
29 readonly parseAuth: (input: AuthProbeInput) => SourceControlProviderAuth;
30};
31
32interface AuthProbeInput {
33 readonly stdout: string;
34 readonly stderr: string;
35 readonly exitCode: VcsProcess.VcsProcessOutput["exitCode"];
36}
37
38interface DiscoveryProbeResult<Kind extends string> {
39 readonly kind: Kind;
40 readonly label: string;
41 readonly executable: string;
42 readonly implemented: boolean;
43 readonly status: "available" | "missing";
44 readonly version: Option.Option<string>;
45 readonly installHint: string;
46 readonly detail: Option.Option<string>;
47}
48
49const VCS_PROBES: ReadonlyArray<VcsProbe> = [
50 {
51 kind: "git",
52 label: "Git",
53 executable: "git",
54 versionArgs: ["--version"],
55 implemented: true,
56 installHint: "Install Git from https://git-scm.com/downloads or with your package manager.",
57 },
58 {
59 kind: "jj",
60 label: "Jujutsu",
61 executable: "jj",
62 versionArgs: ["--version"],
63 implemented: false,
64 installHint: "Install Jujutsu with `brew install jj` or from https://github.com/jj-vcs/jj.",
65 },
66];
67
68const SOURCE_CONTROL_PROVIDER_PROBES: ReadonlyArray<ProviderProbe> = [
69 {
70 kind: "github",
71 label: "GitHub",
72 executable: "gh",
73 versionArgs: ["--version"],
74 authArgs: ["auth", "status"],
75 parseAuth: parseGitHubAuth,
76 implemented: true,
77 installHint: "Install GitHub CLI with `brew install gh` or from https://cli.github.com/.",
78 },
79 {
80 kind: "gitlab",
81 label: "GitLab",
82 executable: "glab",
83 versionArgs: ["--version"],
84 authArgs: ["auth", "status"],
85 parseAuth: parseGitLabAuth,
86 implemented: false,
87 installHint:
88 "Install GitLab CLI with `brew install glab` or from https://gitlab.com/gitlab-org/cli.",
89 },
90 {
91 kind: "azure-devops",
92 label: "Azure DevOps",
93 executable: "az",
94 versionArgs: ["--version"],
95 authArgs: ["account", "show", "--query", "user.name", "-o", "tsv"],
96 parseAuth: parseAzureAuth,
97 implemented: false,
98 installHint:
99 "Install Azure CLI with `brew install azure-cli`, then add Azure DevOps support with `az extension add --name azure-devops`.",
100 },
101 {
102 kind: "bitbucket",
103 label: "Bitbucket",
104 executable: "bb",
105 versionArgs: ["--version"],
106 authArgs: ["auth", "status"],
107 parseAuth: parseBitbucketAuth,
108 implemented: false,
109 installHint: "Install a Bitbucket CLI (`bb`) and authenticate it for your Bitbucket workspace.",
110 },
111];
112
113function firstNonEmptyLine(text: string): Option.Option<string> {
114 const line = text
115 .split(/\r?\n/)
116 .map((entry) => entry.trim())
117 .find((entry) => entry.length > 0);
118 return line === undefined ? Option.none() : Option.some(line);
119}
120
121function detailFromCause(cause: unknown): Option.Option<string> {
122 if (cause instanceof Error && cause.message.trim().length > 0) {
123 return Option.some(cause.message.trim());
124 }
125 return Option.none();
126}
127
128function authAccount(account: string | undefined): Option.Option<string> {
129 const trimmed = account?.trim();
130 return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed);
131}
132
133function authHost(host: string | undefined): Option.Option<string> {
134 const trimmed = host?.trim();
135 return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed);
136}
137
138function authDetail(detail: string | undefined): Option.Option<string> {
139 const trimmed = detail?.trim();
140 return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed);
141}
142
143function providerAuth(input: {
144 readonly status: SourceControlProviderAuth["status"];
145 readonly account?: string | undefined;
146 readonly host?: string | undefined;
147 readonly detail?: string | undefined;
148}): SourceControlProviderAuth {
149 return {
150 status: input.status,
151 account: authAccount(input.account),
152 host: authHost(input.host),
153 detail: authDetail(input.detail),
154 };
155}
156
157function unknownAuth(detail?: string): SourceControlProviderAuth {
158 return providerAuth({ status: "unknown", detail });
159}
160
161function combinedAuthOutput(input: AuthProbeInput): string {
162 return [input.stdout, input.stderr].filter((entry) => entry.trim().length > 0).join("\n");
163}
164
165function sanitizedAuthLines(text: string): ReadonlyArray<string> {
166 return text
167 .split(/\r?\n/)
168 .map((entry) => entry.trim())
169 .filter((entry) => entry.length > 0)
170 .filter((entry) => !/^[-\s]*token(?:\s+scopes?)?:/iu.test(entry));
171}
172
173function firstSafeAuthLine(text: string): string | undefined {
174 return sanitizedAuthLines(text)[0];
175}
176
177function parseCliHost(text: string): string | undefined {
178 return sanitizedAuthLines(text)
179 .map((line) => line.replace(/^[^a-z0-9]+/iu, ""))
180 .find((line) => /^[a-z0-9][a-z0-9.-]*(?::\d+)?$/iu.test(line));
181}
182
183function matchFirst(text: string, patterns: ReadonlyArray<RegExp>): string | undefined {
184 for (const pattern of patterns) {
185 const match = pattern.exec(text);
186 const value = match?.[1]?.trim();
187 if (value && value.length > 0) return value;
188 }
189 return undefined;
190}
191
192function parseGitHubAuth(input: AuthProbeInput): SourceControlProviderAuth {
193 const output = combinedAuthOutput(input);
194 const account = matchFirst(output, [
195 /Logged in to .* account\s+([^\s(]+)/iu,
196 /Logged in to .* as\s+([^\s(]+)/iu,
197 ]);
198 const host = parseCliHost(output);
199
200 if (input.exitCode !== 0) {
201 return providerAuth({
202 status: "unauthenticated",
203 host,
204 detail: firstSafeAuthLine(output) ?? "Run `gh auth login` to authenticate GitHub CLI.",
205 });
206 }
207
208 if (account) {
209 return providerAuth({ status: "authenticated", account, host });
210 }
211
212 return providerAuth({
213 status: "unknown",
214 host,
215 detail: firstSafeAuthLine(output) ?? "GitHub CLI auth status could not be parsed.",
216 });
217}
218
219function parseGitLabAuth(input: AuthProbeInput): SourceControlProviderAuth {
220 const output = combinedAuthOutput(input);
221 const account = matchFirst(output, [
222 /Logged in to .* as\s+([^\s(]+)/iu,
223 /Logged in to .* account\s+([^\s(]+)/iu,
224 /account:\s*([^\s(]+)/iu,
225 ]);
226 const host = parseCliHost(output);
227
228 if (input.exitCode !== 0) {
229 return providerAuth({
230 status: "unauthenticated",
231 host,
232 detail: firstSafeAuthLine(output) ?? "Run `glab auth login` to authenticate GitLab CLI.",
233 });
234 }
235
236 if (account) {
237 return providerAuth({ status: "authenticated", account, host });
238 }
239
240 return providerAuth({
241 status: "unknown",
242 host,
243 detail: firstSafeAuthLine(output) ?? "GitLab CLI auth status could not be parsed.",
244 });
245}
246
247function parseAzureAuth(input: AuthProbeInput): SourceControlProviderAuth {
248 const account = input.stdout.trim().split(/\r?\n/)[0]?.trim();
249
250 if (input.exitCode !== 0) {
251 return providerAuth({
252 status: "unauthenticated",
253 detail:
254 firstSafeAuthLine(combinedAuthOutput(input)) ?? "Run `az login` to authenticate Azure CLI.",
255 });
256 }
257
258 if (account && account.length > 0) {
259 return providerAuth({ status: "authenticated", account, host: "dev.azure.com" });
260 }
261
262 return providerAuth({
263 status: "unknown",
264 host: "dev.azure.com",
265 detail: "Azure CLI account status could not be parsed.",
266 });
267}
268
269function parseBitbucketAuth(input: AuthProbeInput): SourceControlProviderAuth {
270 const output = combinedAuthOutput(input);
271 const account = matchFirst(output, [
272 /Logged in to .* as\s+([^\s(]+)/iu,
273 /Logged in as\s+([^\s(]+)/iu,
274 /account:\s*([^\s(]+)/iu,
275 /user:\s*([^\s(]+)/iu,
276 /username:\s*([^\s(]+)/iu,
277 ]);
278 const host = parseCliHost(output) ?? "bitbucket.org";
279
280 if (input.exitCode !== 0) {
281 return providerAuth({
282 status: "unauthenticated",
283 host,
284 detail:
285 firstSafeAuthLine(output) ?? "Authenticate the Bitbucket CLI before enabling Bitbucket.",
286 });
287 }
288
289 if (account) {
290 return providerAuth({ status: "authenticated", account, host });
291 }
292
293 return providerAuth({
294 status: "unknown",
295 host,
296 detail: firstSafeAuthLine(output) ?? "Bitbucket CLI auth status could not be parsed.",
297 });
298}
299
300export interface SourceControlDiscoveryShape {
301 readonly discover: Effect.Effect<SourceControlDiscoveryResult>;
302}
303
304export class SourceControlDiscovery extends Context.Service<
305 SourceControlDiscovery,
306 SourceControlDiscoveryShape
307>()("t3/source-control/SourceControlDiscovery") {}
308
309export const layer = Layer.effect(
310 SourceControlDiscovery,
311 Effect.gen(function* () {
312 const config = yield* ServerConfig;
313 const process = yield* VcsProcess.VcsProcess;
314
315 const probe = <Kind extends VcsDriverKind | SourceControlProviderKind>(
316 input: DiscoveryProbe & { readonly kind: Kind },
317 ): Effect.Effect<DiscoveryProbeResult<Kind>> =>
318 process
319 .run({
320 operation: "source-control.discovery.probe",
321 command: input.executable,
322 args: input.versionArgs,
323 cwd: config.cwd,
324 timeoutMs: 5_000,
325 maxOutputBytes: 8_000,
326 truncateOutputAtMaxBytes: true,
327 })
328 .pipe(
329 Effect.map(
330 (result) =>
331 ({
332 kind: input.kind,
333 label: input.label,
334 executable: input.executable,
335 implemented: input.implemented,
336 status: "available" as const,
337 version: Option.orElse(firstNonEmptyLine(result.stdout), () =>
338 firstNonEmptyLine(result.stderr),
339 ),
340 installHint: input.installHint,
341 detail: Option.none<string>(),
342 }) satisfies DiscoveryProbeResult<Kind>,
343 ),
344 Effect.catch((cause) =>
345 Effect.succeed({
346 kind: input.kind,
347 label: input.label,
348 executable: input.executable,
349 implemented: input.implemented,
350 status: "missing" as const,
351 version: Option.none<string>(),
352 installHint: input.installHint,
353 detail: detailFromCause(cause),
354 } satisfies DiscoveryProbeResult<Kind>),
355 ),
356 );
357
358 const probeProvider = (input: ProviderProbe) =>
359 probe(input).pipe(
360 Effect.flatMap((item) => {
361 if (item.status !== "available") {
362 return Effect.succeed({
363 ...item,
364 auth: unknownAuth("CLI is not installed."),
365 } satisfies SourceControlProviderDiscoveryItem);
366 }
367
368 return process
369 .run({
370 operation: "source-control.discovery.auth",
371 command: input.executable,
372 args: input.authArgs,
373 cwd: config.cwd,
374 allowNonZeroExit: true,
375 timeoutMs: 5_000,
376 maxOutputBytes: 8_000,
377 truncateOutputAtMaxBytes: true,
378 })
379 .pipe(
380 Effect.map(
381 (result) =>
382 ({
383 ...item,
384 auth: input.parseAuth(result),
385 }) satisfies SourceControlProviderDiscoveryItem,
386 ),
387 Effect.catch((cause) =>
388 Effect.succeed({
389 ...item,
390 auth: unknownAuth(Option.getOrUndefined(detailFromCause(cause))),
391 } satisfies SourceControlProviderDiscoveryItem),
392 ),
393 );
394 }),
395 );
396
397 return SourceControlDiscovery.of({
398 discover: Effect.all({
399 versionControlSystems: Effect.all(
400 VCS_PROBES.map((entry) => probe(entry)) as ReadonlyArray<Effect.Effect<VcsDiscoveryItem>>,
401 { concurrency: "unbounded" },
402 ),
403 sourceControlProviders: Effect.all(
404 SOURCE_CONTROL_PROVIDER_PROBES.map((entry) => probeProvider(entry)) as ReadonlyArray<
405 Effect.Effect<SourceControlProviderDiscoveryItem>
406 >,
407 { concurrency: "unbounded" },
408 ),
409 }),
410 });
411 }),
412);
apps/server/src/sourceControl/SourceControlProvider.ts created+68
......@@ -0,0 +1,68 @@
1import { Context, Effect } from "effect";
2import type {
3 ChangeRequest,
4 ChangeRequestState,
5 SourceControlProviderError,
6 SourceControlProviderInfo,
7 SourceControlProviderKind,
8 SourceControlRepositoryCloneUrls,
9} from "@t3tools/contracts";
10
11export interface SourceControlProviderContext {
12 readonly provider: SourceControlProviderInfo;
13 readonly remoteName: string;
14 readonly remoteUrl: string;
15}
16
17export interface SourceControlRefSelector {
18 readonly refName: string;
19 readonly owner?: string;
20 readonly repository?: string;
21}
22
23export interface SourceControlProviderShape {
24 readonly kind: SourceControlProviderKind;
25 readonly listChangeRequests: (input: {
26 readonly cwd: string;
27 readonly context?: SourceControlProviderContext;
28 readonly source?: SourceControlRefSelector;
29 readonly headSelector: string;
30 readonly state: ChangeRequestState | "all";
31 readonly limit?: number;
32 }) => Effect.Effect<ReadonlyArray<ChangeRequest>, SourceControlProviderError>;
33 readonly getChangeRequest: (input: {
34 readonly cwd: string;
35 readonly context?: SourceControlProviderContext;
36 readonly reference: string;
37 }) => Effect.Effect<ChangeRequest, SourceControlProviderError>;
38 readonly createChangeRequest: (input: {
39 readonly cwd: string;
40 readonly context?: SourceControlProviderContext;
41 readonly source?: SourceControlRefSelector;
42 readonly target?: SourceControlRefSelector;
43 readonly baseRefName: string;
44 readonly headSelector: string;
45 readonly title: string;
46 readonly bodyFile: string;
47 }) => Effect.Effect<void, SourceControlProviderError>;
48 readonly getRepositoryCloneUrls: (input: {
49 readonly cwd: string;
50 readonly context?: SourceControlProviderContext;
51 readonly repository: string;
52 }) => Effect.Effect<SourceControlRepositoryCloneUrls, SourceControlProviderError>;
53 readonly getDefaultBranch: (input: {
54 readonly cwd: string;
55 readonly context?: SourceControlProviderContext;
56 }) => Effect.Effect<string | null, SourceControlProviderError>;
57 readonly checkoutChangeRequest: (input: {
58 readonly cwd: string;
59 readonly context?: SourceControlProviderContext;
60 readonly reference: string;
61 readonly force?: boolean;
62 }) => Effect.Effect<void, SourceControlProviderError>;
63}
64
65export class SourceControlProvider extends Context.Service<
66 SourceControlProvider,
67 SourceControlProviderShape
68>()("t3/source-control/SourceControlProvider") {}
apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts created+114
......@@ -0,0 +1,114 @@
1import { assert, it } from "@effect/vitest";
2import { DateTime, Effect, Layer, Option } from "effect";
3
4import { GitHubCli } from "./GitHubCli.ts";
5import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";
6import { VcsDriverRegistry } from "../vcs/VcsDriverRegistry.ts";
7import type { VcsDriverShape } from "../vcs/VcsDriver.ts";
8
9const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z");
10
11function makeRegistry(input: {
12 readonly remotes: ReadonlyArray<{
13 readonly name: string;
14 readonly url: string;
15 }>;
16}) {
17 const driver = {
18 listRemotes: () =>
19 Effect.succeed({
20 remotes: input.remotes.map((remote) => ({
21 ...remote,
22 pushUrl: Option.none(),
23 isPrimary: remote.name === "origin",
24 })),
25 freshness: {
26 source: "live-local" as const,
27 observedAt: TEST_EPOCH,
28 expiresAt: Option.none(),
29 },
30 }),
31 } satisfies Partial<VcsDriverShape>;
32
33 const registryLayer = Layer.mock(VcsDriverRegistry)({
34 get: () => Effect.succeed(driver as unknown as VcsDriverShape),
35 resolve: () =>
36 Effect.succeed({
37 kind: "git",
38 repository: {
39 kind: "git",
40 rootPath: "/repo",
41 metadataPath: null,
42 freshness: {
43 source: "live-local" as const,
44 observedAt: TEST_EPOCH,
45 expiresAt: Option.none(),
46 },
47 },
48 driver: driver as unknown as VcsDriverShape,
49 }),
50 });
51
52 return SourceControlProviderRegistry.make().pipe(
53 Effect.provide(Layer.mergeAll(registryLayer, Layer.mock(GitHubCli)({}))),
54 );
55}
56
57it.effect("routes GitHub remotes to the GitHub provider", () =>
58 Effect.gen(function* () {
59 const registry = yield* makeRegistry({
60 remotes: [{ name: "origin", url: "git@github.com:pingdotgg/t3code.git" }],
61 });
62
63 const provider = yield* registry.resolve({ cwd: "/repo" });
64
65 assert.strictEqual(provider.kind, "github");
66 }),
67);
68
69it.effect("routes directly by provider kind for remote-first workflows", () =>
70 Effect.gen(function* () {
71 const registry = yield* makeRegistry({
72 remotes: [],
73 });
74
75 const provider = yield* registry.get("github");
76
77 assert.strictEqual(provider.kind, "github");
78 }),
79);
80
81it.effect(
82 "detects GitLab remotes and returns an unsupported provider until one is registered",
83 () =>
84 Effect.gen(function* () {
85 const registry = yield* makeRegistry({
86 remotes: [{ name: "origin", url: "git@gitlab.com:group/project.git" }],
87 });
88
89 const provider = yield* registry.resolve({ cwd: "/repo" });
90
91 assert.strictEqual(provider.kind, "gitlab");
92 const error = yield* Effect.flip(
93 provider.listChangeRequests({
94 cwd: "/repo",
95 headSelector: "feature/source-control",
96 state: "open",
97 }),
98 );
99
100 assert.strictEqual(error.provider, "gitlab");
101 }),
102);
103
104it.effect("falls back to a non-origin remote when origin is not configured", () =>
105 Effect.gen(function* () {
106 const registry = yield* makeRegistry({
107 remotes: [{ name: "upstream", url: "https://dev.azure.com/acme/project/_git/repo" }],
108 });
109
110 const provider = yield* registry.resolve({ cwd: "/repo" });
111
112 assert.strictEqual(provider.kind, "azure-devops");
113 }),
114);
apps/server/src/sourceControl/SourceControlProviderRegistry.ts created+162
......@@ -0,0 +1,162 @@
1import { Cache, Context, Duration, Effect, Exit, Layer } from "effect";
2import { SourceControlProviderError } from "@t3tools/contracts";
3import type { SourceControlProviderKind } from "@t3tools/contracts";
4import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl";
5
6import {
7 SourceControlProvider,
8 type SourceControlProviderContext,
9 type SourceControlProviderShape,
10} from "./SourceControlProvider.ts";
11import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts";
12import { VcsDriverRegistry } from "../vcs/VcsDriverRegistry.ts";
13
14const PROVIDER_DETECTION_CACHE_CAPACITY = 2_048;
15const PROVIDER_DETECTION_CACHE_TTL = Duration.seconds(5);
16
17export interface SourceControlProviderRegistration {
18 readonly kind: SourceControlProviderKind;
19 readonly provider: SourceControlProviderShape;
20}
21
22export interface SourceControlProviderHandle {
23 readonly provider: SourceControlProviderShape;
24 readonly context: SourceControlProviderContext | null;
25}
26
27export interface SourceControlProviderRegistryShape {
28 readonly get: (
29 kind: SourceControlProviderKind,
30 ) => Effect.Effect<SourceControlProviderShape, SourceControlProviderError>;
31 readonly resolveHandle: (input: {
32 readonly cwd: string;
33 }) => Effect.Effect<SourceControlProviderHandle, SourceControlProviderError>;
34 readonly resolve: (input: {
35 readonly cwd: string;
36 }) => Effect.Effect<SourceControlProviderShape, SourceControlProviderError>;
37}
38
39export class SourceControlProviderRegistry extends Context.Service<
40 SourceControlProviderRegistry,
41 SourceControlProviderRegistryShape
42>()("t3/source-control/SourceControlProviderRegistry") {}
43
44function unsupportedProvider(kind: SourceControlProviderKind): SourceControlProviderShape {
45 const unsupported = (operation: string) =>
46 Effect.fail(
47 new SourceControlProviderError({
48 provider: kind,
49 operation,
50 detail: `No ${kind} source control provider is registered.`,
51 }),
52 );
53
54 return SourceControlProvider.of({
55 kind,
56 listChangeRequests: () => unsupported("listChangeRequests"),
57 getChangeRequest: () => unsupported("getChangeRequest"),
58 createChangeRequest: () => unsupported("createChangeRequest"),
59 getRepositoryCloneUrls: () => unsupported("getRepositoryCloneUrls"),
60 getDefaultBranch: () => unsupported("getDefaultBranch"),
61 checkoutChangeRequest: () => unsupported("checkoutChangeRequest"),
62 });
63}
64
65function providerDetectionError(operation: string, cwd: string, cause: unknown) {
66 return new SourceControlProviderError({
67 provider: "unknown",
68 operation,
69 detail: `Failed to detect source control provider for ${cwd}.`,
70 cause,
71 });
72}
73
74function selectProviderContext(
75 remotes: ReadonlyArray<{
76 readonly name: string;
77 readonly url: string;
78 }>,
79): SourceControlProviderContext | null {
80 const candidates = remotes
81 .map((remote) => {
82 const provider = detectSourceControlProviderFromRemoteUrl(remote.url);
83 return provider
84 ? {
85 provider,
86 remoteName: remote.name,
87 remoteUrl: remote.url,
88 }
89 : null;
90 })
91 .filter((value): value is SourceControlProviderContext => value !== null);
92
93 return (
94 candidates.find((candidate) => candidate.remoteName === "origin") ??
95 candidates.find((candidate) => candidate.provider.kind !== "unknown") ??
96 candidates[0] ??
97 null
98 );
99}
100
101export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWithProviders")(
102 function* (registrations: ReadonlyArray<SourceControlProviderRegistration>) {
103 const vcsRegistry = yield* VcsDriverRegistry;
104 const providers = new Map<SourceControlProviderKind, SourceControlProviderShape>(
105 registrations.map((registration) => [registration.kind, registration.provider]),
106 );
107
108 const get: SourceControlProviderRegistryShape["get"] = (kind) =>
109 Effect.succeed(providers.get(kind) ?? unsupportedProvider(kind));
110
111 const detectProviderContext = Effect.fn("SourceControlProviderRegistry.detectProviderContext")(
112 function* (cwd: string) {
113 const handle = yield* vcsRegistry
114 .resolve({ cwd })
115 .pipe(Effect.mapError((error) => providerDetectionError("detectProvider", cwd, error)));
116 const remotes = yield* handle.driver
117 .listRemotes(cwd)
118 .pipe(Effect.mapError((error) => providerDetectionError("detectProvider", cwd, error)));
119
120 return selectProviderContext(remotes.remotes);
121 },
122 );
123
124 const providerContextCache = yield* Cache.makeWith<
125 string,
126 SourceControlProviderContext | null,
127 SourceControlProviderError
128 >(detectProviderContext, {
129 capacity: PROVIDER_DETECTION_CACHE_CAPACITY,
130 timeToLive: (exit) => (Exit.isSuccess(exit) ? PROVIDER_DETECTION_CACHE_TTL : Duration.zero),
131 });
132
133 const resolveHandle: SourceControlProviderRegistryShape["resolveHandle"] = (input) =>
134 Cache.get(providerContextCache, input.cwd).pipe(
135 Effect.map((context) => {
136 const kind = context?.provider.kind ?? "unknown";
137 return {
138 provider: providers.get(kind) ?? unsupportedProvider(kind),
139 context,
140 } satisfies SourceControlProviderHandle;
141 }),
142 );
143
144 return SourceControlProviderRegistry.of({
145 get,
146 resolveHandle,
147 resolve: (input) => resolveHandle(input).pipe(Effect.map((handle) => handle.provider)),
148 });
149 },
150);
151
152export const make = Effect.fn("makeSourceControlProviderRegistry")(function* () {
153 const github = yield* GitHubSourceControlProvider.make();
154 return yield* makeWithProviders([
155 {
156 kind: "github",
157 provider: github,
158 },
159 ]);
160});
161
162export const layer = Layer.effect(SourceControlProviderRegistry, make());
apps/server/src/textGeneration/ClaudeTextGeneration.test.ts created+339
......@@ -0,0 +1,339 @@
1import { ClaudeSettings, ProviderInstanceId } from "@t3tools/contracts";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { it } from "@effect/vitest";
4import { Effect, FileSystem, Layer, Path, Schema } from "effect";
5import { createModelSelection } from "@t3tools/shared/model";
6import { expect } from "vitest";
7
8import { ServerConfig } from "../config.ts";
9import { type TextGenerationShape } from "./TextGeneration.ts";
10import { sanitizeThreadTitle } from "./TextGenerationUtils.ts";
11import { makeClaudeTextGeneration } from "./ClaudeTextGeneration.ts";
12
13const ClaudeTextGenerationTestLayer = ServerConfig.layerTest(process.cwd(), {
14 prefix: "t3code-claude-text-generation-test-",
15}).pipe(Layer.provideMerge(NodeServices.layer));
16
17function makeFakeClaudeBinary(dir: string) {
18 return Effect.gen(function* () {
19 const fs = yield* FileSystem.FileSystem;
20 const path = yield* Path.Path;
21 const binDir = path.join(dir, "bin");
22 const claudePath = path.join(binDir, "claude");
23 yield* fs.makeDirectory(binDir, { recursive: true });
24
25 yield* fs.writeFileString(
26 claudePath,
27 [
28 "#!/bin/sh",
29 'args="$*"',
30 'stdin_content="$(cat)"',
31 'if [ -n "$T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN" ]; then',
32 ' printf "%s" "$args" | grep -F -- "$T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN" >/dev/null || {',
33 ' printf "%s\\n" "args missing expected content" >&2',
34 " exit 2",
35 " }",
36 "fi",
37 'if [ -n "$T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN" ]; then',
38 ' if printf "%s" "$args" | grep -F -- "$T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN" >/dev/null; then',
39 ' printf "%s\\n" "args contained forbidden content" >&2',
40 " exit 3",
41 " fi",
42 "fi",
43 'if [ -n "$T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN" ]; then',
44 ' printf "%s" "$stdin_content" | grep -F -- "$T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN" >/dev/null || {',
45 ' printf "%s\\n" "stdin missing expected content" >&2',
46 " exit 4",
47 " }",
48 "fi",
49 'if [ -n "$T3_FAKE_CLAUDE_HOME_MUST_BE" ] && [ "$HOME" != "$T3_FAKE_CLAUDE_HOME_MUST_BE" ]; then',
50 ' printf "%s\\n" "HOME was $HOME" >&2',
51 " exit 5",
52 "fi",
53 'if [ -n "$T3_FAKE_CLAUDE_STDERR" ]; then',
54 ' printf "%s\\n" "$T3_FAKE_CLAUDE_STDERR" >&2',
55 "fi",
56 'printf "%s" "$T3_FAKE_CLAUDE_OUTPUT"',
57 'exit "${T3_FAKE_CLAUDE_EXIT_CODE:-0}"',
58 "",
59 ].join("\n"),
60 );
61 yield* fs.chmod(claudePath, 0o755);
62 return binDir;
63 });
64}
65
66function withFakeClaudeEnv<A, E, R>(
67 input: {
68 output: string;
69 exitCode?: number;
70 stderr?: string;
71 argsMustContain?: string;
72 argsMustNotContain?: string;
73 stdinMustContain?: string;
74 homeMustBe?: string;
75 claudeConfig?: Partial<ClaudeSettings>;
76 },
77 effectFn: (textGeneration: TextGenerationShape) => Effect.Effect<A, E, R>,
78) {
79 return Effect.gen(function* () {
80 const fs = yield* FileSystem.FileSystem;
81 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-claude-text-" });
82 const binDir = yield* makeFakeClaudeBinary(tempDir);
83 const previousPath = process.env.PATH;
84 const previousOutput = process.env.T3_FAKE_CLAUDE_OUTPUT;
85 const previousExitCode = process.env.T3_FAKE_CLAUDE_EXIT_CODE;
86 const previousStderr = process.env.T3_FAKE_CLAUDE_STDERR;
87 const previousArgsMustContain = process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN;
88 const previousArgsMustNotContain = process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN;
89 const previousStdinMustContain = process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN;
90 const previousHomeMustBe = process.env.T3_FAKE_CLAUDE_HOME_MUST_BE;
91
92 yield* Effect.acquireRelease(
93 Effect.sync(() => {
94 process.env.PATH = `${binDir}:${previousPath ?? ""}`;
95 process.env.T3_FAKE_CLAUDE_OUTPUT = input.output;
96
97 if (input.exitCode !== undefined) {
98 process.env.T3_FAKE_CLAUDE_EXIT_CODE = String(input.exitCode);
99 } else {
100 delete process.env.T3_FAKE_CLAUDE_EXIT_CODE;
101 }
102
103 if (input.stderr !== undefined) {
104 process.env.T3_FAKE_CLAUDE_STDERR = input.stderr;
105 } else {
106 delete process.env.T3_FAKE_CLAUDE_STDERR;
107 }
108
109 if (input.argsMustContain !== undefined) {
110 process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN = input.argsMustContain;
111 } else {
112 delete process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN;
113 }
114
115 if (input.argsMustNotContain !== undefined) {
116 process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN = input.argsMustNotContain;
117 } else {
118 delete process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN;
119 }
120
121 if (input.stdinMustContain !== undefined) {
122 process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN = input.stdinMustContain;
123 } else {
124 delete process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN;
125 }
126
127 if (input.homeMustBe !== undefined) {
128 process.env.T3_FAKE_CLAUDE_HOME_MUST_BE = input.homeMustBe;
129 } else {
130 delete process.env.T3_FAKE_CLAUDE_HOME_MUST_BE;
131 }
132 }),
133 () =>
134 Effect.sync(() => {
135 process.env.PATH = previousPath;
136
137 if (previousOutput === undefined) {
138 delete process.env.T3_FAKE_CLAUDE_OUTPUT;
139 } else {
140 process.env.T3_FAKE_CLAUDE_OUTPUT = previousOutput;
141 }
142
143 if (previousExitCode === undefined) {
144 delete process.env.T3_FAKE_CLAUDE_EXIT_CODE;
145 } else {
146 process.env.T3_FAKE_CLAUDE_EXIT_CODE = previousExitCode;
147 }
148
149 if (previousStderr === undefined) {
150 delete process.env.T3_FAKE_CLAUDE_STDERR;
151 } else {
152 process.env.T3_FAKE_CLAUDE_STDERR = previousStderr;
153 }
154
155 if (previousArgsMustContain === undefined) {
156 delete process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN;
157 } else {
158 process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN = previousArgsMustContain;
159 }
160
161 if (previousArgsMustNotContain === undefined) {
162 delete process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN;
163 } else {
164 process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN = previousArgsMustNotContain;
165 }
166
167 if (previousStdinMustContain === undefined) {
168 delete process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN;
169 } else {
170 process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN = previousStdinMustContain;
171 }
172
173 if (previousHomeMustBe === undefined) {
174 delete process.env.T3_FAKE_CLAUDE_HOME_MUST_BE;
175 } else {
176 process.env.T3_FAKE_CLAUDE_HOME_MUST_BE = previousHomeMustBe;
177 }
178 }),
179 );
180
181 const config = Schema.decodeSync(ClaudeSettings)(input.claudeConfig ?? {});
182 const textGeneration = yield* makeClaudeTextGeneration(config);
183 return yield* effectFn(textGeneration);
184 }).pipe(Effect.scoped);
185}
186
187it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => {
188 it.effect("forwards Claude thinking settings for Haiku without passing effort", () =>
189 withFakeClaudeEnv(
190 {
191 output: JSON.stringify({
192 structured_output: {
193 subject: "Add important change",
194 body: "",
195 },
196 }),
197 argsMustContain: '--settings {"alwaysThinkingEnabled":false}',
198 argsMustNotContain: "--effort",
199 },
200 (textGeneration) =>
201 Effect.gen(function* () {
202 const generated = yield* textGeneration.generateCommitMessage({
203 cwd: process.cwd(),
204 branch: "feature/claude-effect",
205 stagedSummary: "M README.md",
206 stagedPatch: "diff --git a/README.md b/README.md",
207 modelSelection: {
208 ...createModelSelection(ProviderInstanceId.make("claudeAgent"), "claude-haiku-4-5", [
209 { id: "thinking", value: false },
210 { id: "effort", value: "high" },
211 ]),
212 },
213 });
214
215 expect(generated.subject).toBe("Add important change");
216 }),
217 ),
218 );
219
220 it.effect("forwards Claude fast mode and supported effort", () =>
221 withFakeClaudeEnv(
222 {
223 output: JSON.stringify({
224 structured_output: {
225 title: "Improve orchestration flow",
226 body: "Body",
227 },
228 }),
229 argsMustContain: '--effort max --settings {"fastMode":true}',
230 },
231 (textGeneration) =>
232 Effect.gen(function* () {
233 const generated = yield* textGeneration.generatePrContent({
234 cwd: process.cwd(),
235 baseBranch: "main",
236 headBranch: "feature/claude-effect",
237 commitSummary: "Improve orchestration",
238 diffSummary: "1 file changed",
239 diffPatch: "diff --git a/README.md b/README.md",
240 modelSelection: {
241 ...createModelSelection(ProviderInstanceId.make("claudeAgent"), "claude-opus-4-6", [
242 { id: "effort", value: "max" },
243 { id: "fastMode", value: true },
244 ]),
245 },
246 });
247
248 expect(generated.title).toBe("Improve orchestration flow");
249 }),
250 ),
251 );
252
253 it.effect("generates thread titles through the Claude provider", () =>
254 withFakeClaudeEnv(
255 {
256 output: JSON.stringify({
257 structured_output: {
258 title:
259 ' "Reconnect failures after restart because the session state does not recover" ',
260 },
261 }),
262 stdinMustContain: "You write concise thread titles for coding conversations.",
263 },
264 (textGeneration) =>
265 Effect.gen(function* () {
266 const generated = yield* textGeneration.generateThreadTitle({
267 cwd: process.cwd(),
268 message: "Please investigate reconnect failures after restarting the session.",
269 modelSelection: {
270 instanceId: ProviderInstanceId.make("claudeAgent"),
271 model: "claude-sonnet-4-6",
272 },
273 });
274
275 expect(generated.title).toBe(
276 sanitizeThreadTitle(
277 '"Reconnect failures after restart because the session state does not recover"',
278 ),
279 );
280 }),
281 ),
282 );
283
284 it.effect("runs Claude text generation with the configured Claude HOME", () =>
285 Effect.gen(function* () {
286 const path = yield* Path.Path;
287 const claudeHome = path.join(process.cwd(), ".claude-work-test");
288 return yield* withFakeClaudeEnv(
289 {
290 output: JSON.stringify({
291 structured_output: {
292 title: "Use Claude home",
293 },
294 }),
295 homeMustBe: claudeHome,
296 claudeConfig: { homePath: claudeHome },
297 },
298 (textGeneration) =>
299 Effect.gen(function* () {
300 const generated = yield* textGeneration.generateThreadTitle({
301 cwd: process.cwd(),
302 message: "thread title",
303 modelSelection: {
304 instanceId: ProviderInstanceId.make("claudeAgent"),
305 model: "claude-sonnet-4-6",
306 },
307 });
308
309 expect(generated.title).toBe(sanitizeThreadTitle("Use Claude home"));
310 }),
311 );
312 }),
313 );
314
315 it.effect("falls back when Claude thread title normalization becomes whitespace-only", () =>
316 withFakeClaudeEnv(
317 {
318 output: JSON.stringify({
319 structured_output: {
320 title: ' """ """ ',
321 },
322 }),
323 },
324 (textGeneration) =>
325 Effect.gen(function* () {
326 const generated = yield* textGeneration.generateThreadTitle({
327 cwd: process.cwd(),
328 message: "Name this thread.",
329 modelSelection: {
330 instanceId: ProviderInstanceId.make("claudeAgent"),
331 model: "claude-sonnet-4-6",
332 },
333 });
334
335 expect(generated.title).toBe("New thread");
336 }),
337 ),
338 );
339});
apps/server/src/textGeneration/ClaudeTextGeneration.ts created+324
......@@ -0,0 +1,324 @@
1/**
2 * ClaudeTextGeneration – Text generation layer using the Claude CLI.
3 *
4 * Implements the same TextGenerationShape contract as CodexTextGeneration but
5 * delegates to the `claude` CLI (`claude -p`) with structured JSON output
6 * instead of the `codex exec` CLI.
7 *
8 * @module ClaudeTextGeneration
9 */
10import { Effect, Option, Schema, Stream } from "effect";
11import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
12
13import { type ClaudeSettings, type ModelSelection } from "@t3tools/contracts";
14import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
15
16import { TextGenerationError } from "@t3tools/contracts";
17import { type TextGenerationShape } from "./TextGeneration.ts";
18import {
19 buildBranchNamePrompt,
20 buildCommitMessagePrompt,
21 buildPrContentPrompt,
22 buildThreadTitlePrompt,
23} from "./TextGenerationPrompts.ts";
24import {
25 normalizeCliError,
26 sanitizeCommitSubject,
27 sanitizePrTitle,
28 sanitizeThreadTitle,
29 toJsonSchemaObject,
30} from "./TextGenerationUtils.ts";
31import {
32 getModelSelectionStringOptionValue,
33 getProviderOptionDescriptors,
34} from "@t3tools/shared/model";
35import {
36 getClaudeModelCapabilities,
37 normalizeClaudeCliEffort,
38 resolveClaudeApiModelId,
39 resolveClaudeEffort,
40} from "../provider/Layers/ClaudeProvider.ts";
41import { makeClaudeEnvironment } from "../provider/Drivers/ClaudeHome.ts";
42
43const CLAUDE_TIMEOUT_MS = 180_000;
44
45/**
46 * Schema for the wrapper JSON returned by `claude -p --output-format json`.
47 * We only care about `structured_output`.
48 */
49const ClaudeOutputEnvelope = Schema.Struct({
50 structured_output: Schema.Unknown,
51});
52
53export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(function* (
54 claudeSettings: ClaudeSettings,
55 environment: NodeJS.ProcessEnv = process.env,
56) {
57 const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
58 const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment);
59
60 const readStreamAsString = <E>(
61 operation: string,
62 stream: Stream.Stream<Uint8Array, E>,
63 ): Effect.Effect<string, TextGenerationError> =>
64 stream.pipe(
65 Stream.decodeText(),
66 Stream.runFold(
67 () => "",
68 (acc, chunk) => acc + chunk,
69 ),
70 Effect.mapError((cause) =>
71 normalizeCliError("claude", operation, cause, "Failed to collect process output"),
72 ),
73 );
74
75 /**
76 * Spawn the Claude CLI with structured JSON output and return the parsed,
77 * schema-validated result.
78 */
79 const runClaudeJson = Effect.fn("runClaudeJson")(function* <S extends Schema.Top>({
80 operation,
81 cwd,
82 prompt,
83 outputSchemaJson,
84 modelSelection,
85 }: {
86 operation:
87 | "generateCommitMessage"
88 | "generatePrContent"
89 | "generateBranchName"
90 | "generateThreadTitle";
91 cwd: string;
92 prompt: string;
93 outputSchemaJson: S;
94 modelSelection: ModelSelection;
95 }): Effect.fn.Return<S["Type"], TextGenerationError, S["DecodingServices"]> {
96 const jsonSchemaStr = JSON.stringify(toJsonSchemaObject(outputSchemaJson));
97 const caps = getClaudeModelCapabilities(modelSelection.model);
98 const descriptors = getProviderOptionDescriptors({
99 caps,
100 selections: modelSelection.options,
101 });
102 const findDescriptor = (id: string) => descriptors.find((descriptor) => descriptor.id === id);
103 const rawEffortSelection = getModelSelectionStringOptionValue(modelSelection, "effort");
104 const resolvedEffort = resolveClaudeEffort(caps, rawEffortSelection);
105 const cliEffort = normalizeClaudeCliEffort(resolvedEffort);
106 const thinkingDescriptor = findDescriptor("thinking");
107 const fastModeDescriptor = findDescriptor("fastMode");
108 const thinking =
109 thinkingDescriptor?.type === "boolean" ? thinkingDescriptor.currentValue : undefined;
110 const fastMode =
111 fastModeDescriptor?.type === "boolean" ? fastModeDescriptor.currentValue : undefined;
112 const settings = {
113 ...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}),
114 ...(fastMode ? { fastMode: true } : {}),
115 };
116
117 const runClaudeCommand = Effect.fn("runClaudeJson.runClaudeCommand")(function* () {
118 const command = ChildProcess.make(
119 claudeSettings.binaryPath || "claude",
120 [
121 "-p",
122 "--output-format",
123 "json",
124 "--json-schema",
125 jsonSchemaStr,
126 "--model",
127 resolveClaudeApiModelId(modelSelection),
128 ...(cliEffort ? ["--effort", cliEffort] : []),
129 ...(Object.keys(settings).length > 0 ? ["--settings", JSON.stringify(settings)] : []),
130 "--dangerously-skip-permissions",
131 ],
132 {
133 env: claudeEnvironment,
134 cwd,
135 shell: process.platform === "win32",
136 stdin: {
137 stream: Stream.encodeText(Stream.make(prompt)),
138 },
139 },
140 );
141
142 const child = yield* commandSpawner
143 .spawn(command)
144 .pipe(
145 Effect.mapError((cause) =>
146 normalizeCliError("claude", operation, cause, "Failed to spawn Claude CLI process"),
147 ),
148 );
149
150 const [stdout, stderr, exitCode] = yield* Effect.all(
151 [
152 readStreamAsString(operation, child.stdout),
153 readStreamAsString(operation, child.stderr),
154 child.exitCode.pipe(
155 Effect.mapError((cause) =>
156 normalizeCliError("claude", operation, cause, "Failed to read Claude CLI exit code"),
157 ),
158 ),
159 ],
160 { concurrency: "unbounded" },
161 );
162
163 if (exitCode !== 0) {
164 const stderrDetail = stderr.trim();
165 const stdoutDetail = stdout.trim();
166 const detail = stderrDetail.length > 0 ? stderrDetail : stdoutDetail;
167 return yield* new TextGenerationError({
168 operation,
169 detail:
170 detail.length > 0
171 ? `Claude CLI command failed: ${detail}`
172 : `Claude CLI command failed with code ${exitCode}.`,
173 });
174 }
175
176 return stdout;
177 });
178
179 const rawStdout = yield* runClaudeCommand().pipe(
180 Effect.scoped,
181 Effect.timeoutOption(CLAUDE_TIMEOUT_MS),
182 Effect.flatMap(
183 Option.match({
184 onNone: () =>
185 Effect.fail(
186 new TextGenerationError({ operation, detail: "Claude CLI request timed out." }),
187 ),
188 onSome: (value) => Effect.succeed(value),
189 }),
190 ),
191 );
192
193 const envelope = yield* Schema.decodeEffect(Schema.fromJsonString(ClaudeOutputEnvelope))(
194 rawStdout,
195 ).pipe(
196 Effect.catchTag("SchemaError", (cause) =>
197 Effect.fail(
198 new TextGenerationError({
199 operation,
200 detail: "Claude CLI returned unexpected output format.",
201 cause,
202 }),
203 ),
204 ),
205 );
206
207 return yield* Schema.decodeEffect(outputSchemaJson)(envelope.structured_output).pipe(
208 Effect.catchTag("SchemaError", (cause) =>
209 Effect.fail(
210 new TextGenerationError({
211 operation,
212 detail: "Claude returned invalid structured output.",
213 cause,
214 }),
215 ),
216 ),
217 );
218 });
219
220 // ---------------------------------------------------------------------------
221 // TextGenerationShape methods
222 // ---------------------------------------------------------------------------
223
224 const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn(
225 "ClaudeTextGeneration.generateCommitMessage",
226 )(function* (input) {
227 const { prompt, outputSchema } = buildCommitMessagePrompt({
228 branch: input.branch,
229 stagedSummary: input.stagedSummary,
230 stagedPatch: input.stagedPatch,
231 includeBranch: input.includeBranch === true,
232 });
233
234 const generated = yield* runClaudeJson({
235 operation: "generateCommitMessage",
236 cwd: input.cwd,
237 prompt,
238 outputSchemaJson: outputSchema,
239 modelSelection: input.modelSelection,
240 });
241
242 return {
243 subject: sanitizeCommitSubject(generated.subject),
244 body: generated.body.trim(),
245 ...("branch" in generated && typeof generated.branch === "string"
246 ? { branch: sanitizeFeatureBranchName(generated.branch) }
247 : {}),
248 };
249 });
250
251 const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn(
252 "ClaudeTextGeneration.generatePrContent",
253 )(function* (input) {
254 const { prompt, outputSchema } = buildPrContentPrompt({
255 baseBranch: input.baseBranch,
256 headBranch: input.headBranch,
257 commitSummary: input.commitSummary,
258 diffSummary: input.diffSummary,
259 diffPatch: input.diffPatch,
260 });
261
262 const generated = yield* runClaudeJson({
263 operation: "generatePrContent",
264 cwd: input.cwd,
265 prompt,
266 outputSchemaJson: outputSchema,
267 modelSelection: input.modelSelection,
268 });
269
270 return {
271 title: sanitizePrTitle(generated.title),
272 body: generated.body.trim(),
273 };
274 });
275
276 const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn(
277 "ClaudeTextGeneration.generateBranchName",
278 )(function* (input) {
279 const { prompt, outputSchema } = buildBranchNamePrompt({
280 message: input.message,
281 attachments: input.attachments,
282 });
283
284 const generated = yield* runClaudeJson({
285 operation: "generateBranchName",
286 cwd: input.cwd,
287 prompt,
288 outputSchemaJson: outputSchema,
289 modelSelection: input.modelSelection,
290 });
291
292 return {
293 branch: sanitizeBranchFragment(generated.branch),
294 };
295 });
296
297 const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn(
298 "ClaudeTextGeneration.generateThreadTitle",
299 )(function* (input) {
300 const { prompt, outputSchema } = buildThreadTitlePrompt({
301 message: input.message,
302 attachments: input.attachments,
303 });
304
305 const generated = yield* runClaudeJson({
306 operation: "generateThreadTitle",
307 cwd: input.cwd,
308 prompt,
309 outputSchemaJson: outputSchema,
310 modelSelection: input.modelSelection,
311 });
312
313 return {
314 title: sanitizeThreadTitle(generated.title),
315 };
316 });
317
318 return {
319 generateCommitMessage,
320 generatePrContent,
321 generateBranchName,
322 generateThreadTitle,
323 } satisfies TextGenerationShape;
324});
apps/server/src/textGeneration/CodexTextGeneration.test.ts created+592
......@@ -0,0 +1,592 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { it } from "@effect/vitest";
3import { Effect, FileSystem, Layer, Path, Result, Schema } from "effect";
4import { createModelSelection } from "@t3tools/shared/model";
5import { expect } from "vitest";
6
7import { CodexSettings, ProviderInstanceId, TextGenerationError } from "@t3tools/contracts";
8
9import { ServerConfig } from "../config.ts";
10import { type TextGenerationShape } from "./TextGeneration.ts";
11import { makeCodexTextGeneration } from "./CodexTextGeneration.ts";
12
13const DEFAULT_TEST_MODEL_SELECTION = createModelSelection(
14 ProviderInstanceId.make("codex"),
15 "gpt-5.4-mini",
16);
17
18const CodexTextGenerationTestLayer = ServerConfig.layerTest(process.cwd(), {
19 prefix: "t3code-codex-text-generation-test-",
20}).pipe(Layer.provideMerge(NodeServices.layer));
21
22function makeFakeCodexBinary(
23 dir: string,
24 input: {
25 output: string;
26 exitCode?: number;
27 stderr?: string;
28 requireImage?: boolean;
29 requireFastServiceTier?: boolean;
30 requireReasoningEffort?: string;
31 forbidReasoningEffort?: boolean;
32 stdinMustContain?: string;
33 stdinMustNotContain?: string;
34 },
35) {
36 return Effect.gen(function* () {
37 const fs = yield* FileSystem.FileSystem;
38 const path = yield* Path.Path;
39 const binDir = path.join(dir, "bin");
40 const codexPath = path.join(binDir, "codex");
41 yield* fs.makeDirectory(binDir, { recursive: true });
42
43 yield* fs.writeFileString(
44 codexPath,
45 [
46 "#!/bin/sh",
47 'output_path=""',
48 'seen_image="0"',
49 'seen_fast_service_tier="0"',
50 'seen_reasoning_effort=""',
51 "while [ $# -gt 0 ]; do",
52 ' if [ "$1" = "--image" ]; then',
53 " shift",
54 ' if [ -n "$1" ]; then',
55 ' seen_image="1"',
56 " fi",
57 " shift",
58 " continue",
59 " fi",
60 ' if [ "$1" = "--config" ]; then',
61 " shift",
62 ' if [ "$1" = "service_tier=\\"fast\\"" ]; then',
63 ' seen_fast_service_tier="1"',
64 " fi",
65 ' case "$1" in',
66 " model_reasoning_effort=*)",
67 ' seen_reasoning_effort="$1"',
68 " ;;",
69 " esac",
70 " shift",
71 " continue",
72 " fi",
73 ' if [ "$1" = "--output-last-message" ]; then',
74 " shift",
75 ' output_path="$1"',
76 " shift",
77 " continue",
78 " fi",
79 " shift",
80 "done",
81 'stdin_content="$(cat)"',
82 ...(input.requireImage
83 ? [
84 'if [ "$seen_image" != "1" ]; then',
85 ' printf "%s\\n" "missing --image input" >&2',
86 ` exit 2`,
87 "fi",
88 ]
89 : []),
90 ...(input.requireFastServiceTier
91 ? [
92 'if [ "$seen_fast_service_tier" != "1" ]; then',
93 ' printf "%s\\n" "missing fast service tier config" >&2',
94 ` exit 5`,
95 "fi",
96 ]
97 : []),
98 ...(input.requireReasoningEffort !== undefined
99 ? [
100 `if [ "$seen_reasoning_effort" != "model_reasoning_effort=\\"${input.requireReasoningEffort}\\"" ]; then`,
101 ' printf "%s\\n" "unexpected reasoning effort config: $seen_reasoning_effort" >&2',
102 ` exit 6`,
103 "fi",
104 ]
105 : []),
106 ...(input.forbidReasoningEffort
107 ? [
108 'if [ -n "$seen_reasoning_effort" ]; then',
109 ' printf "%s\\n" "reasoning effort config should be omitted: $seen_reasoning_effort" >&2',
110 ` exit 7`,
111 "fi",
112 ]
113 : []),
114 ...(input.stdinMustContain !== undefined
115 ? [
116 `if ! printf "%s" "$stdin_content" | grep -F -- ${JSON.stringify(input.stdinMustContain)} >/dev/null; then`,
117 ' printf "%s\\n" "stdin missing expected content" >&2',
118 ` exit 3`,
119 "fi",
120 ]
121 : []),
122 ...(input.stdinMustNotContain !== undefined
123 ? [
124 `if printf "%s" "$stdin_content" | grep -F -- ${JSON.stringify(input.stdinMustNotContain)} >/dev/null; then`,
125 ' printf "%s\\n" "stdin contained forbidden content" >&2',
126 ` exit 4`,
127 "fi",
128 ]
129 : []),
130 ...(input.stderr !== undefined
131 ? [`printf "%s\\n" ${JSON.stringify(input.stderr)} >&2`]
132 : []),
133 'if [ -n "$output_path" ]; then',
134 " cat > \"$output_path\" <<'__T3CODE_FAKE_CODEX_OUTPUT__'",
135 input.output,
136 "__T3CODE_FAKE_CODEX_OUTPUT__",
137 "fi",
138 `exit ${input.exitCode ?? 0}`,
139 "",
140 ].join("\n"),
141 );
142 yield* fs.chmod(codexPath, 0o755);
143 return codexPath;
144 });
145}
146
147function withFakeCodexEnv<A, E, R>(
148 input: {
149 output: string;
150 exitCode?: number;
151 stderr?: string;
152 requireImage?: boolean;
153 requireFastServiceTier?: boolean;
154 requireReasoningEffort?: string;
155 forbidReasoningEffort?: boolean;
156 stdinMustContain?: string;
157 stdinMustNotContain?: string;
158 },
159 effectFn: (textGeneration: TextGenerationShape) => Effect.Effect<A, E, R>,
160) {
161 return Effect.gen(function* () {
162 const fs = yield* FileSystem.FileSystem;
163 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-codex-text-" });
164 const codexPath = yield* makeFakeCodexBinary(tempDir, input);
165 const config = Schema.decodeSync(CodexSettings)({ binaryPath: codexPath });
166 const textGeneration = yield* makeCodexTextGeneration(config);
167 return yield* effectFn(textGeneration);
168 }).pipe(Effect.scoped);
169}
170
171it.layer(CodexTextGenerationTestLayer)("CodexTextGeneration", (it) => {
172 it.effect("generates and sanitizes commit messages without branch by default", () =>
173 withFakeCodexEnv(
174 {
175 output: JSON.stringify({
176 subject:
177 " Add important change to the system with too much detail and a trailing period.\nsecondary line",
178 body: "\n- added migration\n- updated tests\n",
179 }),
180 stdinMustNotContain: "branch must be a short semantic git branch fragment",
181 },
182 (textGeneration) =>
183 Effect.gen(function* () {
184 const generated = yield* textGeneration.generateCommitMessage({
185 cwd: process.cwd(),
186 branch: "feature/codex-effect",
187 stagedSummary: "M README.md",
188 stagedPatch: "diff --git a/README.md b/README.md",
189 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
190 });
191
192 expect(generated.subject.length).toBeLessThanOrEqual(72);
193 expect(generated.subject.endsWith(".")).toBe(false);
194 expect(generated.body).toBe("- added migration\n- updated tests");
195 expect(generated.branch).toBeUndefined();
196 }),
197 ),
198 );
199
200 it.effect(
201 "forwards codex fast mode and non-default reasoning effort into codex exec config",
202 () =>
203 withFakeCodexEnv(
204 {
205 output: JSON.stringify({
206 subject: "Add important change",
207 body: "",
208 }),
209 requireFastServiceTier: true,
210 requireReasoningEffort: "xhigh",
211 stdinMustNotContain: "branch must be a short semantic git branch fragment",
212 },
213 (textGeneration) =>
214 textGeneration.generateCommitMessage({
215 cwd: process.cwd(),
216 branch: "feature/codex-effect",
217 stagedSummary: "M README.md",
218 stagedPatch: "diff --git a/README.md b/README.md",
219 modelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [
220 { id: "reasoningEffort", value: "xhigh" },
221 { id: "fastMode", value: true },
222 ]),
223 }),
224 ),
225 );
226
227 it.effect("defaults git text generation codex effort to low", () =>
228 withFakeCodexEnv(
229 {
230 output: JSON.stringify({
231 subject: "Add important change",
232 body: "",
233 }),
234 requireReasoningEffort: "low",
235 },
236 (textGeneration) =>
237 textGeneration.generateCommitMessage({
238 cwd: process.cwd(),
239 branch: "feature/codex-effect",
240 stagedSummary: "M README.md",
241 stagedPatch: "diff --git a/README.md b/README.md",
242 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
243 }),
244 ),
245 );
246
247 it.effect("generates commit message with branch when includeBranch is true", () =>
248 withFakeCodexEnv(
249 {
250 output: JSON.stringify({
251 subject: "Add important change",
252 body: "",
253 branch: "fix/important-system-change",
254 }),
255 stdinMustContain: "branch must be a short semantic git branch fragment",
256 },
257 (textGeneration) =>
258 Effect.gen(function* () {
259 const generated = yield* textGeneration.generateCommitMessage({
260 cwd: process.cwd(),
261 branch: "feature/codex-effect",
262 stagedSummary: "M README.md",
263 stagedPatch: "diff --git a/README.md b/README.md",
264 includeBranch: true,
265 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
266 });
267
268 expect(generated.subject).toBe("Add important change");
269 expect(generated.branch).toBe("feature/fix/important-system-change");
270 }),
271 ),
272 );
273
274 it.effect("generates PR content and trims markdown body", () =>
275 withFakeCodexEnv(
276 {
277 output: JSON.stringify({
278 title: " Improve orchestration flow\nwith ignored suffix",
279 body: "\n## Summary\n- improve flow\n\n## Testing\n- bun test\n\n",
280 }),
281 },
282 (textGeneration) =>
283 Effect.gen(function* () {
284 const generated = yield* textGeneration.generatePrContent({
285 cwd: process.cwd(),
286 baseBranch: "main",
287 headBranch: "feature/codex-effect",
288 commitSummary: "feat: improve orchestration flow",
289 diffSummary: "2 files changed",
290 diffPatch: "diff --git a/a.ts b/a.ts",
291 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
292 });
293
294 expect(generated.title).toBe("Improve orchestration flow");
295 expect(generated.body.startsWith("## Summary")).toBe(true);
296 expect(generated.body.endsWith("\n\n")).toBe(false);
297 }),
298 ),
299 );
300
301 it.effect("generates branch names and normalizes branch fragments", () =>
302 withFakeCodexEnv(
303 {
304 output: JSON.stringify({
305 branch: " Feat/Session ",
306 }),
307 stdinMustNotContain: "Image attachments supplied to the model",
308 },
309 (textGeneration) =>
310 Effect.gen(function* () {
311 const generated = yield* textGeneration.generateBranchName({
312 cwd: process.cwd(),
313 message: "Please update session handling.",
314 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
315 });
316
317 expect(generated.branch).toBe("feat/session");
318 }),
319 ),
320 );
321
322 it.effect("generates thread titles and trims them for sidebar use", () =>
323 withFakeCodexEnv(
324 {
325 output: JSON.stringify({
326 title:
327 ' "Investigate websocket reconnect regressions after worktree restore" \nignored line',
328 }),
329 },
330 (textGeneration) =>
331 Effect.gen(function* () {
332 const generated = yield* textGeneration.generateThreadTitle({
333 cwd: process.cwd(),
334 message: "Please investigate websocket reconnect regressions after a worktree restore.",
335 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
336 });
337
338 expect(generated.title).toBe("Investigate websocket reconnect regressions aft...");
339 }),
340 ),
341 );
342
343 it.effect("falls back when thread title normalization becomes whitespace-only", () =>
344 withFakeCodexEnv(
345 {
346 output: JSON.stringify({
347 title: ' """ """ ',
348 }),
349 },
350 (textGeneration) =>
351 Effect.gen(function* () {
352 const generated = yield* textGeneration.generateThreadTitle({
353 cwd: process.cwd(),
354 message: "Name this thread.",
355 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
356 });
357
358 expect(generated.title).toBe("New thread");
359 }),
360 ),
361 );
362
363 it.effect("trims whitespace exposed after quote removal in thread titles", () =>
364 withFakeCodexEnv(
365 {
366 output: JSON.stringify({
367 title: ` "' hello world '" `,
368 }),
369 },
370 (textGeneration) =>
371 Effect.gen(function* () {
372 const generated = yield* textGeneration.generateThreadTitle({
373 cwd: process.cwd(),
374 message: "Name this thread.",
375 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
376 });
377
378 expect(generated.title).toBe("hello world");
379 }),
380 ),
381 );
382
383 it.effect("omits attachment metadata section when no attachments are provided", () =>
384 withFakeCodexEnv(
385 {
386 output: JSON.stringify({
387 branch: "fix/session-timeout",
388 }),
389 stdinMustNotContain: "Attachment metadata:",
390 },
391 (textGeneration) =>
392 Effect.gen(function* () {
393 const generated = yield* textGeneration.generateBranchName({
394 cwd: process.cwd(),
395 message: "Fix timeout behavior.",
396 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
397 });
398
399 expect(generated.branch).toBe("fix/session-timeout");
400 }),
401 ),
402 );
403
404 it.effect("passes image attachments through as codex image inputs", () =>
405 withFakeCodexEnv(
406 {
407 output: JSON.stringify({
408 branch: "fix/ui-regression",
409 }),
410 requireImage: true,
411 stdinMustContain: "Attachment metadata:",
412 },
413 (textGeneration) =>
414 Effect.gen(function* () {
415 const fs = yield* FileSystem.FileSystem;
416 const path = yield* Path.Path;
417 const { attachmentsDir } = yield* ServerConfig;
418 const attachmentId = `thread-branch-image-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
419 const attachmentPath = path.join(attachmentsDir, `${attachmentId}.png`);
420 yield* fs.makeDirectory(attachmentsDir, { recursive: true });
421 yield* fs.writeFile(attachmentPath, Buffer.from("hello"));
422
423 const generated = yield* textGeneration.generateBranchName({
424 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
425 cwd: process.cwd(),
426 message: "Fix layout bug from screenshot.",
427 attachments: [
428 {
429 type: "image",
430 id: attachmentId,
431 name: "bug.png",
432 mimeType: "image/png",
433 sizeBytes: 5,
434 },
435 ],
436 });
437
438 expect(generated.branch).toBe("fix/ui-regression");
439 }),
440 ),
441 );
442
443 it.effect("resolves persisted attachment ids to files for codex image inputs", () =>
444 withFakeCodexEnv(
445 {
446 output: JSON.stringify({
447 branch: "fix/ui-regression",
448 }),
449 requireImage: true,
450 },
451 (textGeneration) =>
452 Effect.gen(function* () {
453 const fs = yield* FileSystem.FileSystem;
454 const path = yield* Path.Path;
455 const { attachmentsDir } = yield* ServerConfig;
456 const attachmentId = `thread-1-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
457 const imagePath = path.join(attachmentsDir, `${attachmentId}.png`);
458 yield* fs.makeDirectory(attachmentsDir, { recursive: true });
459 yield* fs.writeFile(imagePath, Buffer.from("hello"));
460
461 const generated = yield* textGeneration
462 .generateBranchName({
463 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
464 cwd: process.cwd(),
465 message: "Fix layout bug from screenshot.",
466 attachments: [
467 {
468 type: "image",
469 id: attachmentId,
470 name: "bug.png",
471 mimeType: "image/png",
472 sizeBytes: 5,
473 },
474 ],
475 })
476 .pipe(
477 Effect.tap(() =>
478 fs.stat(imagePath).pipe(
479 Effect.map((fileInfo) => {
480 expect(fileInfo.type).toBe("File");
481 }),
482 ),
483 ),
484 Effect.ensuring(fs.remove(imagePath).pipe(Effect.catch(() => Effect.void))),
485 );
486
487 expect(generated.branch).toBe("fix/ui-regression");
488 }),
489 ),
490 );
491
492 it.effect("ignores missing attachment ids for codex image inputs", () =>
493 withFakeCodexEnv(
494 {
495 output: JSON.stringify({
496 branch: "fix/ui-regression",
497 }),
498 requireImage: true,
499 },
500 (textGeneration) =>
501 Effect.gen(function* () {
502 const fs = yield* FileSystem.FileSystem;
503 const path = yield* Path.Path;
504 const { attachmentsDir } = yield* ServerConfig;
505 const missingAttachmentId = `thread-missing-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
506 const missingPath = path.join(attachmentsDir, `${missingAttachmentId}.png`);
507 yield* fs.remove(missingPath).pipe(Effect.catch(() => Effect.void));
508
509 const result = yield* textGeneration
510 .generateBranchName({
511 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
512 cwd: process.cwd(),
513 message: "Fix layout bug from screenshot.",
514 attachments: [
515 {
516 type: "image",
517 id: missingAttachmentId,
518 name: "outside.png",
519 mimeType: "image/png",
520 sizeBytes: 5,
521 },
522 ],
523 })
524 .pipe(Effect.result);
525
526 expect(Result.isFailure(result)).toBe(true);
527 if (Result.isFailure(result)) {
528 expect(result.failure).toBeInstanceOf(TextGenerationError);
529 expect(result.failure.message).toContain("missing --image input");
530 }
531 }),
532 ),
533 );
534
535 it.effect(
536 "fails with typed TextGenerationError when codex returns wrong branch payload shape",
537 () =>
538 withFakeCodexEnv(
539 {
540 output: JSON.stringify({
541 title: "This is not a branch payload",
542 }),
543 },
544 (textGeneration) =>
545 Effect.gen(function* () {
546 const result = yield* textGeneration
547 .generateBranchName({
548 cwd: process.cwd(),
549 message: "Fix websocket reconnect flake",
550 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
551 })
552 .pipe(Effect.result);
553
554 expect(Result.isFailure(result)).toBe(true);
555 if (Result.isFailure(result)) {
556 expect(result.failure).toBeInstanceOf(TextGenerationError);
557 expect(result.failure.message).toContain("Codex returned invalid structured output");
558 }
559 }),
560 ),
561 );
562
563 it.effect("returns typed TextGenerationError when codex exits non-zero", () =>
564 withFakeCodexEnv(
565 {
566 output: JSON.stringify({ subject: "ignored", body: "" }),
567 exitCode: 1,
568 stderr: "codex execution failed",
569 },
570 (textGeneration) =>
571 Effect.gen(function* () {
572 const result = yield* textGeneration
573 .generateCommitMessage({
574 cwd: process.cwd(),
575 branch: "feature/codex-error",
576 stagedSummary: "M README.md",
577 stagedPatch: "diff --git a/README.md b/README.md",
578 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
579 })
580 .pipe(Effect.result);
581
582 expect(Result.isFailure(result)).toBe(true);
583 if (Result.isFailure(result)) {
584 expect(result.failure).toBeInstanceOf(TextGenerationError);
585 expect(result.failure.message).toContain(
586 "Codex CLI command failed: codex execution failed",
587 );
588 }
589 }),
590 ),
591 );
592});
apps/server/src/textGeneration/CodexTextGeneration.ts created+388
......@@ -0,0 +1,388 @@
1import { Effect, FileSystem, Option, Path, Random, Schema, Scope, Stream } from "effect";
2import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
3
4import { type CodexSettings, type ModelSelection } from "@t3tools/contracts";
5import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
6
7import { resolveAttachmentPath } from "../attachmentStore.ts";
8import { ServerConfig } from "../config.ts";
9import { expandHomePath } from "../pathExpansion.ts";
10import { TextGenerationError } from "@t3tools/contracts";
11import {
12 type BranchNameGenerationInput,
13 type ThreadTitleGenerationResult,
14 type TextGenerationShape,
15} from "./TextGeneration.ts";
16import {
17 buildBranchNamePrompt,
18 buildCommitMessagePrompt,
19 buildPrContentPrompt,
20 buildThreadTitlePrompt,
21} from "./TextGenerationPrompts.ts";
22import {
23 normalizeCliError,
24 sanitizeCommitSubject,
25 sanitizePrTitle,
26 sanitizeThreadTitle,
27 toJsonSchemaObject,
28} from "./TextGenerationUtils.ts";
29import {
30 getModelSelectionBooleanOptionValue,
31 getModelSelectionStringOptionValue,
32} from "@t3tools/shared/model";
33
34const CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT = "low";
35const CODEX_TIMEOUT_MS = 180_000;
36/**
37 * Build a Codex text-generation closure bound to a specific `CodexSettings`
38 * payload. See `makeCodexAdapter` for the overall per-instance rationale.
39 */
40export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(function* (
41 codexConfig: CodexSettings,
42 environment: NodeJS.ProcessEnv = process.env,
43) {
44 const fileSystem = yield* FileSystem.FileSystem;
45 const path = yield* Path.Path;
46 const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
47 const serverConfig = yield* Effect.service(ServerConfig);
48
49 type MaterializedImageAttachments = {
50 readonly imagePaths: ReadonlyArray<string>;
51 };
52
53 const readStreamAsString = <E>(
54 operation: string,
55 stream: Stream.Stream<Uint8Array, E>,
56 ): Effect.Effect<string, TextGenerationError> =>
57 stream.pipe(
58 Stream.decodeText(),
59 Stream.runFold(
60 () => "",
61 (acc, chunk) => acc + chunk,
62 ),
63 Effect.mapError((cause) =>
64 normalizeCliError("codex", operation, cause, "Failed to collect process output"),
65 ),
66 );
67
68 const writeTempFile = (
69 operation: string,
70 prefix: string,
71 content: string,
72 ): Effect.Effect<string, TextGenerationError, Scope.Scope> => {
73 return Effect.gen(function* () {
74 const tempFileId = yield* Random.nextUUIDv4;
75 return yield* fileSystem
76 .makeTempFileScoped({
77 prefix: `t3code-${prefix}-${process.pid}-${tempFileId}.tmp`,
78 })
79 .pipe(Effect.tap((filePath) => fileSystem.writeFileString(filePath, content)));
80 }).pipe(
81 Effect.mapError(
82 (cause) =>
83 new TextGenerationError({
84 operation,
85 detail: `Failed to write temp file`,
86 cause,
87 }),
88 ),
89 );
90 };
91
92 const safeUnlink = (filePath: string): Effect.Effect<void, never> =>
93 fileSystem.remove(filePath).pipe(Effect.catch(() => Effect.void));
94
95 const materializeImageAttachments = Effect.fn("materializeImageAttachments")(function* (
96 _operation:
97 | "generateCommitMessage"
98 | "generatePrContent"
99 | "generateBranchName"
100 | "generateThreadTitle",
101 attachments: BranchNameGenerationInput["attachments"],
102 ): Effect.fn.Return<MaterializedImageAttachments, TextGenerationError> {
103 if (!attachments || attachments.length === 0) {
104 return { imagePaths: [] };
105 }
106
107 const imagePaths: string[] = [];
108 for (const attachment of attachments) {
109 if (attachment.type !== "image") {
110 continue;
111 }
112
113 const resolvedPath = resolveAttachmentPath({
114 attachmentsDir: serverConfig.attachmentsDir,
115 attachment,
116 });
117 if (!resolvedPath || !path.isAbsolute(resolvedPath)) {
118 continue;
119 }
120 const fileInfo = yield* fileSystem
121 .stat(resolvedPath)
122 .pipe(Effect.catch(() => Effect.succeed(null)));
123 if (!fileInfo || fileInfo.type !== "File") {
124 continue;
125 }
126 imagePaths.push(resolvedPath);
127 }
128 return { imagePaths };
129 });
130
131 const runCodexJson = Effect.fn("runCodexJson")(function* <S extends Schema.Top>({
132 operation,
133 cwd,
134 prompt,
135 outputSchemaJson,
136 imagePaths = [],
137 cleanupPaths = [],
138 modelSelection,
139 }: {
140 operation:
141 | "generateCommitMessage"
142 | "generatePrContent"
143 | "generateBranchName"
144 | "generateThreadTitle";
145 cwd: string;
146 prompt: string;
147 outputSchemaJson: S;
148 imagePaths?: ReadonlyArray<string>;
149 cleanupPaths?: ReadonlyArray<string>;
150 modelSelection: ModelSelection;
151 }): Effect.fn.Return<S["Type"], TextGenerationError, S["DecodingServices"]> {
152 const schemaPath = yield* writeTempFile(
153 operation,
154 "codex-schema",
155 JSON.stringify(toJsonSchemaObject(outputSchemaJson)),
156 );
157 const outputPath = yield* writeTempFile(operation, "codex-output", "");
158
159 const runCodexCommand = Effect.fn("runCodexJson.runCodexCommand")(function* () {
160 const reasoningEffort =
161 getModelSelectionStringOptionValue(modelSelection, "reasoningEffort") ??
162 CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT;
163 const command = ChildProcess.make(
164 codexConfig.binaryPath || "codex",
165 [
166 "exec",
167 "--ephemeral",
168 "--skip-git-repo-check",
169 "-s",
170 "read-only",
171 "--model",
172 modelSelection.model,
173 "--config",
174 `model_reasoning_effort="${reasoningEffort}"`,
175 ...(getModelSelectionBooleanOptionValue(modelSelection, "fastMode") === true
176 ? ["--config", `service_tier="fast"`]
177 : []),
178 "--output-schema",
179 schemaPath,
180 "--output-last-message",
181 outputPath,
182 ...imagePaths.flatMap((imagePath) => ["--image", imagePath]),
183 "-",
184 ],
185 {
186 env: {
187 ...environment,
188 ...(codexConfig.homePath ? { CODEX_HOME: expandHomePath(codexConfig.homePath) } : {}),
189 },
190 cwd,
191 shell: process.platform === "win32",
192 stdin: {
193 stream: Stream.encodeText(Stream.make(prompt)),
194 },
195 },
196 );
197
198 const child = yield* commandSpawner
199 .spawn(command)
200 .pipe(
201 Effect.mapError((cause) =>
202 normalizeCliError("codex", operation, cause, "Failed to spawn Codex CLI process"),
203 ),
204 );
205
206 const [stdout, stderr, exitCode] = yield* Effect.all(
207 [
208 readStreamAsString(operation, child.stdout),
209 readStreamAsString(operation, child.stderr),
210 child.exitCode.pipe(
211 Effect.mapError((cause) =>
212 normalizeCliError("codex", operation, cause, "Failed to read Codex CLI exit code"),
213 ),
214 ),
215 ],
216 { concurrency: "unbounded" },
217 );
218
219 if (exitCode !== 0) {
220 const stderrDetail = stderr.trim();
221 const stdoutDetail = stdout.trim();
222 const detail = stderrDetail.length > 0 ? stderrDetail : stdoutDetail;
223 return yield* new TextGenerationError({
224 operation,
225 detail:
226 detail.length > 0
227 ? `Codex CLI command failed: ${detail}`
228 : `Codex CLI command failed with code ${exitCode}.`,
229 });
230 }
231 });
232
233 const cleanup = Effect.all(
234 [schemaPath, outputPath, ...cleanupPaths].map((filePath) => safeUnlink(filePath)),
235 {
236 concurrency: "unbounded",
237 },
238 ).pipe(Effect.asVoid);
239
240 return yield* Effect.gen(function* () {
241 yield* runCodexCommand().pipe(
242 Effect.scoped,
243 Effect.timeoutOption(CODEX_TIMEOUT_MS),
244 Effect.flatMap(
245 Option.match({
246 onNone: () =>
247 Effect.fail(
248 new TextGenerationError({ operation, detail: "Codex CLI request timed out." }),
249 ),
250 onSome: () => Effect.void,
251 }),
252 ),
253 );
254
255 return yield* fileSystem.readFileString(outputPath).pipe(
256 Effect.mapError(
257 (cause) =>
258 new TextGenerationError({
259 operation,
260 detail: "Failed to read Codex output file.",
261 cause,
262 }),
263 ),
264 Effect.flatMap(Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson))),
265 Effect.catchTag("SchemaError", (cause) =>
266 Effect.fail(
267 new TextGenerationError({
268 operation,
269 detail: "Codex returned invalid structured output.",
270 cause,
271 }),
272 ),
273 ),
274 );
275 }).pipe(Effect.ensuring(cleanup));
276 });
277
278 const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn(
279 "CodexTextGeneration.generateCommitMessage",
280 )(function* (input) {
281 const { prompt, outputSchema } = buildCommitMessagePrompt({
282 branch: input.branch,
283 stagedSummary: input.stagedSummary,
284 stagedPatch: input.stagedPatch,
285 includeBranch: input.includeBranch === true,
286 });
287
288 const generated = yield* runCodexJson({
289 operation: "generateCommitMessage",
290 cwd: input.cwd,
291 prompt,
292 outputSchemaJson: outputSchema,
293 modelSelection: input.modelSelection,
294 });
295
296 return {
297 subject: sanitizeCommitSubject(generated.subject),
298 body: generated.body.trim(),
299 ...("branch" in generated && typeof generated.branch === "string"
300 ? { branch: sanitizeFeatureBranchName(generated.branch) }
301 : {}),
302 };
303 });
304
305 const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn(
306 "CodexTextGeneration.generatePrContent",
307 )(function* (input) {
308 const { prompt, outputSchema } = buildPrContentPrompt({
309 baseBranch: input.baseBranch,
310 headBranch: input.headBranch,
311 commitSummary: input.commitSummary,
312 diffSummary: input.diffSummary,
313 diffPatch: input.diffPatch,
314 });
315
316 const generated = yield* runCodexJson({
317 operation: "generatePrContent",
318 cwd: input.cwd,
319 prompt,
320 outputSchemaJson: outputSchema,
321 modelSelection: input.modelSelection,
322 });
323
324 return {
325 title: sanitizePrTitle(generated.title),
326 body: generated.body.trim(),
327 };
328 });
329
330 const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn(
331 "CodexTextGeneration.generateBranchName",
332 )(function* (input) {
333 const { imagePaths } = yield* materializeImageAttachments(
334 "generateBranchName",
335 input.attachments,
336 );
337 const { prompt, outputSchema } = buildBranchNamePrompt({
338 message: input.message,
339 attachments: input.attachments,
340 });
341
342 const generated = yield* runCodexJson({
343 operation: "generateBranchName",
344 cwd: input.cwd,
345 prompt,
346 outputSchemaJson: outputSchema,
347 imagePaths,
348 modelSelection: input.modelSelection,
349 });
350
351 return {
352 branch: sanitizeBranchFragment(generated.branch),
353 };
354 });
355
356 const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn(
357 "CodexTextGeneration.generateThreadTitle",
358 )(function* (input) {
359 const { imagePaths } = yield* materializeImageAttachments(
360 "generateThreadTitle",
361 input.attachments,
362 );
363 const { prompt, outputSchema } = buildThreadTitlePrompt({
364 message: input.message,
365 attachments: input.attachments,
366 });
367
368 const generated = yield* runCodexJson({
369 operation: "generateThreadTitle",
370 cwd: input.cwd,
371 prompt,
372 outputSchemaJson: outputSchema,
373 imagePaths,
374 modelSelection: input.modelSelection,
375 });
376
377 return {
378 title: sanitizeThreadTitle(generated.title),
379 } satisfies ThreadTitleGenerationResult;
380 });
381
382 return {
383 generateCommitMessage,
384 generatePrContent,
385 generateBranchName,
386 generateThreadTitle,
387 } satisfies TextGenerationShape;
388});
apps/server/src/textGeneration/CursorTextGeneration.test.ts created+264
......@@ -0,0 +1,264 @@
1import * as path from "node:path";
2import * as os from "node:os";
3import { fileURLToPath } from "node:url";
4import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
5
6import * as NodeServices from "@effect/platform-node/NodeServices";
7import { it } from "@effect/vitest";
8import { Effect, Layer, Schema } from "effect";
9import { createModelSelection } from "@t3tools/shared/model";
10import { expect } from "vitest";
11
12import { CursorSettings, ProviderInstanceId } from "@t3tools/contracts";
13
14import { ServerConfig } from "../config.ts";
15import { type TextGenerationShape } from "./TextGeneration.ts";
16import { makeCursorTextGeneration } from "./CursorTextGeneration.ts";
17
18const __dirname = path.dirname(fileURLToPath(import.meta.url));
19const mockAgentPath = path.join(__dirname, "../../scripts/acp-mock-agent.ts");
20
21function shellSingleQuote(value: string): string {
22 return `'${value.replaceAll("'", `'"'"'`)}'`;
23}
24
25const CursorTextGenerationTestLayer = ServerConfig.layerTest(process.cwd(), {
26 prefix: "t3code-cursor-text-generation-test-",
27}).pipe(Layer.provideMerge(NodeServices.layer));
28
29function makeAcpAgentWrapper(dir: string, env: Record<string, string>): string {
30 const binDir = path.join(dir, "bin");
31 const agentPath = path.join(binDir, "agent");
32 mkdirSync(binDir, { recursive: true });
33 writeFileSync(
34 agentPath,
35 [
36 "#!/bin/sh",
37 ...Object.entries(env).map(([key, value]) => `export ${key}=${shellSingleQuote(value)}`),
38 'if [ "$1" != "acp" ]; then',
39 ' printf "%s\\n" "unexpected args: $*" >&2',
40 " exit 11",
41 "fi",
42 `exec bun ${JSON.stringify(mockAgentPath)}`,
43 "",
44 ].join("\n"),
45 "utf8",
46 );
47 chmodSync(agentPath, 0o755);
48 return agentPath;
49}
50
51function withFakeAcpAgent<A, E, R>(
52 env: Record<string, string>,
53 effectFn: (textGeneration: TextGenerationShape) => Effect.Effect<A, E, R>,
54) {
55 return Effect.gen(function* () {
56 const tempDir = mkdtempSync(path.join(os.tmpdir(), "t3code-cursor-text-acp-"));
57 yield* Effect.addFinalizer(() =>
58 Effect.sync(() => {
59 rmSync(tempDir, { recursive: true, force: true });
60 }),
61 );
62 const agentPath = makeAcpAgentWrapper(tempDir, env);
63 const config = Schema.decodeSync(CursorSettings)({ binaryPath: agentPath });
64 const textGeneration = yield* makeCursorTextGeneration(config);
65 return yield* effectFn(textGeneration);
66 }).pipe(Effect.scoped);
67}
68
69function waitForFileContent(path: string): Effect.Effect<string> {
70 return Effect.promise(async () => {
71 const deadline = Date.now() + 5_000;
72 for (;;) {
73 try {
74 return readFileSync(path, "utf8");
75 } catch (error) {
76 if (Date.now() >= deadline) {
77 throw error instanceof Error ? error : new Error(String(error));
78 }
79 }
80 await new Promise((resolve) => setTimeout(resolve, 25));
81 }
82 });
83}
84
85it.layer(CursorTextGenerationTestLayer)("CursorTextGeneration", (it) => {
86 it.effect("uses ACP model config options instead of raw CLI model ids", () => {
87 const requestLogDir = mkdtempSync(path.join(os.tmpdir(), "t3code-cursor-text-log-"));
88 const requestLogPath = path.join(requestLogDir, "requests.ndjson");
89
90 return withFakeAcpAgent(
91 {
92 T3_ACP_REQUEST_LOG_PATH: requestLogPath,
93 T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({
94 subject: "Add generated commit message",
95 body: "- verify cursor acp model config path",
96 }),
97 },
98 (textGeneration) =>
99 Effect.gen(function* () {
100 const generated = yield* textGeneration.generateCommitMessage({
101 cwd: process.cwd(),
102 branch: "feature/cursor-text-generation",
103 stagedSummary: "M apps/server/src/textGeneration/CursorTextGeneration.ts",
104 stagedPatch:
105 "diff --git a/apps/server/src/textGeneration/CursorTextGeneration.ts b/apps/server/src/textGeneration/CursorTextGeneration.ts",
106 modelSelection: {
107 ...createModelSelection(ProviderInstanceId.make("cursor"), "gpt-5.4", [
108 { id: "reasoning", value: "xhigh" },
109 { id: "fastMode", value: true },
110 { id: "contextWindow", value: "1m" },
111 ]),
112 },
113 });
114
115 expect(generated.subject).toBe("Add generated commit message");
116 expect(generated.body).toBe("- verify cursor acp model config path");
117
118 const requests = readFileSync(requestLogPath, "utf8")
119 .trim()
120 .split("\n")
121 .filter((line) => line.length > 0)
122 .map(
123 (line) => JSON.parse(line) as { method?: string; params?: Record<string, unknown> },
124 );
125
126 expect(
127 requests.find((request) => request.method === "initialize")?.params?.clientCapabilities,
128 ).toMatchObject({
129 _meta: {
130 parameterizedModelPicker: true,
131 },
132 });
133 expect(
134 requests.some(
135 (request) =>
136 request.method === "session/set_config_option" &&
137 request.params?.configId === "model" &&
138 request.params?.value === "gpt-5.4",
139 ),
140 ).toBe(true);
141 expect(
142 requests.some(
143 (request) =>
144 request.method === "session/set_config_option" &&
145 request.params?.configId === "reasoning" &&
146 request.params?.value === "extra-high",
147 ),
148 ).toBe(true);
149 expect(
150 requests.some(
151 (request) =>
152 request.method === "session/set_config_option" &&
153 request.params?.configId === "context" &&
154 request.params?.value === "1m",
155 ),
156 ).toBe(true);
157 expect(
158 requests.some(
159 (request) =>
160 request.method === "session/set_config_option" &&
161 request.params?.configId === "fast" &&
162 request.params?.value === "true",
163 ),
164 ).toBe(true);
165 expect(
166 requests.find((request) => request.method === "session/prompt")?.params?.prompt,
167 ).toEqual(
168 expect.arrayContaining([
169 expect.objectContaining({
170 type: "text",
171 text: expect.stringContaining("Staged patch:"),
172 }),
173 ]),
174 );
175
176 rmSync(requestLogDir, { recursive: true, force: true });
177 }),
178 );
179 });
180
181 it.effect("accepts json objects with extra assistant text around them", () =>
182 withFakeAcpAgent(
183 {
184 T3_ACP_PROMPT_RESPONSE_TEXT:
185 'Sure, here is the JSON:\n```json\n{\n "subject": "Update README dummy comment with attribution and date",\n "body": ""\n}\n```\nDone.',
186 },
187 (textGeneration) =>
188 Effect.gen(function* () {
189 const generated = yield* textGeneration.generateCommitMessage({
190 cwd: process.cwd(),
191 branch: "feature/cursor-noisy-json",
192 stagedSummary: "M README.md",
193 stagedPatch: "diff --git a/README.md b/README.md",
194 modelSelection: {
195 instanceId: ProviderInstanceId.make("cursor"),
196 model: "composer-2",
197 },
198 });
199
200 expect(generated.subject).toBe("Update README dummy comment with attribution and date");
201 expect(generated.body).toBe("");
202 }),
203 ),
204 );
205
206 it.effect("generates thread titles through Cursor ACP text generation", () =>
207 withFakeAcpAgent(
208 {
209 T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({
210 title: '"Trim reconnect spinner status after resume."',
211 }),
212 },
213 (textGeneration) =>
214 Effect.gen(function* () {
215 const generated = yield* textGeneration.generateThreadTitle({
216 cwd: process.cwd(),
217 message: "Fix the reconnect spinner after a resumed session.",
218 modelSelection: {
219 instanceId: ProviderInstanceId.make("cursor"),
220 model: "composer-2",
221 },
222 });
223
224 expect(generated.title).toBe("Trim reconnect spinner status after resume.");
225 }),
226 ),
227 );
228
229 it.effect("closes the ACP child process after text generation completes", () => {
230 const exitLogDir = mkdtempSync(path.join(os.tmpdir(), "t3code-cursor-text-exit-log-"));
231 const exitLogPath = path.join(exitLogDir, "exit.log");
232
233 return withFakeAcpAgent(
234 {
235 T3_ACP_EXIT_LOG_PATH: exitLogPath,
236 T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({
237 subject: "Close runtime after generation",
238 body: "",
239 }),
240 },
241 (textGeneration) =>
242 Effect.gen(function* () {
243 const generated = yield* textGeneration.generateCommitMessage({
244 cwd: process.cwd(),
245 branch: "feature/cursor-runtime-close",
246 stagedSummary: "M apps/server/src/textGeneration/CursorTextGeneration.ts",
247 stagedPatch:
248 "diff --git a/apps/server/src/textGeneration/CursorTextGeneration.ts b/apps/server/src/textGeneration/CursorTextGeneration.ts",
249 modelSelection: {
250 instanceId: ProviderInstanceId.make("cursor"),
251 model: "composer-2",
252 },
253 });
254
255 expect(generated.subject).toBe("Close runtime after generation");
256
257 const exitLog = yield* waitForFileContent(exitLogPath);
258 expect(exitLog).toContain("exit:0");
259
260 rmSync(exitLogDir, { recursive: true, force: true });
261 }),
262 );
263 });
264});
apps/server/src/textGeneration/CursorTextGeneration.ts created+276
......@@ -0,0 +1,276 @@
1import { Effect, Option, Ref, Schema } from "effect";
2import { ChildProcessSpawner } from "effect/unstable/process";
3
4import { type CursorSettings, type ModelSelection } from "@t3tools/contracts";
5import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
6
7import { TextGenerationError } from "@t3tools/contracts";
8import { type ThreadTitleGenerationResult, type TextGenerationShape } from "./TextGeneration.ts";
9import {
10 buildBranchNamePrompt,
11 buildCommitMessagePrompt,
12 buildPrContentPrompt,
13 buildThreadTitlePrompt,
14} from "./TextGenerationPrompts.ts";
15import {
16 extractJsonObject,
17 sanitizeCommitSubject,
18 sanitizePrTitle,
19 sanitizeThreadTitle,
20} from "./TextGenerationUtils.ts";
21import {
22 applyCursorAcpModelSelection,
23 makeCursorAcpRuntime,
24} from "../provider/acp/CursorAcpSupport.ts";
25
26const CURSOR_TIMEOUT_MS = 180_000;
27
28function mapCursorAcpError(
29 operation:
30 | "generateCommitMessage"
31 | "generatePrContent"
32 | "generateBranchName"
33 | "generateThreadTitle",
34 detail: string,
35 cause: unknown,
36): TextGenerationError {
37 return new TextGenerationError({
38 operation,
39 detail,
40 ...(cause !== undefined ? { cause } : {}),
41 });
42}
43
44function isTextGenerationError(error: unknown): error is TextGenerationError {
45 return (
46 typeof error === "object" &&
47 error !== null &&
48 "_tag" in error &&
49 error._tag === "TextGenerationError"
50 );
51}
52
53/**
54 * Build a Cursor text-generation closure bound to a specific `CursorSettings`
55 * payload. See `makeCodexAdapter` for the overall per-instance rationale.
56 */
57export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(function* (
58 cursorSettings: CursorSettings,
59 environment: NodeJS.ProcessEnv = process.env,
60) {
61 const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
62
63 const runCursorJson = <S extends Schema.Top>({
64 operation,
65 cwd,
66 prompt,
67 outputSchemaJson,
68 modelSelection,
69 }: {
70 operation:
71 | "generateCommitMessage"
72 | "generatePrContent"
73 | "generateBranchName"
74 | "generateThreadTitle";
75 cwd: string;
76 prompt: string;
77 outputSchemaJson: S;
78 modelSelection: ModelSelection;
79 }): Effect.Effect<S["Type"], TextGenerationError, S["DecodingServices"]> =>
80 Effect.gen(function* () {
81 const outputRef = yield* Ref.make("");
82 const runtime = yield* makeCursorAcpRuntime({
83 cursorSettings,
84 environment,
85 childProcessSpawner: commandSpawner,
86 cwd,
87 clientInfo: { name: "t3-code-git-text", version: "0.0.0" },
88 });
89
90 yield* runtime.handleSessionUpdate((notification) => {
91 const update = notification.update;
92 if (update.sessionUpdate !== "agent_message_chunk") {
93 return Effect.void;
94 }
95 const content = update.content;
96 if (content.type !== "text") {
97 return Effect.void;
98 }
99 return Ref.update(outputRef, (current) => current + content.text);
100 });
101
102 const promptResult = yield* Effect.gen(function* () {
103 yield* runtime.start();
104 yield* Effect.ignore(runtime.setMode("ask"));
105 yield* applyCursorAcpModelSelection({
106 runtime,
107 model: modelSelection.model,
108 selections: modelSelection.options,
109 mapError: ({ cause, configId, step }) =>
110 mapCursorAcpError(
111 operation,
112 step === "set-config-option"
113 ? `Failed to set Cursor ACP config option "${configId}" for text generation.`
114 : "Failed to set Cursor ACP base model for text generation.",
115 cause,
116 ),
117 });
118
119 return yield* runtime.prompt({
120 prompt: [{ type: "text", text: prompt }],
121 });
122 }).pipe(
123 Effect.timeoutOption(CURSOR_TIMEOUT_MS),
124 Effect.flatMap(
125 Option.match({
126 onNone: () =>
127 Effect.fail(
128 new TextGenerationError({
129 operation,
130 detail: "Cursor Agent request timed out.",
131 }),
132 ),
133 onSome: (value) => Effect.succeed(value),
134 }),
135 ),
136 Effect.mapError((cause) =>
137 isTextGenerationError(cause)
138 ? cause
139 : mapCursorAcpError(operation, "Cursor ACP request failed.", cause),
140 ),
141 );
142
143 const rawResult = (yield* Ref.get(outputRef)).trim();
144 if (!rawResult) {
145 return yield* new TextGenerationError({
146 operation,
147 detail:
148 promptResult.stopReason === "cancelled"
149 ? "Cursor ACP request was cancelled."
150 : "Cursor Agent returned empty output.",
151 });
152 }
153
154 return yield* Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson))(
155 extractJsonObject(rawResult),
156 ).pipe(
157 Effect.catchTag("SchemaError", (cause) =>
158 Effect.fail(
159 new TextGenerationError({
160 operation,
161 detail: "Cursor Agent returned invalid structured output.",
162 cause,
163 }),
164 ),
165 ),
166 );
167 }).pipe(
168 Effect.mapError((cause) =>
169 isTextGenerationError(cause)
170 ? cause
171 : mapCursorAcpError(operation, "Cursor ACP text generation failed.", cause),
172 ),
173 Effect.scoped,
174 );
175
176 const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn(
177 "CursorTextGeneration.generateCommitMessage",
178 )(function* (input) {
179 const { prompt, outputSchema } = buildCommitMessagePrompt({
180 branch: input.branch,
181 stagedSummary: input.stagedSummary,
182 stagedPatch: input.stagedPatch,
183 includeBranch: input.includeBranch === true,
184 });
185
186 const generated = yield* runCursorJson({
187 operation: "generateCommitMessage",
188 cwd: input.cwd,
189 prompt,
190 outputSchemaJson: outputSchema,
191 modelSelection: input.modelSelection,
192 });
193
194 return {
195 subject: sanitizeCommitSubject(generated.subject),
196 body: generated.body.trim(),
197 ...("branch" in generated && typeof generated.branch === "string"
198 ? { branch: sanitizeFeatureBranchName(generated.branch) }
199 : {}),
200 };
201 });
202
203 const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn(
204 "CursorTextGeneration.generatePrContent",
205 )(function* (input) {
206 const { prompt, outputSchema } = buildPrContentPrompt({
207 baseBranch: input.baseBranch,
208 headBranch: input.headBranch,
209 commitSummary: input.commitSummary,
210 diffSummary: input.diffSummary,
211 diffPatch: input.diffPatch,
212 });
213
214 const generated = yield* runCursorJson({
215 operation: "generatePrContent",
216 cwd: input.cwd,
217 prompt,
218 outputSchemaJson: outputSchema,
219 modelSelection: input.modelSelection,
220 });
221
222 return {
223 title: sanitizePrTitle(generated.title),
224 body: generated.body.trim(),
225 };
226 });
227
228 const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn(
229 "CursorTextGeneration.generateBranchName",
230 )(function* (input) {
231 const { prompt, outputSchema } = buildBranchNamePrompt({
232 message: input.message,
233 attachments: input.attachments,
234 });
235
236 const generated = yield* runCursorJson({
237 operation: "generateBranchName",
238 cwd: input.cwd,
239 prompt,
240 outputSchemaJson: outputSchema,
241 modelSelection: input.modelSelection,
242 });
243
244 return {
245 branch: sanitizeBranchFragment(generated.branch),
246 };
247 });
248
249 const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn(
250 "CursorTextGeneration.generateThreadTitle",
251 )(function* (input) {
252 const { prompt, outputSchema } = buildThreadTitlePrompt({
253 message: input.message,
254 attachments: input.attachments,
255 });
256
257 const generated = yield* runCursorJson({
258 operation: "generateThreadTitle",
259 cwd: input.cwd,
260 prompt,
261 outputSchemaJson: outputSchema,
262 modelSelection: input.modelSelection,
263 });
264
265 return {
266 title: sanitizeThreadTitle(generated.title),
267 } satisfies ThreadTitleGenerationResult;
268 });
269
270 return {
271 generateCommitMessage,
272 generatePrContent,
273 generateBranchName,
274 generateThreadTitle,
275 } satisfies TextGenerationShape;
276});
apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts created+346
......@@ -0,0 +1,346 @@
1import { OpenCodeSettings, ProviderInstanceId } from "@t3tools/contracts";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { it } from "@effect/vitest";
4import { Duration, Effect, Layer, Schema } from "effect";
5import { TestClock } from "effect/testing";
6import { NetService } from "@t3tools/shared/Net";
7import { beforeEach, expect } from "vitest";
8
9import { ServerConfig } from "../config.ts";
10import {
11 OpenCodeRuntime,
12 OpenCodeRuntimeError,
13 type OpenCodeRuntimeShape,
14} from "../provider/opencodeRuntime.ts";
15import { type TextGenerationShape } from "./TextGeneration.ts";
16import { makeOpenCodeTextGeneration } from "./OpenCodeTextGeneration.ts";
17
18const runtimeMock = {
19 state: {
20 startCalls: [] as string[],
21 promptUrls: [] as string[],
22 authHeaders: [] as Array<string | null>,
23 closeCalls: [] as string[],
24 promptResult: undefined as
25 | { data?: { info?: { error?: unknown }; parts?: Array<{ type: string; text?: string }> } }
26 | undefined,
27 },
28 reset() {
29 this.state.startCalls.length = 0;
30 this.state.promptUrls.length = 0;
31 this.state.authHeaders.length = 0;
32 this.state.closeCalls.length = 0;
33 this.state.promptResult = undefined;
34 },
35};
36
37const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
38 startOpenCodeServerProcess: ({ binaryPath }) =>
39 Effect.gen(function* () {
40 const index = runtimeMock.state.startCalls.length + 1;
41 const url = `http://127.0.0.1:${4_300 + index}`;
42 runtimeMock.state.startCalls.push(binaryPath);
43 // The production runtime binds server lifetime to the caller's scope.
44 // Mirror that here so the closeCalls probe observes scope close.
45 yield* Effect.addFinalizer(() =>
46 Effect.sync(() => {
47 runtimeMock.state.closeCalls.push(url);
48 }),
49 );
50 return {
51 url,
52 exitCode: Effect.never,
53 };
54 }),
55 connectToOpenCodeServer: ({ serverUrl }) =>
56 Effect.succeed({
57 url: serverUrl ?? "http://127.0.0.1:4301",
58 exitCode: null,
59 external: Boolean(serverUrl),
60 }),
61 runOpenCodeCommand: () => Effect.succeed({ stdout: "", stderr: "", code: 0 }),
62 createOpenCodeSdkClient: ({ baseUrl, serverPassword }) =>
63 ({
64 session: {
65 create: async () => ({ data: { id: `${baseUrl}/session` } }),
66 prompt: async () => {
67 runtimeMock.state.promptUrls.push(baseUrl);
68 runtimeMock.state.authHeaders.push(
69 serverPassword ? `Basic ${btoa(`opencode:${serverPassword}`)}` : null,
70 );
71 return (
72 runtimeMock.state.promptResult ?? {
73 data: {
74 parts: [
75 {
76 type: "text",
77 text: JSON.stringify({
78 subject: "Improve OpenCode reuse",
79 body: "Reuse one server for the full action.",
80 }),
81 },
82 ],
83 },
84 }
85 );
86 },
87 },
88 }) as unknown as ReturnType<OpenCodeRuntimeShape["createOpenCodeSdkClient"]>,
89 loadOpenCodeInventory: () =>
90 Effect.fail(
91 new OpenCodeRuntimeError({
92 operation: "loadOpenCodeInventory",
93 detail: "OpenCodeRuntimeTestDouble.loadOpenCodeInventory not used in this test",
94 cause: null,
95 }),
96 ),
97};
98
99const DEFAULT_TEST_MODEL_SELECTION = {
100 instanceId: ProviderInstanceId.make("opencode"),
101 model: "openai/gpt-5",
102};
103
104const OPENCODE_TEXT_GENERATION_IDLE_TTL_MS = 30_000;
105
106const OpenCodeTextGenerationTestLayer = Layer.succeed(
107 OpenCodeRuntime,
108 OpenCodeRuntimeTestDouble,
109).pipe(
110 Layer.provideMerge(
111 ServerConfig.layerTest(process.cwd(), {
112 prefix: "t3code-opencode-text-generation-test-",
113 }),
114 ),
115 Layer.provideMerge(NetService.layer),
116 Layer.provideMerge(NodeServices.layer),
117);
118
119const OpenCodeTextGenerationExistingServerTestLayer = Layer.succeed(
120 OpenCodeRuntime,
121 OpenCodeRuntimeTestDouble,
122).pipe(
123 Layer.provideMerge(
124 ServerConfig.layerTest(process.cwd(), {
125 prefix: "t3code-opencode-text-generation-existing-server-test-",
126 }),
127 ),
128 Layer.provideMerge(NetService.layer),
129 Layer.provideMerge(NodeServices.layer),
130);
131
132const DEFAULT_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({
133 binaryPath: "fake-opencode",
134});
135const EXISTING_SERVER_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({
136 binaryPath: "fake-opencode",
137 serverUrl: "http://127.0.0.1:9999",
138 serverPassword: "secret-password",
139});
140
141function withOpenCodeTextGeneration<A, E, R>(
142 settings: OpenCodeSettings,
143 effectFn: (textGeneration: TextGenerationShape) => Effect.Effect<A, E, R>,
144) {
145 return Effect.gen(function* () {
146 const textGeneration = yield* makeOpenCodeTextGeneration(settings);
147 return yield* effectFn(textGeneration);
148 }).pipe(Effect.scoped);
149}
150
151beforeEach(() => {
152 runtimeMock.reset();
153});
154
155const advanceIdleClock = Effect.gen(function* () {
156 yield* Effect.yieldNow;
157 yield* TestClock.adjust(Duration.millis(OPENCODE_TEXT_GENERATION_IDLE_TTL_MS + 1));
158 yield* Effect.yieldNow;
159});
160
161it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGeneration", (it) => {
162 it.effect("reuses a warm server across back-to-back requests and closes it after idling", () =>
163 withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) =>
164 Effect.gen(function* () {
165 yield* textGeneration.generateCommitMessage({
166 cwd: process.cwd(),
167 branch: "feature/opencode-reuse",
168 stagedSummary: "M README.md",
169 stagedPatch: "diff --git a/README.md b/README.md",
170 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
171 });
172 yield* textGeneration.generateCommitMessage({
173 cwd: process.cwd(),
174 branch: "feature/opencode-reuse",
175 stagedSummary: "M README.md",
176 stagedPatch: "diff --git a/README.md b/README.md",
177 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
178 });
179
180 expect(runtimeMock.state.startCalls).toEqual(["fake-opencode"]);
181 expect(runtimeMock.state.promptUrls).toEqual([
182 "http://127.0.0.1:4301",
183 "http://127.0.0.1:4301",
184 ]);
185 expect(runtimeMock.state.closeCalls).toEqual([]);
186
187 yield* advanceIdleClock;
188
189 expect(runtimeMock.state.closeCalls).toEqual(["http://127.0.0.1:4301"]);
190 }),
191 ).pipe(Effect.provide(TestClock.layer())),
192 );
193
194 it.effect("starts a new server after the warm server idles out", () =>
195 withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) =>
196 Effect.gen(function* () {
197 yield* textGeneration.generateCommitMessage({
198 cwd: process.cwd(),
199 branch: "feature/opencode-reuse",
200 stagedSummary: "M README.md",
201 stagedPatch: "diff --git a/README.md b/README.md",
202 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
203 });
204
205 yield* advanceIdleClock;
206
207 yield* textGeneration.generateCommitMessage({
208 cwd: process.cwd(),
209 branch: "feature/opencode-reuse",
210 stagedSummary: "M README.md",
211 stagedPatch: "diff --git a/README.md b/README.md",
212 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
213 });
214
215 expect(runtimeMock.state.startCalls).toEqual(["fake-opencode", "fake-opencode"]);
216 expect(runtimeMock.state.promptUrls).toEqual([
217 "http://127.0.0.1:4301",
218 "http://127.0.0.1:4302",
219 ]);
220 expect(runtimeMock.state.closeCalls).toEqual(["http://127.0.0.1:4301"]);
221 }),
222 ).pipe(Effect.provide(TestClock.layer())),
223 );
224
225 it.effect("returns a typed empty-output error when OpenCode returns no text parts", () =>
226 withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) =>
227 Effect.gen(function* () {
228 runtimeMock.state.promptResult = { data: {} };
229
230 const error = yield* textGeneration
231 .generateCommitMessage({
232 cwd: process.cwd(),
233 branch: "feature/opencode-reuse",
234 stagedSummary: "M README.md",
235 stagedPatch: "diff --git a/README.md b/README.md",
236 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
237 })
238 .pipe(Effect.flip);
239
240 expect(error.message).toContain("OpenCode returned empty output.");
241 }),
242 ),
243 );
244
245 it.effect("parses JSON returned as plain text output", () =>
246 withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) =>
247 Effect.gen(function* () {
248 runtimeMock.state.promptResult = {
249 data: {
250 parts: [
251 {
252 type: "text",
253 text: 'Here is the result:\n{"subject":"Tighten OpenCode parsing","body":"Handle JSON text output locally."}',
254 },
255 ],
256 },
257 };
258
259 const result = yield* textGeneration.generateCommitMessage({
260 cwd: process.cwd(),
261 branch: "feature/opencode-reuse",
262 stagedSummary: "M README.md",
263 stagedPatch: "diff --git a/README.md b/README.md",
264 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
265 });
266
267 expect(result).toEqual({
268 subject: "Tighten OpenCode parsing",
269 body: "Handle JSON text output locally.",
270 });
271 }),
272 ),
273 );
274
275 it.effect("surfaces the upstream OpenCode structured-output error message", () =>
276 withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) =>
277 Effect.gen(function* () {
278 runtimeMock.state.promptResult = {
279 data: {
280 info: {
281 error: {
282 name: "StructuredOutputError",
283 data: {
284 message: "Model did not produce structured output",
285 retries: 2,
286 },
287 },
288 },
289 },
290 };
291
292 const error = yield* textGeneration
293 .generateCommitMessage({
294 cwd: process.cwd(),
295 branch: "feature/opencode-reuse",
296 stagedSummary: "M README.md",
297 stagedPatch: "diff --git a/README.md b/README.md",
298 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
299 })
300 .pipe(Effect.flip);
301
302 expect(error.message).toContain("Model did not produce structured output");
303 }),
304 ),
305 );
306});
307
308it.layer(OpenCodeTextGenerationExistingServerTestLayer)(
309 "OpenCodeTextGeneration with configured server URL",
310 (it) => {
311 it.effect("reuses a configured OpenCode server URL without spawning or applying idle TTL", () =>
312 withOpenCodeTextGeneration(EXISTING_SERVER_OPENCODE_SETTINGS, (textGeneration) =>
313 Effect.gen(function* () {
314 yield* textGeneration.generateCommitMessage({
315 cwd: process.cwd(),
316 branch: "feature/opencode-reuse",
317 stagedSummary: "M README.md",
318 stagedPatch: "diff --git a/README.md b/README.md",
319 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
320 });
321 yield* textGeneration.generateCommitMessage({
322 cwd: process.cwd(),
323 branch: "feature/opencode-reuse",
324 stagedSummary: "M README.md",
325 stagedPatch: "diff --git a/README.md b/README.md",
326 modelSelection: DEFAULT_TEST_MODEL_SELECTION,
327 });
328
329 expect(runtimeMock.state.startCalls).toEqual([]);
330 expect(runtimeMock.state.promptUrls).toEqual([
331 "http://127.0.0.1:9999",
332 "http://127.0.0.1:9999",
333 ]);
334 expect(runtimeMock.state.authHeaders).toEqual([
335 `Basic ${btoa("opencode:secret-password")}`,
336 `Basic ${btoa("opencode:secret-password")}`,
337 ]);
338
339 yield* advanceIdleClock;
340
341 expect(runtimeMock.state.closeCalls).toEqual([]);
342 }),
343 ).pipe(Effect.provide(TestClock.layer())),
344 );
345 },
346);
apps/server/src/textGeneration/OpenCodeTextGeneration.ts created+464
......@@ -0,0 +1,464 @@
1import { Effect, Exit, Fiber, Schema, Scope } from "effect";
2import * as Semaphore from "effect/Semaphore";
3
4import {
5 TextGenerationError,
6 type ChatAttachment,
7 type ModelSelection,
8 type OpenCodeSettings,
9} from "@t3tools/contracts";
10import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git";
11import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";
12
13import { ServerConfig } from "../config.ts";
14import { resolveAttachmentPath } from "../attachmentStore.ts";
15import {
16 buildBranchNamePrompt,
17 buildCommitMessagePrompt,
18 buildPrContentPrompt,
19 buildThreadTitlePrompt,
20} from "./TextGenerationPrompts.ts";
21import { type TextGenerationShape } from "./TextGeneration.ts";
22import {
23 extractJsonObject,
24 sanitizeCommitSubject,
25 sanitizePrTitle,
26 sanitizeThreadTitle,
27} from "./TextGenerationUtils.ts";
28import {
29 OpenCodeRuntime,
30 type OpenCodeServerConnection,
31 type OpenCodeServerProcess,
32 openCodeRuntimeErrorDetail,
33 parseOpenCodeModelSlug,
34 toOpenCodeFileParts,
35} from "../provider/opencodeRuntime.ts";
36
37const OPENCODE_TEXT_GENERATION_IDLE_TTL = "30 seconds";
38
39function getOpenCodePromptErrorMessage(error: unknown): string | null {
40 if (!error || typeof error !== "object") {
41 return null;
42 }
43
44 const message =
45 "data" in error &&
46 error.data &&
47 typeof error.data === "object" &&
48 "message" in error.data &&
49 typeof error.data.message === "string"
50 ? error.data.message.trim()
51 : "";
52 if (message.length > 0) {
53 return message;
54 }
55
56 if ("name" in error && typeof error.name === "string") {
57 const name = error.name.trim();
58 return name.length > 0 ? name : null;
59 }
60
61 return null;
62}
63
64function getOpenCodeTextResponse(parts: ReadonlyArray<unknown> | undefined): string {
65 return (parts ?? [])
66 .flatMap((part) => {
67 if (!part || typeof part !== "object") {
68 return [];
69 }
70 if (!("type" in part) || part.type !== "text") {
71 return [];
72 }
73 if (!("text" in part) || typeof part.text !== "string") {
74 return [];
75 }
76 return [part.text];
77 })
78 .join("")
79 .trim();
80}
81
82interface SharedOpenCodeTextGenerationServerState {
83 server: OpenCodeServerProcess | null;
84 /**
85 * The scope that owns the shared server's lifetime. Closing this scope
86 * terminates the OpenCode child process and interrupts any fibers the
87 * runtime forked during startup. We don't hold a `close()` function on
88 * the server handle anymore — the scope is the only lifecycle handle.
89 */
90 serverScope: Scope.Closeable | null;
91 binaryPath: string | null;
92 activeRequests: number;
93 idleCloseFiber: Fiber.Fiber<void, never> | null;
94}
95
96export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration")(function* (
97 openCodeSettings: OpenCodeSettings,
98 environment: NodeJS.ProcessEnv = process.env,
99) {
100 const serverConfig = yield* ServerConfig;
101 const openCodeRuntime = yield* OpenCodeRuntime;
102 const idleFiberScope = yield* Effect.acquireRelease(Scope.make(), (scope) =>
103 Scope.close(scope, Exit.void),
104 );
105 const sharedServerMutex = yield* Semaphore.make(1);
106 const sharedServerState: SharedOpenCodeTextGenerationServerState = {
107 server: null,
108 serverScope: null,
109 binaryPath: null,
110 activeRequests: 0,
111 idleCloseFiber: null,
112 };
113
114 const closeSharedServer = Effect.fn("closeSharedServer")(function* () {
115 const scope = sharedServerState.serverScope;
116 sharedServerState.server = null;
117 sharedServerState.serverScope = null;
118 sharedServerState.binaryPath = null;
119 if (scope !== null) {
120 yield* Scope.close(scope, Exit.void).pipe(Effect.ignore);
121 }
122 });
123
124 const cancelIdleCloseFiber = Effect.fn("cancelIdleCloseFiber")(function* () {
125 const idleCloseFiber = sharedServerState.idleCloseFiber;
126 sharedServerState.idleCloseFiber = null;
127 if (idleCloseFiber !== null) {
128 yield* Fiber.interrupt(idleCloseFiber).pipe(Effect.ignore);
129 }
130 });
131
132 const scheduleIdleClose = Effect.fn("scheduleIdleClose")(function* (
133 server: OpenCodeServerProcess,
134 ) {
135 yield* cancelIdleCloseFiber();
136 const fiber = yield* Effect.sleep(OPENCODE_TEXT_GENERATION_IDLE_TTL).pipe(
137 Effect.andThen(
138 sharedServerMutex.withPermit(
139 Effect.gen(function* () {
140 if (sharedServerState.server !== server || sharedServerState.activeRequests > 0) {
141 return;
142 }
143 sharedServerState.idleCloseFiber = null;
144 yield* closeSharedServer();
145 }),
146 ),
147 ),
148 Effect.forkIn(idleFiberScope),
149 );
150 sharedServerState.idleCloseFiber = fiber;
151 });
152
153 const acquireSharedServer = (input: {
154 readonly binaryPath: string;
155 readonly operation:
156 | "generateCommitMessage"
157 | "generatePrContent"
158 | "generateBranchName"
159 | "generateThreadTitle";
160 }) =>
161 sharedServerMutex.withPermit(
162 Effect.gen(function* () {
163 yield* cancelIdleCloseFiber();
164
165 const existingServer = sharedServerState.server;
166 if (existingServer !== null) {
167 if (
168 sharedServerState.binaryPath !== input.binaryPath &&
169 sharedServerState.activeRequests === 0
170 ) {
171 yield* closeSharedServer();
172 } else {
173 if (sharedServerState.binaryPath !== input.binaryPath) {
174 yield* Effect.logWarning(
175 "OpenCode shared server binary path mismatch: requested " +
176 input.binaryPath +
177 " but active server uses " +
178 sharedServerState.binaryPath +
179 "; reusing existing server because there are active requests",
180 );
181 }
182 sharedServerState.activeRequests += 1;
183 return existingServer;
184 }
185 }
186
187 // Create a fresh scope that owns this shared server. The runtime
188 // will attach its child-process and fiber finalizers to this scope;
189 // closing it kills the server and interrupts those fibers.
190 //
191 // The `Scope.make` / spawn / record-or-close transitions run inside
192 // `uninterruptibleMask` so an interrupt arriving between any two
193 // steps can't orphan the scope (and the child process attached to
194 // it) before we either close it on failure or hand ownership to
195 // `sharedServerState`. `restore` keeps the actual spawn
196 // interruptible; an interrupt during the spawn is captured by
197 // `Effect.exit` and drives us through the failure branch that
198 // closes the fresh scope.
199 return yield* Effect.uninterruptibleMask((restore) =>
200 Effect.gen(function* () {
201 const serverScope = yield* Scope.make();
202 const startedExit = yield* Effect.exit(
203 restore(
204 openCodeRuntime
205 .startOpenCodeServerProcess({
206 binaryPath: input.binaryPath,
207 environment,
208 })
209 .pipe(
210 Effect.provideService(Scope.Scope, serverScope),
211 Effect.mapError(
212 (cause) =>
213 new TextGenerationError({
214 operation: input.operation,
215 detail: openCodeRuntimeErrorDetail(cause),
216 cause,
217 }),
218 ),
219 ),
220 ),
221 );
222 if (startedExit._tag === "Failure") {
223 yield* Scope.close(serverScope, Exit.void).pipe(Effect.ignore);
224 return yield* Effect.failCause(startedExit.cause);
225 }
226
227 const server = startedExit.value;
228 sharedServerState.server = server;
229 sharedServerState.serverScope = serverScope;
230 sharedServerState.binaryPath = input.binaryPath;
231 sharedServerState.activeRequests = 1;
232 return server;
233 }),
234 );
235 }),
236 );
237
238 const releaseSharedServer = (server: OpenCodeServerProcess) =>
239 sharedServerMutex.withPermit(
240 Effect.gen(function* () {
241 if (sharedServerState.server !== server) {
242 return;
243 }
244 sharedServerState.activeRequests = Math.max(0, sharedServerState.activeRequests - 1);
245 if (sharedServerState.activeRequests === 0) {
246 yield* scheduleIdleClose(server);
247 }
248 }),
249 );
250
251 // Module-level finalizer: on layer shutdown, cancel the idle close fiber
252 // and close the shared server scope. Consumers therefore cannot leak
253 // the shared OpenCode server by forgetting to call anything.
254 yield* Effect.addFinalizer(() =>
255 sharedServerMutex.withPermit(
256 Effect.gen(function* () {
257 yield* cancelIdleCloseFiber();
258 sharedServerState.activeRequests = 0;
259 yield* closeSharedServer();
260 }),
261 ),
262 );
263
264 const runOpenCodeJson = Effect.fn("runOpenCodeJson")(function* <S extends Schema.Top>(input: {
265 readonly operation:
266 | "generateCommitMessage"
267 | "generatePrContent"
268 | "generateBranchName"
269 | "generateThreadTitle";
270 readonly cwd: string;
271 readonly prompt: string;
272 readonly outputSchemaJson: S;
273 readonly modelSelection: ModelSelection;
274 readonly attachments?: ReadonlyArray<ChatAttachment> | undefined;
275 }) {
276 const parsedModel = parseOpenCodeModelSlug(input.modelSelection.model);
277 if (!parsedModel) {
278 return yield* new TextGenerationError({
279 operation: input.operation,
280 detail: "OpenCode model selection must use the 'provider/model' format.",
281 });
282 }
283
284 const fileParts = toOpenCodeFileParts({
285 attachments: input.attachments,
286 resolveAttachmentPath: (attachment) =>
287 resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }),
288 });
289
290 const runAgainstServer = (server: Pick<OpenCodeServerConnection, "url">) =>
291 Effect.tryPromise({
292 try: async () => {
293 const client = openCodeRuntime.createOpenCodeSdkClient({
294 baseUrl: server.url,
295 directory: input.cwd,
296 ...(openCodeSettings.serverUrl.length > 0 && openCodeSettings.serverPassword
297 ? { serverPassword: openCodeSettings.serverPassword }
298 : {}),
299 });
300 const session = await client.session.create({
301 title: `T3 Code ${input.operation}`,
302 permission: [{ permission: "*", pattern: "*", action: "deny" }],
303 });
304 if (!session.data) {
305 throw new Error("OpenCode session.create returned no session payload.");
306 }
307 const selectedAgent = getModelSelectionStringOptionValue(input.modelSelection, "agent");
308 const selectedVariant = getModelSelectionStringOptionValue(
309 input.modelSelection,
310 "variant",
311 );
312
313 const result = await client.session.prompt({
314 sessionID: session.data.id,
315 model: parsedModel,
316 ...(selectedAgent ? { agent: selectedAgent } : {}),
317 ...(selectedVariant ? { variant: selectedVariant } : {}),
318 parts: [{ type: "text", text: input.prompt }, ...fileParts],
319 });
320 const info = result.data?.info;
321 const errorMessage = getOpenCodePromptErrorMessage(info?.error);
322 if (errorMessage) {
323 throw new Error(errorMessage);
324 }
325 const rawText = getOpenCodeTextResponse(result.data?.parts);
326 if (rawText.length === 0) {
327 throw new Error("OpenCode returned empty output.");
328 }
329 return rawText;
330 },
331 catch: (cause) =>
332 new TextGenerationError({
333 operation: input.operation,
334 detail: openCodeRuntimeErrorDetail(cause),
335 cause,
336 }),
337 });
338
339 const rawOutput =
340 openCodeSettings.serverUrl.length > 0
341 ? yield* runAgainstServer({ url: openCodeSettings.serverUrl })
342 : yield* Effect.acquireUseRelease(
343 acquireSharedServer({
344 binaryPath: openCodeSettings.binaryPath,
345 operation: input.operation,
346 }),
347 runAgainstServer,
348 releaseSharedServer,
349 );
350
351 return yield* Schema.decodeEffect(Schema.fromJsonString(input.outputSchemaJson))(
352 extractJsonObject(rawOutput),
353 ).pipe(
354 Effect.catchTag("SchemaError", (cause) =>
355 Effect.fail(
356 new TextGenerationError({
357 operation: input.operation,
358 detail: "OpenCode returned invalid structured output.",
359 cause,
360 }),
361 ),
362 ),
363 );
364 });
365
366 const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn(
367 "OpenCodeTextGeneration.generateCommitMessage",
368 )(function* (input) {
369 const { prompt, outputSchema } = buildCommitMessagePrompt({
370 branch: input.branch,
371 stagedSummary: input.stagedSummary,
372 stagedPatch: input.stagedPatch,
373 includeBranch: input.includeBranch === true,
374 });
375 const generated = yield* runOpenCodeJson({
376 operation: "generateCommitMessage",
377 cwd: input.cwd,
378 prompt,
379 outputSchemaJson: outputSchema,
380 modelSelection: input.modelSelection,
381 });
382
383 return {
384 subject: sanitizeCommitSubject(generated.subject),
385 body: generated.body.trim(),
386 ...("branch" in generated && typeof generated.branch === "string"
387 ? { branch: sanitizeFeatureBranchName(generated.branch) }
388 : {}),
389 };
390 });
391
392 const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn(
393 "OpenCodeTextGeneration.generatePrContent",
394 )(function* (input) {
395 const { prompt, outputSchema } = buildPrContentPrompt({
396 baseBranch: input.baseBranch,
397 headBranch: input.headBranch,
398 commitSummary: input.commitSummary,
399 diffSummary: input.diffSummary,
400 diffPatch: input.diffPatch,
401 });
402 const generated = yield* runOpenCodeJson({
403 operation: "generatePrContent",
404 cwd: input.cwd,
405 prompt,
406 outputSchemaJson: outputSchema,
407 modelSelection: input.modelSelection,
408 });
409
410 return {
411 title: sanitizePrTitle(generated.title),
412 body: generated.body.trim(),
413 };
414 });
415
416 const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn(
417 "OpenCodeTextGeneration.generateBranchName",
418 )(function* (input) {
419 const { prompt, outputSchema } = buildBranchNamePrompt({
420 message: input.message,
421 attachments: input.attachments,
422 });
423 const generated = yield* runOpenCodeJson({
424 operation: "generateBranchName",
425 cwd: input.cwd,
426 prompt,
427 outputSchemaJson: outputSchema,
428 modelSelection: input.modelSelection,
429 attachments: input.attachments,
430 });
431
432 return {
433 branch: sanitizeBranchFragment(generated.branch),
434 };
435 });
436
437 const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn(
438 "OpenCodeTextGeneration.generateThreadTitle",
439 )(function* (input) {
440 const { prompt, outputSchema } = buildThreadTitlePrompt({
441 message: input.message,
442 attachments: input.attachments,
443 });
444 const generated = yield* runOpenCodeJson({
445 operation: "generateThreadTitle",
446 cwd: input.cwd,
447 prompt,
448 outputSchemaJson: outputSchema,
449 modelSelection: input.modelSelection,
450 attachments: input.attachments,
451 });
452
453 return {
454 title: sanitizeThreadTitle(generated.title),
455 };
456 });
457
458 return {
459 generateCommitMessage,
460 generatePrContent,
461 generateBranchName,
462 generateThreadTitle,
463 } satisfies TextGenerationShape;
464});
apps/server/src/textGeneration/TextGeneration.test.ts created+117
......@@ -0,0 +1,117 @@
1import { it } from "@effect/vitest";
2import { Effect, PubSub, Result, Stream } from "effect";
3import { describe, expect } from "vitest";
4
5import { ProviderInstanceId } from "@t3tools/contracts";
6import { createModelSelection } from "@t3tools/shared/model";
7
8import type { ProviderInstance } from "../provider/ProviderDriver.ts";
9import type { ProviderInstanceRegistryShape } from "../provider/Services/ProviderInstanceRegistry.ts";
10import type { TextGenerationShape } from "./TextGeneration.ts";
11
12import { makeTextGenerationFromRegistry } from "./TextGeneration.ts";
13
14const makeStubTextGeneration = (overrides: Partial<TextGenerationShape>): TextGenerationShape => ({
15 generateCommitMessage: () =>
16 Effect.die("generateCommitMessage stub not configured for this test"),
17 generatePrContent: () => Effect.die("generatePrContent stub not configured for this test"),
18 generateBranchName: () => Effect.die("generateBranchName stub not configured for this test"),
19 generateThreadTitle: () => Effect.die("generateThreadTitle stub not configured for this test"),
20 ...overrides,
21});
22
23const makeStubInstance = (
24 instanceId: ProviderInstanceId,
25 textGeneration: TextGenerationShape,
26): ProviderInstance =>
27 ({
28 instanceId,
29 driverKind: instanceId as unknown as ProviderInstance["driverKind"],
30 continuationIdentity: {
31 driverKind: instanceId as unknown as ProviderInstance["driverKind"],
32 continuationKey: `${instanceId}:test`,
33 },
34 displayName: undefined,
35 enabled: true,
36 snapshot: {} as ProviderInstance["snapshot"],
37 adapter: {} as ProviderInstance["adapter"],
38 textGeneration,
39 }) satisfies ProviderInstance;
40
41const makeStubRegistry = (
42 instances: ReadonlyArray<ProviderInstance>,
43): ProviderInstanceRegistryShape => {
44 const byId = new Map(instances.map((instance) => [instance.instanceId, instance] as const));
45 return {
46 getInstance: (id) => Effect.succeed(byId.get(id)),
47 listInstances: Effect.succeed(instances),
48 listUnavailable: Effect.succeed([]),
49 streamChanges: Stream.empty,
50 // Tests never drive changes through this stub; acquire a throwaway
51 // subscription on an unused PubSub so the shape is satisfied.
52 subscribeChanges: Effect.flatMap(PubSub.unbounded<void>(), (pubsub) =>
53 PubSub.subscribe(pubsub),
54 ),
55 };
56};
57
58describe("makeTextGenerationFromRegistry", () => {
59 it.effect("delegates to the matching instance's textGeneration closure", () =>
60 Effect.gen(function* () {
61 const personalId = ProviderInstanceId.make("codex_personal");
62 const personalCalls: string[] = [];
63 const personal = makeStubInstance(
64 personalId,
65 makeStubTextGeneration({
66 generateBranchName: (input) => {
67 personalCalls.push(input.message);
68 return Effect.succeed({ branch: "personal-branch" });
69 },
70 }),
71 );
72
73 const workId = ProviderInstanceId.make("codex_work");
74 const work = makeStubInstance(
75 workId,
76 makeStubTextGeneration({
77 generateBranchName: () => Effect.succeed({ branch: "work-branch" }),
78 }),
79 );
80
81 const tg = makeTextGenerationFromRegistry(makeStubRegistry([personal, work]));
82
83 const result = yield* tg.generateBranchName({
84 cwd: process.cwd(),
85 message: "Refactor the routing layer",
86 modelSelection: createModelSelection(ProviderInstanceId.make("codex_personal"), "gpt-5"),
87 });
88
89 expect(result.branch).toBe("personal-branch");
90 expect(personalCalls).toEqual(["Refactor the routing layer"]);
91 }),
92 );
93
94 it.effect("fails with TextGenerationError when the instance is unknown", () =>
95 Effect.gen(function* () {
96 const tg = makeTextGenerationFromRegistry(makeStubRegistry([]));
97
98 const result = yield* tg
99 .generateBranchName({
100 cwd: process.cwd(),
101 message: "anything",
102 modelSelection: createModelSelection(
103 ProviderInstanceId.make("missing_instance"),
104 "gpt-5",
105 ),
106 })
107 .pipe(Effect.result);
108
109 expect(Result.isFailure(result)).toBe(true);
110 if (Result.isFailure(result)) {
111 expect(result.failure._tag).toBe("TextGenerationError");
112 expect(result.failure.operation).toBe("generateBranchName");
113 expect(result.failure.detail).toContain("missing_instance");
114 }
115 }),
116 );
117});
apps/server/src/textGeneration/TextGeneration.ts created+171
......@@ -0,0 +1,171 @@
1import { Context, Effect, Layer } from "effect";
2import type { ChatAttachment, ModelSelection, ProviderInstanceId } from "@t3tools/contracts";
3import { TextGenerationError } from "@t3tools/contracts";
4
5import {
6 ProviderInstanceRegistry,
7 type ProviderInstanceRegistryShape,
8} from "../provider/Services/ProviderInstanceRegistry.ts";
9import type { ProviderInstance } from "../provider/ProviderDriver.ts";
10
11export type TextGenerationProvider = "codex" | "claudeAgent" | "cursor" | "opencode";
12
13export interface CommitMessageGenerationInput {
14 cwd: string;
15 branch: string | null;
16 stagedSummary: string;
17 stagedPatch: string;
18 /** When true, the model also returns a semantic branch name for the change. */
19 includeBranch?: boolean;
20 /** What model and provider to use for generation. */
21 modelSelection: ModelSelection;
22}
23
24export interface CommitMessageGenerationResult {
25 subject: string;
26 body: string;
27 /** Only present when `includeBranch` was set on the input. */
28 branch?: string | undefined;
29}
30
31export interface PrContentGenerationInput {
32 cwd: string;
33 baseBranch: string;
34 headBranch: string;
35 commitSummary: string;
36 diffSummary: string;
37 diffPatch: string;
38 /** What model and provider to use for generation. */
39 modelSelection: ModelSelection;
40}
41
42export interface PrContentGenerationResult {
43 title: string;
44 body: string;
45}
46
47export interface BranchNameGenerationInput {
48 cwd: string;
49 message: string;
50 attachments?: ReadonlyArray<ChatAttachment> | undefined;
51 /** What model and provider to use for generation. */
52 modelSelection: ModelSelection;
53}
54
55export interface BranchNameGenerationResult {
56 branch: string;
57}
58
59export interface ThreadTitleGenerationInput {
60 cwd: string;
61 message: string;
62 attachments?: ReadonlyArray<ChatAttachment> | undefined;
63 /** What model and provider to use for generation. */
64 modelSelection: ModelSelection;
65}
66
67export interface ThreadTitleGenerationResult {
68 title: string;
69}
70
71export interface TextGenerationService {
72 generateCommitMessage(
73 input: CommitMessageGenerationInput,
74 ): Promise<CommitMessageGenerationResult>;
75 generatePrContent(input: PrContentGenerationInput): Promise<PrContentGenerationResult>;
76 generateBranchName(input: BranchNameGenerationInput): Promise<BranchNameGenerationResult>;
77 generateThreadTitle(input: ThreadTitleGenerationInput): Promise<ThreadTitleGenerationResult>;
78}
79
80/**
81 * TextGenerationShape - Service API for commit/PR text generation.
82 */
83export interface TextGenerationShape {
84 /**
85 * Generate a commit message from staged change context.
86 */
87 readonly generateCommitMessage: (
88 input: CommitMessageGenerationInput,
89 ) => Effect.Effect<CommitMessageGenerationResult, TextGenerationError>;
90
91 /**
92 * Generate pull request title/body from branch and diff context.
93 */
94 readonly generatePrContent: (
95 input: PrContentGenerationInput,
96 ) => Effect.Effect<PrContentGenerationResult, TextGenerationError>;
97
98 /**
99 * Generate a concise branch name from a user message.
100 */
101 readonly generateBranchName: (
102 input: BranchNameGenerationInput,
103 ) => Effect.Effect<BranchNameGenerationResult, TextGenerationError>;
104
105 /**
106 * Generate a concise thread title from a user's first message.
107 */
108 readonly generateThreadTitle: (
109 input: ThreadTitleGenerationInput,
110 ) => Effect.Effect<ThreadTitleGenerationResult, TextGenerationError>;
111}
112
113/**
114 * TextGeneration - Service tag for commit and PR text generation.
115 */
116export class TextGeneration extends Context.Service<TextGeneration, TextGenerationShape>()(
117 "t3/text-generation/TextGeneration",
118) {}
119
120type TextGenerationOp =
121 | "generateCommitMessage"
122 | "generatePrContent"
123 | "generateBranchName"
124 | "generateThreadTitle";
125
126const resolveInstance = (
127 registry: ProviderInstanceRegistryShape,
128 operation: TextGenerationOp,
129 instanceId: ProviderInstanceId,
130): Effect.Effect<ProviderInstance["textGeneration"], TextGenerationError> =>
131 registry.getInstance(instanceId).pipe(
132 Effect.flatMap((instance) =>
133 instance
134 ? Effect.succeed(instance.textGeneration)
135 : Effect.fail(
136 new TextGenerationError({
137 operation,
138 detail: `No provider instance registered for id '${instanceId}'.`,
139 }),
140 ),
141 ),
142 );
143
144export const makeTextGenerationFromRegistry = (
145 registry: ProviderInstanceRegistryShape,
146): TextGenerationShape => ({
147 generateCommitMessage: (input) =>
148 resolveInstance(registry, "generateCommitMessage", input.modelSelection.instanceId).pipe(
149 Effect.flatMap((textGeneration) => textGeneration.generateCommitMessage(input)),
150 ),
151 generatePrContent: (input) =>
152 resolveInstance(registry, "generatePrContent", input.modelSelection.instanceId).pipe(
153 Effect.flatMap((textGeneration) => textGeneration.generatePrContent(input)),
154 ),
155 generateBranchName: (input) =>
156 resolveInstance(registry, "generateBranchName", input.modelSelection.instanceId).pipe(
157 Effect.flatMap((textGeneration) => textGeneration.generateBranchName(input)),
158 ),
159 generateThreadTitle: (input) =>
160 resolveInstance(registry, "generateThreadTitle", input.modelSelection.instanceId).pipe(
161 Effect.flatMap((textGeneration) => textGeneration.generateThreadTitle(input)),
162 ),
163});
164
165export const layer = Layer.effect(
166 TextGeneration,
167 Effect.gen(function* () {
168 const registry = yield* ProviderInstanceRegistry;
169 return makeTextGenerationFromRegistry(registry);
170 }),
171);
apps/server/src/textGeneration/TextGenerationPolicy.ts created+19
......@@ -0,0 +1,19 @@
1import { Schema } from "effect";
2
3export const TextGenerationPolicyKind = Schema.Literals([
4 "default",
5 "conventional_commits",
6 "repo_conventions",
7 "custom",
8]);
9export type TextGenerationPolicyKind = typeof TextGenerationPolicyKind.Type;
10
11export const TextGenerationPolicy = Schema.Struct({
12 kind: TextGenerationPolicyKind,
13 commitInstructions: Schema.optional(Schema.String),
14 changeRequestInstructions: Schema.optional(Schema.String),
15 branchInstructions: Schema.optional(Schema.String),
16 threadTitleInstructions: Schema.optional(Schema.String),
17 inferRepositoryConventions: Schema.Boolean,
18});
19export type TextGenerationPolicy = typeof TextGenerationPolicy.Type;
apps/server/src/textGeneration/TextGenerationPresets.ts created+41
......@@ -0,0 +1,41 @@
1import type { TextGenerationPolicy, TextGenerationPolicyKind } from "./TextGenerationPolicy.ts";
2
3export const defaultTextGenerationPolicy: TextGenerationPolicy = {
4 kind: "default",
5 inferRepositoryConventions: false,
6};
7
8export const conventionalCommitsTextGenerationPolicy: TextGenerationPolicy = {
9 kind: "conventional_commits",
10 commitInstructions:
11 "Use Conventional Commits when generating commit subjects. Prefer the narrowest accurate type and include a scope only when it is obvious from the diff.",
12 changeRequestInstructions:
13 "Keep the change request title concise. Do not force Conventional Commit syntax into the title unless the repository already uses it.",
14 inferRepositoryConventions: false,
15};
16
17export const repositoryConventionsTextGenerationPolicy: TextGenerationPolicy = {
18 kind: "repo_conventions",
19 commitInstructions:
20 "Follow the repository's established commit message style when examples are available.",
21 changeRequestInstructions:
22 "Follow the repository's established change request title and body style when examples are available.",
23 inferRepositoryConventions: true,
24};
25
26export const customTextGenerationPolicy = (
27 overrides: Omit<Partial<TextGenerationPolicy>, "kind">,
28): TextGenerationPolicy => ({
29 kind: "custom",
30 inferRepositoryConventions: false,
31 ...overrides,
32});
33
34export const textGenerationPresets: Record<
35 Exclude<TextGenerationPolicyKind, "custom">,
36 TextGenerationPolicy
37> = {
38 default: defaultTextGenerationPolicy,
39 conventional_commits: conventionalCommitsTextGenerationPolicy,
40 repo_conventions: repositoryConventionsTextGenerationPolicy,
41};
apps/server/src/textGeneration/TextGenerationPrompts.test.ts created+193
......@@ -0,0 +1,193 @@
1import { describe, expect, it } from "vitest";
2
3import {
4 buildBranchNamePrompt,
5 buildCommitMessagePrompt,
6 buildPrContentPrompt,
7 buildThreadTitlePrompt,
8} from "./TextGenerationPrompts.ts";
9import { normalizeCliError, sanitizeThreadTitle } from "./TextGenerationUtils.ts";
10import { TextGenerationError } from "@t3tools/contracts";
11
12describe("buildCommitMessagePrompt", () => {
13 it("includes staged patch and summary in the prompt", () => {
14 const result = buildCommitMessagePrompt({
15 branch: "main",
16 stagedSummary: "M README.md",
17 stagedPatch: "diff --git a/README.md b/README.md\n+hello",
18 includeBranch: false,
19 });
20
21 expect(result.prompt).toContain("Staged files:");
22 expect(result.prompt).toContain("M README.md");
23 expect(result.prompt).toContain("Staged patch:");
24 expect(result.prompt).toContain("diff --git a/README.md b/README.md");
25 expect(result.prompt).toContain("Branch: main");
26 // Should NOT include the branch generation instruction
27 expect(result.prompt).not.toContain("branch must be a short semantic git branch fragment");
28 });
29
30 it("includes branch generation instruction when includeBranch is true", () => {
31 const result = buildCommitMessagePrompt({
32 branch: "feature/foo",
33 stagedSummary: "M README.md",
34 stagedPatch: "diff",
35 includeBranch: true,
36 });
37
38 expect(result.prompt).toContain("branch must be a short semantic git branch fragment");
39 expect(result.prompt).toContain("Return a JSON object with keys: subject, body, branch.");
40 });
41
42 it("shows (detached) when branch is null", () => {
43 const result = buildCommitMessagePrompt({
44 branch: null,
45 stagedSummary: "M a.ts",
46 stagedPatch: "diff",
47 includeBranch: false,
48 });
49
50 expect(result.prompt).toContain("Branch: (detached)");
51 });
52});
53
54describe("buildPrContentPrompt", () => {
55 it("includes branch names, commits, and diff in the prompt", () => {
56 const result = buildPrContentPrompt({
57 baseBranch: "main",
58 headBranch: "feature/auth",
59 commitSummary: "feat: add login page",
60 diffSummary: "3 files changed",
61 diffPatch: "diff --git a/auth.ts b/auth.ts\n+export function login()",
62 });
63
64 expect(result.prompt).toContain("Base branch: main");
65 expect(result.prompt).toContain("Head branch: feature/auth");
66 expect(result.prompt).toContain("Commits:");
67 expect(result.prompt).toContain("feat: add login page");
68 expect(result.prompt).toContain("Diff stat:");
69 expect(result.prompt).toContain("3 files changed");
70 expect(result.prompt).toContain("Diff patch:");
71 expect(result.prompt).toContain("export function login()");
72 });
73});
74
75describe("buildBranchNamePrompt", () => {
76 it("includes the user message in the prompt", () => {
77 const result = buildBranchNamePrompt({
78 message: "Fix the login timeout bug",
79 });
80
81 expect(result.prompt).toContain("User message:");
82 expect(result.prompt).toContain("Fix the login timeout bug");
83 expect(result.prompt).not.toContain("Attachment metadata:");
84 });
85
86 it("includes attachment metadata when attachments are provided", () => {
87 const result = buildBranchNamePrompt({
88 message: "Fix the layout from screenshot",
89 attachments: [
90 {
91 type: "image" as const,
92 id: "att-123",
93 name: "screenshot.png",
94 mimeType: "image/png",
95 sizeBytes: 12345,
96 },
97 ],
98 });
99
100 expect(result.prompt).toContain("Attachment metadata:");
101 expect(result.prompt).toContain("screenshot.png");
102 expect(result.prompt).toContain("image/png");
103 expect(result.prompt).toContain("12345 bytes");
104 });
105});
106
107describe("buildThreadTitlePrompt", () => {
108 it("includes the user message in the prompt", () => {
109 const result = buildThreadTitlePrompt({
110 message: "Investigate reconnect regressions after session restore",
111 });
112
113 expect(result.prompt).toContain("User message:");
114 expect(result.prompt).toContain("Investigate reconnect regressions after session restore");
115 expect(result.prompt).not.toContain("Attachment metadata:");
116 });
117
118 it("includes attachment metadata when attachments are provided", () => {
119 const result = buildThreadTitlePrompt({
120 message: "Name this thread from the screenshot",
121 attachments: [
122 {
123 type: "image" as const,
124 id: "att-456",
125 name: "thread.png",
126 mimeType: "image/png",
127 sizeBytes: 67890,
128 },
129 ],
130 });
131
132 expect(result.prompt).toContain("Attachment metadata:");
133 expect(result.prompt).toContain("thread.png");
134 expect(result.prompt).toContain("image/png");
135 expect(result.prompt).toContain("67890 bytes");
136 });
137});
138
139describe("sanitizeThreadTitle", () => {
140 it("truncates long titles with the shared sidebar-safe limit", () => {
141 expect(
142 sanitizeThreadTitle(
143 ' "Reconnect failures after restart because the session state does not recover" ',
144 ),
145 ).toBe("Reconnect failures after restart because the se...");
146 });
147});
148
149describe("normalizeCliError", () => {
150 it("detects 'Command not found' and includes CLI name in the message", () => {
151 const error = normalizeCliError(
152 "claude",
153 "generateCommitMessage",
154 new Error("Command not found: claude"),
155 "Something went wrong",
156 );
157
158 expect(error).toBeInstanceOf(TextGenerationError);
159 expect(error.detail).toContain("Claude CLI");
160 expect(error.detail).toContain("not available on PATH");
161 });
162
163 it("uses the CLI name from the first argument for codex", () => {
164 const error = normalizeCliError(
165 "codex",
166 "generateBranchName",
167 new Error("Command not found: codex"),
168 "Something went wrong",
169 );
170
171 expect(error).toBeInstanceOf(TextGenerationError);
172 expect(error.detail).toContain("Codex CLI");
173 expect(error.detail).toContain("not available on PATH");
174 });
175
176 it("returns the error as-is if it is already a TextGenerationError", () => {
177 const existing = new TextGenerationError({
178 operation: "generatePrContent",
179 detail: "Already wrapped",
180 });
181
182 const result = normalizeCliError("claude", "generatePrContent", existing, "fallback");
183
184 expect(result).toBe(existing);
185 });
186
187 it("wraps unknown non-Error values with the fallback message", () => {
188 const result = normalizeCliError("codex", "generateCommitMessage", "string error", "fallback");
189
190 expect(result).toBeInstanceOf(TextGenerationError);
191 expect(result.detail).toBe("fallback");
192 });
193});
apps/server/src/textGeneration/TextGenerationPrompts.ts created+218
......@@ -0,0 +1,218 @@
1/**
2 * Shared prompt builders for text generation providers.
3 *
4 * Extracts the prompt construction logic that is identical across
5 * Codex, Claude, and any future CLI-based text generation backends.
6 *
7 * @module textGenerationPrompts
8 */
9import { Schema } from "effect";
10import type { ChatAttachment } from "@t3tools/contracts";
11
12import { limitSection } from "./TextGenerationUtils.ts";
13import type { TextGenerationPolicy } from "./TextGenerationPolicy.ts";
14
15function policyInstruction(instruction: string | undefined): ReadonlyArray<string> {
16 const trimmed = instruction?.trim();
17 return trimmed ? ["", "Additional instructions:", limitSection(trimmed, 4_000)] : [];
18}
19
20// ---------------------------------------------------------------------------
21// Commit message
22// ---------------------------------------------------------------------------
23
24export interface CommitMessagePromptInput {
25 branch: string | null;
26 stagedSummary: string;
27 stagedPatch: string;
28 includeBranch: boolean;
29 policy?: TextGenerationPolicy | undefined;
30}
31
32export function buildCommitMessagePrompt(input: CommitMessagePromptInput) {
33 const wantsBranch = input.includeBranch;
34
35 const prompt = [
36 "You write concise git commit messages.",
37 wantsBranch
38 ? "Return a JSON object with keys: subject, body, branch."
39 : "Return a JSON object with keys: subject, body.",
40 "Rules:",
41 "- subject must be imperative, <= 72 chars, and no trailing period",
42 "- body can be empty string or short bullet points",
43 ...(wantsBranch
44 ? ["- branch must be a short semantic git branch fragment for this change"]
45 : []),
46 "- capture the primary user-visible or developer-visible change",
47 ...policyInstruction(input.policy?.commitInstructions),
48 "",
49 `Branch: ${input.branch ?? "(detached)"}`,
50 "",
51 "Staged files:",
52 limitSection(input.stagedSummary, 6_000),
53 "",
54 "Staged patch:",
55 limitSection(input.stagedPatch, 40_000),
56 ].join("\n");
57
58 if (wantsBranch) {
59 return {
60 prompt,
61 outputSchema: Schema.Struct({
62 subject: Schema.String,
63 body: Schema.String,
64 branch: Schema.String,
65 }),
66 };
67 }
68
69 return {
70 prompt,
71 outputSchema: Schema.Struct({
72 subject: Schema.String,
73 body: Schema.String,
74 }),
75 };
76}
77
78// ---------------------------------------------------------------------------
79// PR content
80// ---------------------------------------------------------------------------
81
82export interface PrContentPromptInput {
83 baseBranch: string;
84 headBranch: string;
85 commitSummary: string;
86 diffSummary: string;
87 diffPatch: string;
88 policy?: TextGenerationPolicy | undefined;
89}
90
91export function buildPrContentPrompt(input: PrContentPromptInput) {
92 const prompt = [
93 "You write GitHub pull request content.",
94 "Return a JSON object with keys: title, body.",
95 "Rules:",
96 "- title should be concise and specific",
97 "- body must be markdown and include headings '## Summary' and '## Testing'",
98 "- under Summary, provide short bullet points",
99 "- under Testing, include bullet points with concrete checks or 'Not run' where appropriate",
100 ...policyInstruction(input.policy?.changeRequestInstructions),
101 "",
102 `Base branch: ${input.baseBranch}`,
103 `Head branch: ${input.headBranch}`,
104 "",
105 "Commits:",
106 limitSection(input.commitSummary, 12_000),
107 "",
108 "Diff stat:",
109 limitSection(input.diffSummary, 12_000),
110 "",
111 "Diff patch:",
112 limitSection(input.diffPatch, 40_000),
113 ].join("\n");
114
115 const outputSchema = Schema.Struct({
116 title: Schema.String,
117 body: Schema.String,
118 });
119
120 return { prompt, outputSchema };
121}
122
123// ---------------------------------------------------------------------------
124// Branch name
125// ---------------------------------------------------------------------------
126
127export interface BranchNamePromptInput {
128 message: string;
129 attachments?: ReadonlyArray<ChatAttachment> | undefined;
130 policy?: TextGenerationPolicy | undefined;
131}
132
133interface PromptFromMessageInput {
134 instruction: string;
135 responseShape: string;
136 rules: ReadonlyArray<string>;
137 message: string;
138 attachments?: ReadonlyArray<ChatAttachment> | undefined;
139 additionalInstructions?: string | undefined;
140}
141
142function buildPromptFromMessage(input: PromptFromMessageInput): string {
143 const attachmentLines = (input.attachments ?? []).map(
144 (attachment) => `- ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes)`,
145 );
146
147 const promptSections = [
148 input.instruction,
149 input.responseShape,
150 "Rules:",
151 ...input.rules.map((rule) => `- ${rule}`),
152 "",
153 "User message:",
154 limitSection(input.message, 8_000),
155 ...policyInstruction(input.additionalInstructions),
156 ];
157 if (attachmentLines.length > 0) {
158 promptSections.push(
159 "",
160 "Attachment metadata:",
161 limitSection(attachmentLines.join("\n"), 4_000),
162 );
163 }
164
165 return promptSections.join("\n");
166}
167
168export function buildBranchNamePrompt(input: BranchNamePromptInput) {
169 const prompt = buildPromptFromMessage({
170 instruction: "You generate concise git branch names.",
171 responseShape: "Return a JSON object with key: branch.",
172 rules: [
173 "Branch should describe the requested work from the user message.",
174 "Keep it short and specific (2-6 words).",
175 "Use plain words only, no issue prefixes and no punctuation-heavy text.",
176 "If images are attached, use them as primary context for visual/UI issues.",
177 ],
178 message: input.message,
179 attachments: input.attachments,
180 additionalInstructions: input.policy?.branchInstructions,
181 });
182 const outputSchema = Schema.Struct({
183 branch: Schema.String,
184 });
185
186 return { prompt, outputSchema };
187}
188
189// ---------------------------------------------------------------------------
190// Thread title
191// ---------------------------------------------------------------------------
192
193export interface ThreadTitlePromptInput {
194 message: string;
195 attachments?: ReadonlyArray<ChatAttachment> | undefined;
196 policy?: TextGenerationPolicy | undefined;
197}
198
199export function buildThreadTitlePrompt(input: ThreadTitlePromptInput) {
200 const prompt = buildPromptFromMessage({
201 instruction: "You write concise thread titles for coding conversations.",
202 responseShape: "Return a JSON object with key: title.",
203 rules: [
204 "Title should summarize the user's request, not restate it verbatim.",
205 "Keep it short and specific (3-8 words).",
206 "Avoid quotes, filler, prefixes, and trailing punctuation.",
207 "If images are attached, use them as primary context for visual/UI issues.",
208 ],
209 message: input.message,
210 attachments: input.attachments,
211 additionalInstructions: input.policy?.threadTitleInstructions,
212 });
213 const outputSchema = Schema.Struct({
214 title: Schema.String,
215 });
216
217 return { prompt, outputSchema };
218}
apps/server/src/textGeneration/TextGenerationUtils.ts created+159
......@@ -0,0 +1,159 @@
1import { Schema } from "effect";
2
3import { TextGenerationError } from "@t3tools/contracts";
4
5/** Convert an Effect Schema to a flat JSON Schema object, inlining `$defs` when present. */
6export function toJsonSchemaObject(schema: Schema.Top): unknown {
7 const document = Schema.toJsonSchemaDocument(schema);
8 if (document.definitions && Object.keys(document.definitions).length > 0) {
9 return { ...document.schema, $defs: document.definitions };
10 }
11 return document.schema;
12}
13
14/** Truncate a text section to `maxChars`, appending a `[truncated]` marker when needed. */
15export function limitSection(value: string, maxChars: number): string {
16 if (value.length <= maxChars) return value;
17 const truncated = value.slice(0, maxChars);
18 return `${truncated}\n\n[truncated]`;
19}
20
21export function extractJsonObject(raw: string): string {
22 const trimmed = raw.trim();
23 if (trimmed.length === 0) {
24 return trimmed;
25 }
26
27 const start = trimmed.indexOf("{");
28 if (start < 0) {
29 return trimmed;
30 }
31
32 let depth = 0;
33 let inString = false;
34 let escaping = false;
35 for (let index = start; index < trimmed.length; index += 1) {
36 const char = trimmed[index];
37 if (inString) {
38 if (escaping) {
39 escaping = false;
40 } else if (char === "\\") {
41 escaping = true;
42 } else if (char === '"') {
43 inString = false;
44 }
45 continue;
46 }
47
48 if (char === '"') {
49 inString = true;
50 continue;
51 }
52
53 if (char === "{") {
54 depth += 1;
55 continue;
56 }
57
58 if (char === "}") {
59 depth -= 1;
60 if (depth === 0) {
61 return trimmed.slice(start, index + 1);
62 }
63 }
64 }
65
66 return trimmed.slice(start);
67}
68
69/** Normalise a raw commit subject to imperative-mood, ≤72 chars, no trailing period. */
70export function sanitizeCommitSubject(raw: string): string {
71 const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? "";
72 const withoutTrailingPeriod = singleLine.replace(/[.]+$/g, "").trim();
73 if (withoutTrailingPeriod.length === 0) {
74 return "Update project files";
75 }
76
77 if (withoutTrailingPeriod.length <= 72) {
78 return withoutTrailingPeriod;
79 }
80 return withoutTrailingPeriod.slice(0, 72).trimEnd();
81}
82
83/** Normalise a raw PR title to a single line with a sensible fallback. */
84export function sanitizePrTitle(raw: string): string {
85 const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? "";
86 if (singleLine.length > 0) {
87 return singleLine;
88 }
89 return "Update project changes";
90}
91
92/** Normalise a raw thread title to a compact single-line sidebar-safe label. */
93export function sanitizeThreadTitle(raw: string): string {
94 const normalized = raw
95 .trim()
96 .split(/\r?\n/g)[0]
97 ?.trim()
98 .replace(/^['"`]+|['"`]+$/g, "")
99 .trim()
100 .replace(/\s+/g, " ");
101
102 if (!normalized || normalized.trim().length === 0) {
103 return "New thread";
104 }
105
106 if (normalized.length <= 50) {
107 return normalized;
108 }
109
110 return `${normalized.slice(0, 47).trimEnd()}...`;
111}
112
113/** CLI name to human-readable label, e.g. "codex" → "Codex CLI (`codex`)" */
114function cliLabel(cliName: string): string {
115 const capitalized = cliName.charAt(0).toUpperCase() + cliName.slice(1);
116 return `${capitalized} CLI (\`${cliName}\`)`;
117}
118
119/**
120 * Normalize an unknown error from a CLI text generation process into a
121 * typed `TextGenerationError`. Parameterized by CLI name so both Codex
122 * and Claude (and future providers) can share the same logic.
123 */
124export function normalizeCliError(
125 cliName: string,
126 operation: string,
127 error: unknown,
128 fallback: string,
129): TextGenerationError {
130 if (Schema.is(TextGenerationError)(error)) {
131 return error;
132 }
133
134 if (error instanceof Error) {
135 const lower = error.message.toLowerCase();
136 if (
137 error.message.includes(`Command not found: ${cliName}`) ||
138 lower.includes(`spawn ${cliName}`) ||
139 lower.includes("enoent")
140 ) {
141 return new TextGenerationError({
142 operation,
143 detail: `${cliLabel(cliName)} is required but not available on PATH.`,
144 cause: error,
145 });
146 }
147 return new TextGenerationError({
148 operation,
149 detail: `${fallback}: ${error.message}`,
150 cause: error,
151 });
152 }
153
154 return new TextGenerationError({
155 operation,
156 detail: fallback,
157 cause: error,
158 });
159}
apps/server/src/vcs/GitVcsDriver.test.ts created+99
......@@ -0,0 +1,99 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { Effect, FileSystem, Layer, Path, PlatformError } from "effect";
3import { ChildProcessSpawner } from "effect/unstable/process";
4import { assert, it } from "@effect/vitest";
5
6import { GitCommandError } from "@t3tools/contracts";
7import { ServerConfig } from "../config.ts";
8import * as GitVcsDriver from "./GitVcsDriver.ts";
9import * as VcsProcess from "./VcsProcess.ts";
10import { runVcsDriverContractSuite } from "./testing/VcsDriverContractHarness.ts";
11
12const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), {
13 prefix: "t3-git-vcs-contract-",
14});
15const GitContractLayer = Layer.mergeAll(GitVcsDriver.vcsLayer, GitVcsDriver.layer).pipe(
16 Layer.provide(ServerConfigLayer),
17 Layer.provideMerge(VcsProcess.layer),
18 Layer.provideMerge(NodeServices.layer),
19);
20
21const runGit = (cwd: string, args: ReadonlyArray<string>) =>
22 Effect.gen(function* () {
23 const driver = yield* GitVcsDriver.GitVcsDriver;
24 yield* driver.execute({
25 operation: "GitVcsDriver.contract.git",
26 cwd,
27 args,
28 timeoutMs: 10_000,
29 });
30 });
31
32type GitContractError = GitCommandError | PlatformError.PlatformError;
33
34runVcsDriverContractSuite<GitVcsDriver.GitVcsDriver, GitContractError>({
35 name: "Git",
36 kind: "git",
37 layer: GitContractLayer,
38 fixture: {
39 createRepo: (cwd) =>
40 Effect.gen(function* () {
41 yield* runGit(cwd, ["init"]);
42 yield* runGit(cwd, ["config", "user.email", "test@test.com"]);
43 yield* runGit(cwd, ["config", "user.name", "Test"]);
44 }),
45 writeFile: (cwd, relativePath, contents) =>
46 Effect.gen(function* () {
47 const fileSystem = yield* FileSystem.FileSystem;
48 const path = yield* Path.Path;
49 const absolutePath = path.join(cwd, relativePath);
50 yield* fileSystem.makeDirectory(path.dirname(absolutePath), { recursive: true });
51 yield* fileSystem.writeFileString(absolutePath, contents);
52 }),
53 trackFile: (cwd, relativePath) => runGit(cwd, ["add", relativePath]),
54 commit: (cwd, message) => runGit(cwd, ["commit", "-m", message]),
55 ignorePath: (cwd, pattern) =>
56 Effect.gen(function* () {
57 const fileSystem = yield* FileSystem.FileSystem;
58 const path = yield* Path.Path;
59 yield* fileSystem.writeFileString(path.join(cwd, ".gitignore"), `${pattern}\n`);
60 }),
61 },
62});
63
64it.effect("GitVcsDriver forwards execute env to the VCS process", () => {
65 let observedEnv: NodeJS.ProcessEnv | undefined;
66
67 return Effect.gen(function* () {
68 const driver = yield* GitVcsDriver.makeVcsDriverShape();
69
70 yield* driver.execute({
71 operation: "GitVcsDriver.test.env",
72 cwd: "/repo",
73 args: ["status"],
74 env: {
75 GIT_INDEX_FILE: "/tmp/t3-index",
76 },
77 });
78
79 assert.deepStrictEqual(observedEnv, {
80 GIT_INDEX_FILE: "/tmp/t3-index",
81 });
82 }).pipe(
83 Effect.provide(
84 Layer.mock(VcsProcess.VcsProcess)({
85 run: (input) =>
86 Effect.sync(() => {
87 observedEnv = input.env;
88 return {
89 exitCode: ChildProcessSpawner.ExitCode(0),
90 stdout: "",
91 stderr: "",
92 stdoutTruncated: false,
93 stderrTruncated: false,
94 };
95 }),
96 }),
97 ),
98 );
99});
apps/server/src/vcs/GitVcsDriver.ts created+543
......@@ -0,0 +1,543 @@
1import { Context, DateTime, Effect, Layer, Option } from "effect";
2import { ChildProcessSpawner } from "effect/unstable/process";
3
4import {
5 GitCommandError,
6 VcsProcessExitError,
7 type VcsSwitchRefInput,
8 type VcsSwitchRefResult,
9 type VcsCreateRefInput,
10 type VcsCreateRefResult,
11 type VcsCreateWorktreeInput,
12 type VcsCreateWorktreeResult,
13 type VcsInitInput,
14 type VcsListRefsInput,
15 type VcsListRefsResult,
16 type VcsPullResult,
17 type VcsRemoveWorktreeInput,
18 type VcsStatusInput,
19 type VcsStatusResult,
20} from "@t3tools/contracts";
21import { makeGitVcsDriverCore } from "./GitVcsDriverCore.ts";
22import { VcsDriver, type VcsDriverShape } from "./VcsDriver.ts";
23import { VcsProcess, type VcsProcessShape } from "./VcsProcess.ts";
24
25export interface ExecuteGitInput {
26 readonly operation: string;
27 readonly cwd: string;
28 readonly args: ReadonlyArray<string>;
29 readonly stdin?: string;
30 readonly env?: NodeJS.ProcessEnv;
31 readonly allowNonZeroExit?: boolean;
32 readonly timeoutMs?: number;
33 readonly maxOutputBytes?: number;
34 readonly truncateOutputAtMaxBytes?: boolean;
35 readonly progress?: ExecuteGitProgress;
36}
37
38export interface ExecuteGitResult {
39 readonly exitCode: ChildProcessSpawner.ExitCode;
40 readonly stdout: string;
41 readonly stderr: string;
42 readonly stdoutTruncated: boolean;
43 readonly stderrTruncated: boolean;
44}
45
46export interface GitStatusDetails {
47 isRepo: boolean;
48 sourceControlProvider?: VcsStatusResult["sourceControlProvider"];
49 hasOriginRemote: boolean;
50 isDefaultBranch: boolean;
51 branch: string | null;
52 upstreamRef: string | null;
53 hasWorkingTreeChanges: boolean;
54 workingTree: VcsStatusResult["workingTree"];
55 hasUpstream: boolean;
56 aheadCount: number;
57 behindCount: number;
58}
59
60export interface GitPreparedCommitContext {
61 stagedSummary: string;
62 stagedPatch: string;
63}
64
65export interface ExecuteGitProgress {
66 readonly onStdoutLine?: (line: string) => Effect.Effect<void, never>;
67 readonly onStderrLine?: (line: string) => Effect.Effect<void, never>;
68 readonly onHookStarted?: (hookName: string) => Effect.Effect<void, never>;
69 readonly onHookFinished?: (input: {
70 hookName: string;
71 exitCode: number | null;
72 durationMs: number | null;
73 }) => Effect.Effect<void, never>;
74}
75
76export interface GitCommitProgress {
77 readonly onOutputLine?: (input: {
78 stream: "stdout" | "stderr";
79 text: string;
80 }) => Effect.Effect<void, never>;
81 readonly onHookStarted?: (hookName: string) => Effect.Effect<void, never>;
82 readonly onHookFinished?: (input: {
83 hookName: string;
84 exitCode: number | null;
85 durationMs: number | null;
86 }) => Effect.Effect<void, never>;
87}
88
89export interface GitCommitOptions {
90 readonly timeoutMs?: number;
91 readonly progress?: GitCommitProgress;
92}
93
94export interface GitPushResult {
95 status: "pushed" | "skipped_up_to_date";
96 branch: string;
97 upstreamBranch?: string | undefined;
98 setUpstream?: boolean | undefined;
99}
100
101export interface GitRangeContext {
102 commitSummary: string;
103 diffSummary: string;
104 diffPatch: string;
105}
106
107export interface GitRenameBranchInput {
108 cwd: string;
109 oldBranch: string;
110 newBranch: string;
111}
112
113export interface GitRenameBranchResult {
114 branch: string;
115}
116
117export interface GitFetchPullRequestBranchInput {
118 cwd: string;
119 prNumber: number;
120 branch: string;
121}
122
123export interface GitEnsureRemoteInput {
124 cwd: string;
125 preferredName: string;
126 url: string;
127}
128
129export interface GitFetchRemoteBranchInput {
130 cwd: string;
131 remoteName: string;
132 remoteBranch: string;
133 localBranch: string;
134}
135
136export interface GitSetBranchUpstreamInput {
137 cwd: string;
138 branch: string;
139 remoteName: string;
140 remoteBranch: string;
141}
142
143export interface GitVcsDriverShape {
144 readonly execute: (input: ExecuteGitInput) => Effect.Effect<ExecuteGitResult, GitCommandError>;
145 readonly status: (input: VcsStatusInput) => Effect.Effect<VcsStatusResult, GitCommandError>;
146 readonly statusDetails: (cwd: string) => Effect.Effect<GitStatusDetails, GitCommandError>;
147 readonly statusDetailsLocal: (cwd: string) => Effect.Effect<GitStatusDetails, GitCommandError>;
148 readonly prepareCommitContext: (
149 cwd: string,
150 filePaths?: readonly string[],
151 ) => Effect.Effect<GitPreparedCommitContext | null, GitCommandError>;
152 readonly commit: (
153 cwd: string,
154 subject: string,
155 body: string,
156 options?: GitCommitOptions,
157 ) => Effect.Effect<{ commitSha: string }, GitCommandError>;
158 readonly pushCurrentBranch: (
159 cwd: string,
160 fallbackBranch: string | null,
161 ) => Effect.Effect<GitPushResult, GitCommandError>;
162 readonly readRangeContext: (
163 cwd: string,
164 baseRef: string,
165 ) => Effect.Effect<GitRangeContext, GitCommandError>;
166 readonly readConfigValue: (
167 cwd: string,
168 key: string,
169 ) => Effect.Effect<string | null, GitCommandError>;
170 readonly listRefs: (input: VcsListRefsInput) => Effect.Effect<VcsListRefsResult, GitCommandError>;
171 readonly pullCurrentBranch: (cwd: string) => Effect.Effect<VcsPullResult, GitCommandError>;
172 readonly createWorktree: (
173 input: VcsCreateWorktreeInput,
174 ) => Effect.Effect<VcsCreateWorktreeResult, GitCommandError>;
175 readonly fetchPullRequestBranch: (
176 input: GitFetchPullRequestBranchInput,
177 ) => Effect.Effect<void, GitCommandError>;
178 readonly ensureRemote: (input: GitEnsureRemoteInput) => Effect.Effect<string, GitCommandError>;
179 readonly fetchRemoteBranch: (
180 input: GitFetchRemoteBranchInput,
181 ) => Effect.Effect<void, GitCommandError>;
182 readonly setBranchUpstream: (
183 input: GitSetBranchUpstreamInput,
184 ) => Effect.Effect<void, GitCommandError>;
185 readonly removeWorktree: (input: VcsRemoveWorktreeInput) => Effect.Effect<void, GitCommandError>;
186 readonly renameBranch: (
187 input: GitRenameBranchInput,
188 ) => Effect.Effect<GitRenameBranchResult, GitCommandError>;
189 readonly createRef: (
190 input: VcsCreateRefInput,
191 ) => Effect.Effect<VcsCreateRefResult, GitCommandError>;
192 readonly switchRef: (
193 input: VcsSwitchRefInput,
194 ) => Effect.Effect<VcsSwitchRefResult, GitCommandError>;
195 readonly initRepo: (input: VcsInitInput) => Effect.Effect<void, GitCommandError>;
196 readonly listLocalBranchNames: (cwd: string) => Effect.Effect<string[], GitCommandError>;
197}
198
199export class GitVcsDriver extends Context.Service<GitVcsDriver, GitVcsDriverShape>()(
200 "t3/vcs/GitVcsDriver",
201) {}
202
203const WORKSPACE_FILES_MAX_OUTPUT_BYTES = 16 * 1024 * 1024;
204const GIT_CHECK_IGNORE_MAX_STDIN_BYTES = 256 * 1024;
205const WORKSPACE_GIT_HARDENED_CONFIG_ARGS = [
206 "-c",
207 "core.fsmonitor=false",
208 "-c",
209 "core.untrackedCache=false",
210] as const;
211
212const nowFreshness = Effect.fn("GitVcsDriver.nowFreshness")(function* () {
213 const now = yield* DateTime.now;
214 return {
215 source: "live-local" as const,
216 observedAt: now,
217 expiresAt: Option.none(),
218 };
219});
220
221function splitNullSeparatedPaths(input: string, truncated: boolean): string[] {
222 const parts = input.split("\0");
223 if (parts.length === 0) return [];
224
225 if (truncated && parts[parts.length - 1]?.length) {
226 parts.pop();
227 }
228
229 return parts.filter((value) => value.length > 0);
230}
231
232function chunkPathsForGitCheckIgnore(relativePaths: ReadonlyArray<string>): string[][] {
233 const chunks: string[][] = [];
234 let chunk: string[] = [];
235 let chunkBytes = 0;
236
237 for (const relativePath of relativePaths) {
238 const relativePathBytes = Buffer.byteLength(relativePath) + 1;
239 if (chunk.length > 0 && chunkBytes + relativePathBytes > GIT_CHECK_IGNORE_MAX_STDIN_BYTES) {
240 chunks.push(chunk);
241 chunk = [];
242 chunkBytes = 0;
243 }
244
245 chunk.push(relativePath);
246 chunkBytes += relativePathBytes;
247
248 if (chunkBytes >= GIT_CHECK_IGNORE_MAX_STDIN_BYTES) {
249 chunks.push(chunk);
250 chunk = [];
251 chunkBytes = 0;
252 }
253 }
254
255 if (chunk.length > 0) {
256 chunks.push(chunk);
257 }
258
259 return chunks;
260}
261
262function parseGitRemoteVerboseOutput(
263 output: string,
264): Map<string, { url?: string; pushUrl?: string }> {
265 const remotes = new Map<string, { url?: string; pushUrl?: string }>();
266 for (const line of output.split("\n")) {
267 const trimmed = line.trim();
268 if (trimmed.length === 0) {
269 continue;
270 }
271
272 const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)$/.exec(trimmed);
273 if (!match) {
274 continue;
275 }
276
277 const name = match[1];
278 const url = match[2];
279 const direction = match[3];
280 if (!name || !url || !direction) {
281 continue;
282 }
283 const remote = remotes.get(name) ?? {};
284 if (direction === "fetch") {
285 remote.url = url;
286 } else {
287 remote.pushUrl = url;
288 }
289 remotes.set(name, remote);
290 }
291 return remotes;
292}
293
294const gitCommand = (
295 process: VcsProcessShape,
296 operation: string,
297 cwd: string,
298 args: ReadonlyArray<string>,
299 options?: {
300 readonly stdin?: string;
301 readonly env?: NodeJS.ProcessEnv;
302 readonly allowNonZeroExit?: boolean;
303 readonly timeoutMs?: number;
304 readonly maxOutputBytes?: number;
305 readonly truncateOutputAtMaxBytes?: boolean;
306 },
307) =>
308 process.run({
309 operation,
310 command: "git",
311 args,
312 cwd,
313 ...(options?.stdin !== undefined ? { stdin: options.stdin } : {}),
314 ...(options?.env !== undefined ? { env: options.env } : {}),
315 ...(options?.allowNonZeroExit !== undefined
316 ? { allowNonZeroExit: options.allowNonZeroExit }
317 : {}),
318 ...(options?.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
319 ...(options?.maxOutputBytes !== undefined ? { maxOutputBytes: options.maxOutputBytes } : {}),
320 ...(options?.truncateOutputAtMaxBytes !== undefined
321 ? { truncateOutputAtMaxBytes: options.truncateOutputAtMaxBytes }
322 : {}),
323 });
324
325export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* () {
326 const process = yield* VcsProcess;
327 const capabilities = {
328 kind: "git" as const,
329 supportsWorktrees: true,
330 supportsBookmarks: false,
331 supportsAtomicSnapshot: false,
332 supportsPushDefaultRemote: true,
333 ignoreClassifier: "native" as const,
334 };
335
336 const isInsideWorkTree: VcsDriverShape["isInsideWorkTree"] = (cwd) =>
337 gitCommand(
338 process,
339 "GitVcsDriver.isInsideWorkTree",
340 cwd,
341 ["rev-parse", "--is-inside-work-tree"],
342 {
343 allowNonZeroExit: true,
344 timeoutMs: 5_000,
345 maxOutputBytes: 4_096,
346 },
347 ).pipe(Effect.map((result) => result.exitCode === 0 && result.stdout.trim() === "true"));
348
349 const execute: VcsDriverShape["execute"] = (input) =>
350 gitCommand(process, input.operation, input.cwd, input.args, {
351 ...(input.stdin !== undefined ? { stdin: input.stdin } : {}),
352 ...(input.env !== undefined ? { env: input.env } : {}),
353 ...(input.allowNonZeroExit !== undefined ? { allowNonZeroExit: input.allowNonZeroExit } : {}),
354 ...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}),
355 ...(input.maxOutputBytes !== undefined ? { maxOutputBytes: input.maxOutputBytes } : {}),
356 ...(input.truncateOutputAtMaxBytes !== undefined
357 ? { truncateOutputAtMaxBytes: input.truncateOutputAtMaxBytes }
358 : {}),
359 });
360
361 const detectRepository: VcsDriverShape["detectRepository"] = Effect.fn("detectRepository")(
362 function* (cwd) {
363 if (!(yield* isInsideWorkTree(cwd))) {
364 return null;
365 }
366
367 const root = yield* gitCommand(process, "GitVcsDriver.detectRepository.root", cwd, [
368 "rev-parse",
369 "--show-toplevel",
370 ]);
371 const gitCommonDir = yield* gitCommand(
372 process,
373 "GitVcsDriver.detectRepository.commonDir",
374 cwd,
375 ["rev-parse", "--git-common-dir"],
376 ).pipe(Effect.catch(() => Effect.succeed(null)));
377
378 return {
379 kind: "git" as const,
380 rootPath: root.stdout.trim(),
381 metadataPath: gitCommonDir?.stdout.trim() || null,
382 freshness: yield* nowFreshness(),
383 };
384 },
385 );
386
387 const listWorkspaceFiles: VcsDriverShape["listWorkspaceFiles"] = (cwd) =>
388 gitCommand(
389 process,
390 "GitVcsDriver.listWorkspaceFiles",
391 cwd,
392 [
393 ...WORKSPACE_GIT_HARDENED_CONFIG_ARGS,
394 "ls-files",
395 "--cached",
396 "--others",
397 "--exclude-standard",
398 "-z",
399 ],
400 {
401 allowNonZeroExit: true,
402 timeoutMs: 20_000,
403 maxOutputBytes: WORKSPACE_FILES_MAX_OUTPUT_BYTES,
404 truncateOutputAtMaxBytes: true,
405 },
406 ).pipe(
407 Effect.flatMap((result) =>
408 result.exitCode === 0
409 ? Effect.gen(function* () {
410 const freshness = yield* nowFreshness();
411 return {
412 paths: splitNullSeparatedPaths(result.stdout, result.stdoutTruncated),
413 truncated: result.stdoutTruncated,
414 freshness,
415 };
416 })
417 : Effect.fail(
418 new VcsProcessExitError({
419 operation: "GitVcsDriver.listWorkspaceFiles",
420 command: "git ls-files",
421 cwd,
422 exitCode: result.exitCode,
423 detail: result.stderr.trim() || "git ls-files failed",
424 }),
425 ),
426 ),
427 );
428
429 const listRemotes: VcsDriverShape["listRemotes"] = Effect.fn("listRemotes")(function* (cwd) {
430 const result = yield* gitCommand(process, "GitVcsDriver.listRemotes", cwd, ["remote", "-v"], {
431 allowNonZeroExit: true,
432 timeoutMs: 5_000,
433 maxOutputBytes: 64 * 1024,
434 });
435
436 if (result.exitCode !== 0) {
437 return yield* new VcsProcessExitError({
438 operation: "GitVcsDriver.listRemotes",
439 command: "git remote -v",
440 cwd,
441 exitCode: result.exitCode,
442 detail: result.stderr.trim() || "git remote -v failed",
443 });
444 }
445
446 const parsed = parseGitRemoteVerboseOutput(result.stdout);
447 const remotes = Array.from(parsed.entries()).flatMap(([name, remote]) => {
448 if (!remote.url) {
449 return [];
450 }
451 return [
452 {
453 name,
454 url: remote.url,
455 pushUrl: remote.pushUrl ? Option.some(remote.pushUrl) : Option.none(),
456 isPrimary: name === "origin",
457 },
458 ];
459 });
460
461 return {
462 remotes,
463 freshness: yield* nowFreshness(),
464 };
465 });
466
467 const filterIgnoredPaths: VcsDriverShape["filterIgnoredPaths"] = Effect.fn("filterIgnoredPaths")(
468 function* (cwd, relativePaths) {
469 if (relativePaths.length === 0) {
470 return relativePaths;
471 }
472
473 const ignoredPaths = new Set<string>();
474 const chunks = chunkPathsForGitCheckIgnore(relativePaths);
475
476 for (const chunk of chunks) {
477 const result = yield* gitCommand(
478 process,
479 "GitVcsDriver.filterIgnoredPaths",
480 cwd,
481 [...WORKSPACE_GIT_HARDENED_CONFIG_ARGS, "check-ignore", "--no-index", "-z", "--stdin"],
482 {
483 stdin: `${chunk.join("\0")}\0`,
484 allowNonZeroExit: true,
485 timeoutMs: 20_000,
486 maxOutputBytes: WORKSPACE_FILES_MAX_OUTPUT_BYTES,
487 truncateOutputAtMaxBytes: true,
488 },
489 );
490
491 if (result.exitCode !== 0 && result.exitCode !== 1) {
492 return yield* new VcsProcessExitError({
493 operation: "GitVcsDriver.filterIgnoredPaths",
494 command: "git check-ignore",
495 cwd,
496 exitCode: result.exitCode,
497 detail: result.stderr.trim() || "git check-ignore failed",
498 });
499 }
500
501 for (const ignoredPath of splitNullSeparatedPaths(result.stdout, result.stdoutTruncated)) {
502 ignoredPaths.add(ignoredPath);
503 }
504 }
505
506 if (ignoredPaths.size === 0) {
507 return relativePaths;
508 }
509
510 return relativePaths.filter((relativePath) => !ignoredPaths.has(relativePath));
511 },
512 );
513
514 const initRepository: VcsDriverShape["initRepository"] = (input) =>
515 gitCommand(process, "GitVcsDriver.initRepository", input.cwd, ["init"], {
516 timeoutMs: 10_000,
517 maxOutputBytes: 64 * 1024,
518 }).pipe(Effect.asVoid);
519
520 return {
521 capabilities,
522 execute,
523 detectRepository,
524 isInsideWorkTree,
525 listWorkspaceFiles,
526 listRemotes,
527 filterIgnoredPaths,
528 initRepository,
529 } satisfies VcsDriverShape;
530});
531
532export const makeVcsDriver = Effect.fn("makeGitVcsDriver")(function* () {
533 const driver = yield* makeVcsDriverShape();
534 return VcsDriver.of(driver);
535});
536
537export const make = Effect.fn("makeGitVcsDriverService")(function* () {
538 const git = yield* makeGitVcsDriverCore();
539 return GitVcsDriver.of(git);
540});
541
542export const vcsLayer = Layer.effect(VcsDriver, makeVcsDriver());
543export const layer = Layer.effect(GitVcsDriver, make());
apps/server/src/vcs/GitVcsDriverCore.test.ts created+246
......@@ -0,0 +1,246 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";
3import { Effect, FileSystem, Layer, Path, PlatformError, Scope } from "effect";
4import { describe } from "vitest";
5
6import { GitCommandError } from "@t3tools/contracts";
7import { ServerConfig } from "../config.ts";
8import * as GitVcsDriver from "./GitVcsDriver.ts";
9
10const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), {
11 prefix: "t3-git-vcs-driver-test-",
12});
13const TestLayer = GitVcsDriver.layer.pipe(
14 Layer.provide(ServerConfigLayer),
15 Layer.provideMerge(NodeServices.layer),
16);
17
18const makeTmpDir = (
19 prefix = "git-vcs-driver-test-",
20): Effect.Effect<string, PlatformError.PlatformError, FileSystem.FileSystem | Scope.Scope> =>
21 Effect.gen(function* () {
22 const fileSystem = yield* FileSystem.FileSystem;
23 return yield* fileSystem.makeTempDirectoryScoped({ prefix });
24 });
25
26const writeTextFile = (
27 cwd: string,
28 relativePath: string,
29 contents: string,
30): Effect.Effect<void, PlatformError.PlatformError, FileSystem.FileSystem | Path.Path> =>
31 Effect.gen(function* () {
32 const fileSystem = yield* FileSystem.FileSystem;
33 const pathService = yield* Path.Path;
34 const filePath = pathService.join(cwd, relativePath);
35 yield* fileSystem.makeDirectory(pathService.dirname(filePath), { recursive: true });
36 yield* fileSystem.writeFileString(filePath, contents);
37 });
38
39const git = (
40 cwd: string,
41 args: ReadonlyArray<string>,
42 env?: NodeJS.ProcessEnv,
43): Effect.Effect<string, GitCommandError, GitVcsDriver.GitVcsDriver> =>
44 Effect.gen(function* () {
45 const driver = yield* GitVcsDriver.GitVcsDriver;
46 const result = yield* driver.execute({
47 operation: "GitVcsDriver.test.git",
48 cwd,
49 args,
50 ...(env ? { env } : {}),
51 timeoutMs: 10_000,
52 });
53 return result.stdout.trim();
54 });
55
56const initRepoWithCommit = (
57 cwd: string,
58): Effect.Effect<
59 { readonly initialBranch: string },
60 GitCommandError | PlatformError.PlatformError,
61 GitVcsDriver.GitVcsDriver | FileSystem.FileSystem | Path.Path
62> =>
63 Effect.gen(function* () {
64 const driver = yield* GitVcsDriver.GitVcsDriver;
65 yield* driver.initRepo({ cwd });
66 yield* git(cwd, ["config", "user.email", "test@test.com"]);
67 yield* git(cwd, ["config", "user.name", "Test"]);
68 yield* writeTextFile(cwd, "README.md", "# test\n");
69 yield* git(cwd, ["add", "."]);
70 yield* git(cwd, ["commit", "-m", "initial commit"]);
71 const initialBranch = yield* git(cwd, ["branch", "--show-current"]);
72 return { initialBranch };
73 });
74
75it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
76 describe("repository status", () => {
77 it.effect("reports non-repository directories without failing", () =>
78 Effect.gen(function* () {
79 const cwd = yield* makeTmpDir();
80 const driver = yield* GitVcsDriver.GitVcsDriver;
81
82 const refs = yield* driver.listRefs({ cwd });
83 assert.equal(refs.isRepo, false);
84 assert.deepStrictEqual(refs.refs, []);
85 }),
86 );
87
88 it.effect("reports refName and dirty state for a repository", () =>
89 Effect.gen(function* () {
90 const cwd = yield* makeTmpDir();
91 const { initialBranch } = yield* initRepoWithCommit(cwd);
92 yield* writeTextFile(cwd, "feature.ts", "export const value = 1;\n");
93
94 const status = yield* (yield* GitVcsDriver.GitVcsDriver).statusDetails(cwd);
95
96 assert.equal(status.isRepo, true);
97 assert.equal(status.branch, initialBranch);
98 assert.equal(status.hasWorkingTreeChanges, true);
99 assert.include(
100 status.workingTree.files.map((file) => file.path),
101 "feature.ts",
102 );
103 }),
104 );
105 });
106
107 describe("refName operations", () => {
108 it.effect("creates, checks out, renames, and lists refs", () =>
109 Effect.gen(function* () {
110 const cwd = yield* makeTmpDir();
111 yield* initRepoWithCommit(cwd);
112 const driver = yield* GitVcsDriver.GitVcsDriver;
113
114 yield* driver.createRef({ cwd, refName: "feature/original" });
115 const switchRef = yield* driver.switchRef({ cwd, refName: "feature/original" });
116 assert.equal(switchRef.refName, "feature/original");
117
118 const renamed = yield* driver.renameBranch({
119 cwd,
120 oldBranch: "feature/original",
121 newBranch: "feature/renamed",
122 });
123 assert.equal(renamed.branch, "feature/renamed");
124 assert.equal(yield* git(cwd, ["branch", "--show-current"]), "feature/renamed");
125
126 const refs = yield* driver.listRefs({ cwd });
127 assert.equal(
128 refs.refs.find((refName) => refName.name === "feature/renamed")?.current,
129 true,
130 );
131 }),
132 );
133
134 it.effect("returns the existing refName when rename source and target match", () =>
135 Effect.gen(function* () {
136 const cwd = yield* makeTmpDir();
137 yield* initRepoWithCommit(cwd);
138 const driver = yield* GitVcsDriver.GitVcsDriver;
139
140 const current = yield* git(cwd, ["branch", "--show-current"]);
141 const result = yield* driver.renameBranch({
142 cwd,
143 oldBranch: current,
144 newBranch: current,
145 });
146
147 assert.equal(result.branch, current);
148 }),
149 );
150 });
151
152 describe("worktree operations", () => {
153 it.effect("creates and removes a worktree for a new refName", () =>
154 Effect.gen(function* () {
155 const cwd = yield* makeTmpDir();
156 const { initialBranch } = yield* initRepoWithCommit(cwd);
157 const pathService = yield* Path.Path;
158 const worktreePath = pathService.join(
159 yield* makeTmpDir("git-worktrees-"),
160 "feature-worktree",
161 );
162 const driver = yield* GitVcsDriver.GitVcsDriver;
163
164 const created = yield* driver.createWorktree({
165 cwd,
166 path: worktreePath,
167 refName: initialBranch,
168 newRefName: "feature/worktree",
169 });
170
171 assert.equal(created.worktree.path, worktreePath);
172 assert.equal(created.worktree.refName, "feature/worktree");
173 assert.equal(yield* git(worktreePath, ["branch", "--show-current"]), "feature/worktree");
174
175 yield* driver.removeWorktree({ cwd, path: worktreePath });
176 const fileSystem = yield* FileSystem.FileSystem;
177 assert.equal(yield* fileSystem.exists(worktreePath), false);
178 }),
179 );
180 });
181
182 describe("commit context", () => {
183 it.effect("stages selected files and commits only those files", () =>
184 Effect.gen(function* () {
185 const cwd = yield* makeTmpDir();
186 yield* initRepoWithCommit(cwd);
187 const driver = yield* GitVcsDriver.GitVcsDriver;
188
189 yield* writeTextFile(cwd, "a.txt", "a\n");
190 yield* writeTextFile(cwd, "b.txt", "b\n");
191
192 const context = yield* driver.prepareCommitContext(cwd, ["a.txt"]);
193 assert.include(context?.stagedSummary ?? "", "a.txt");
194 assert.notInclude(context?.stagedSummary ?? "", "b.txt");
195
196 const commit = yield* driver.commit(cwd, "Add a", "");
197 assert.match(commit.commitSha, /^[a-f0-9]{40}$/);
198 assert.equal(yield* git(cwd, ["log", "-1", "--pretty=%s"]), "Add a");
199
200 const status = yield* git(cwd, ["status", "--porcelain"]);
201 assert.include(status, "?? b.txt");
202 assert.notInclude(status, "a.txt");
203 }),
204 );
205 });
206
207 describe("remote operations", () => {
208 it.effect("pushes with upstream setup and skips when already up to date", () =>
209 Effect.gen(function* () {
210 const cwd = yield* makeTmpDir();
211 const remote = yield* makeTmpDir("git-remote-");
212 yield* initRepoWithCommit(cwd);
213 yield* git(remote, ["init", "--bare"]);
214 yield* git(cwd, ["remote", "add", "origin", remote]);
215 yield* (yield* GitVcsDriver.GitVcsDriver).createRef({
216 cwd,
217 refName: "feature/push",
218 });
219 yield* (yield* GitVcsDriver.GitVcsDriver).switchRef({
220 cwd,
221 refName: "feature/push",
222 });
223 yield* writeTextFile(cwd, "feature.txt", "feature\n");
224 yield* (yield* GitVcsDriver.GitVcsDriver).prepareCommitContext(cwd);
225 yield* (yield* GitVcsDriver.GitVcsDriver).commit(cwd, "Add feature", "");
226
227 const pushed = yield* (yield* GitVcsDriver.GitVcsDriver).pushCurrentBranch(cwd, null);
228 assert.deepInclude(pushed, {
229 status: "pushed",
230 branch: "feature/push",
231 setUpstream: true,
232 });
233 assert.equal(
234 yield* git(cwd, ["rev-parse", "--abbrev-ref", "@{upstream}"]),
235 "origin/feature/push",
236 );
237
238 const skipped = yield* (yield* GitVcsDriver.GitVcsDriver).pushCurrentBranch(cwd, null);
239 assert.deepInclude(skipped, {
240 status: "skipped_up_to_date",
241 branch: "feature/push",
242 });
243 }),
244 );
245 });
246});
apps/server/src/vcs/GitVcsDriverCore.ts created+2087
......@@ -0,0 +1,2087 @@
1import {
2 Cache,
3 Data,
4 DateTime,
5 Duration,
6 Effect,
7 Exit,
8 FileSystem,
9 Option,
10 Path,
11 PlatformError,
12 Ref,
13 Result,
14 Schema,
15 Scope,
16 Semaphore,
17 Stream,
18} from "effect";
19import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
20
21import { GitCommandError, type VcsRef } from "@t3tools/contracts";
22import { dedupeRemoteBranchesWithLocalMatches } from "@t3tools/shared/git";
23import { compactTraceAttributes } from "../observability/Attributes.ts";
24import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../observability/Metrics.ts";
25import type {
26 ExecuteGitProgress,
27 GitCommitOptions,
28 GitVcsDriverShape,
29 GitStatusDetails,
30 ExecuteGitInput,
31 ExecuteGitResult,
32} from "./GitVcsDriver.ts";
33import {
34 parseRemoteNames,
35 parseRemoteNamesInGitOrder,
36 parseRemoteRefWithRemoteNames,
37} from "../git/remoteRefs.ts";
38import { ServerConfig } from "../config.ts";
39import { decodeJsonResult } from "@t3tools/shared/schemaJson";
40
41const DEFAULT_TIMEOUT_MS = 30_000;
42const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000;
43const OUTPUT_TRUNCATED_MARKER = "\n\n[truncated]";
44const PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES = 49_000;
45const RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES = 19_000;
46const RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES = 19_000;
47const RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES = 59_000;
48const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15);
49const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5);
50const STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN = Duration.seconds(5);
51const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048;
52const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const;
53const GIT_LIST_BRANCHES_DEFAULT_LIMIT = 100;
54const NON_REPOSITORY_STATUS_DETAILS = Object.freeze<GitStatusDetails>({
55 isRepo: false,
56 hasOriginRemote: false,
57 isDefaultBranch: false,
58 branch: null,
59 upstreamRef: null,
60 hasWorkingTreeChanges: false,
61 workingTree: { files: [], insertions: 0, deletions: 0 },
62 hasUpstream: false,
63 aheadCount: 0,
64 behindCount: 0,
65});
66
67type TraceTailState = {
68 processedChars: number;
69 remainder: string;
70};
71
72class StatusRemoteRefreshCacheKey extends Data.Class<{
73 gitCommonDir: string;
74 remoteName: string;
75}> {}
76
77interface ExecuteGitOptions {
78 stdin?: string | undefined;
79 timeoutMs?: number | undefined;
80 allowNonZeroExit?: boolean | undefined;
81 fallbackErrorMessage?: string | undefined;
82 maxOutputBytes?: number | undefined;
83 truncateOutputAtMaxBytes?: boolean | undefined;
84 progress?: ExecuteGitProgress | undefined;
85}
86
87function parseBranchAb(value: string): { ahead: number; behind: number } {
88 const match = value.match(/^\+(\d+)\s+-(\d+)$/);
89 if (!match) return { ahead: 0, behind: 0 };
90 return {
91 ahead: Number(match[1] ?? "0"),
92 behind: Number(match[2] ?? "0"),
93 };
94}
95
96function parseNumstatEntries(
97 stdout: string,
98): Array<{ path: string; insertions: number; deletions: number }> {
99 const entries: Array<{ path: string; insertions: number; deletions: number }> = [];
100 for (const line of stdout.split(/\r?\n/g)) {
101 if (line.trim().length === 0) continue;
102 const [addedRaw, deletedRaw, ...pathParts] = line.split("\t");
103 const rawPath =
104 pathParts.length > 1 ? (pathParts.at(-1) ?? "").trim() : pathParts.join("\t").trim();
105 if (rawPath.length === 0) continue;
106 const added = Number.parseInt(addedRaw ?? "0", 10);
107 const deleted = Number.parseInt(deletedRaw ?? "0", 10);
108 const renameArrowIndex = rawPath.indexOf(" => ");
109 const normalizedPath =
110 renameArrowIndex >= 0 ? rawPath.slice(renameArrowIndex + " => ".length).trim() : rawPath;
111 entries.push({
112 path: normalizedPath.length > 0 ? normalizedPath : rawPath,
113 insertions: Number.isFinite(added) ? added : 0,
114 deletions: Number.isFinite(deleted) ? deleted : 0,
115 });
116 }
117 return entries;
118}
119
120function parsePorcelainPath(line: string): string | null {
121 if (line.startsWith("? ") || line.startsWith("! ")) {
122 const simple = line.slice(2).trim();
123 return simple.length > 0 ? simple : null;
124 }
125
126 if (!(line.startsWith("1 ") || line.startsWith("2 ") || line.startsWith("u "))) {
127 return null;
128 }
129
130 const tabIndex = line.indexOf("\t");
131 if (tabIndex >= 0) {
132 const fromTab = line.slice(tabIndex + 1);
133 const [filePath] = fromTab.split("\t");
134 return filePath?.trim().length ? filePath.trim() : null;
135 }
136
137 const parts = line.trim().split(/\s+/g);
138 const filePath = parts.at(-1) ?? "";
139 return filePath.length > 0 ? filePath : null;
140}
141
142function parseBranchLine(line: string): { name: string; current: boolean } | null {
143 const trimmed = line.trim();
144 if (trimmed.length === 0) return null;
145
146 const name = trimmed.replace(/^[*+]\s+/, "");
147 // Exclude symbolic refs like: "origin/HEAD -> origin/main".
148 // Exclude detached HEAD pseudo-refs like: "(HEAD detached at origin/main)".
149 if (name.includes(" -> ") || name.startsWith("(")) return null;
150
151 return {
152 name,
153 current: trimmed.startsWith("* "),
154 };
155}
156
157function filterBranchesForListQuery(
158 refs: ReadonlyArray<VcsRef>,
159 query?: string,
160): ReadonlyArray<VcsRef> {
161 if (!query) {
162 return refs;
163 }
164
165 const normalizedQuery = query.toLowerCase();
166 return refs.filter((refName) => refName.name.toLowerCase().includes(normalizedQuery));
167}
168
169function paginateBranches(input: {
170 refs: ReadonlyArray<VcsRef>;
171 cursor?: number | undefined;
172 limit?: number | undefined;
173}): {
174 refs: ReadonlyArray<VcsRef>;
175 nextCursor: number | null;
176 totalCount: number;
177} {
178 const cursor = input.cursor ?? 0;
179 const limit = input.limit ?? GIT_LIST_BRANCHES_DEFAULT_LIMIT;
180 const totalCount = input.refs.length;
181 const refs = input.refs.slice(cursor, cursor + limit);
182 const nextCursor = cursor + refs.length < totalCount ? cursor + refs.length : null;
183
184 return {
185 refs,
186 nextCursor,
187 totalCount,
188 };
189}
190
191function sanitizeRemoteName(value: string): string {
192 const sanitized = value
193 .trim()
194 .replace(/[^A-Za-z0-9._-]+/g, "-")
195 .replace(/^-+|-+$/g, "");
196 return sanitized.length > 0 ? sanitized : "fork";
197}
198
199function normalizeRemoteUrl(value: string): string {
200 return value
201 .trim()
202 .replace(/\/+$/g, "")
203 .replace(/\.git$/i, "")
204 .toLowerCase();
205}
206
207function parseRemoteFetchUrls(stdout: string): Map<string, string> {
208 const remotes = new Map<string, string>();
209 for (const line of stdout.split("\n")) {
210 const trimmed = line.trim();
211 if (trimmed.length === 0) continue;
212 const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)$/.exec(trimmed);
213 if (!match) continue;
214 const [, remoteName = "", remoteUrl = "", direction = ""] = match;
215 if (direction !== "fetch" || remoteName.length === 0 || remoteUrl.length === 0) {
216 continue;
217 }
218 remotes.set(remoteName, remoteUrl);
219 }
220 return remotes;
221}
222
223function parseUpstreamRefWithRemoteNames(
224 upstreamRef: string,
225 remoteNames: ReadonlyArray<string>,
226): { upstreamRef: string; remoteName: string; branchName: string } | null {
227 const parsed = parseRemoteRefWithRemoteNames(upstreamRef, remoteNames);
228 if (!parsed) {
229 return null;
230 }
231
232 return {
233 upstreamRef,
234 remoteName: parsed.remoteName,
235 branchName: parsed.branchName,
236 };
237}
238
239function parseUpstreamRefByFirstSeparator(
240 upstreamRef: string,
241): { upstreamRef: string; remoteName: string; branchName: string } | null {
242 const separatorIndex = upstreamRef.indexOf("/");
243 if (separatorIndex <= 0 || separatorIndex === upstreamRef.length - 1) {
244 return null;
245 }
246
247 const remoteName = upstreamRef.slice(0, separatorIndex).trim();
248 const branchName = upstreamRef.slice(separatorIndex + 1).trim();
249 if (remoteName.length === 0 || branchName.length === 0) {
250 return null;
251 }
252
253 return {
254 upstreamRef,
255 remoteName,
256 branchName,
257 };
258}
259
260function parseTrackingBranchByUpstreamRef(stdout: string, upstreamRef: string): string | null {
261 for (const line of stdout.split("\n")) {
262 const trimmedLine = line.trim();
263 if (trimmedLine.length === 0) {
264 continue;
265 }
266 const [branchNameRaw, upstreamBranchRaw = ""] = trimmedLine.split("\t");
267 const branchName = branchNameRaw?.trim() ?? "";
268 const candidateUpstreamRef = upstreamBranchRaw.trim();
269 if (branchName.length === 0 || candidateUpstreamRef.length === 0) {
270 continue;
271 }
272 if (candidateUpstreamRef === upstreamRef) {
273 return branchName;
274 }
275 }
276
277 return null;
278}
279
280function deriveLocalBranchNameFromRemoteRef(branchName: string): string | null {
281 const separatorIndex = branchName.indexOf("/");
282 if (separatorIndex <= 0 || separatorIndex === branchName.length - 1) {
283 return null;
284 }
285 const localBranch = branchName.slice(separatorIndex + 1).trim();
286 return localBranch.length > 0 ? localBranch : null;
287}
288
289function commandLabel(args: readonly string[]): string {
290 return `git ${args.join(" ")}`;
291}
292
293function parseDefaultBranchFromRemoteHeadRef(value: string, remoteName: string): string | null {
294 const trimmed = value.trim();
295 const prefix = `refs/remotes/${remoteName}/`;
296 if (!trimmed.startsWith(prefix)) {
297 return null;
298 }
299 const refName = trimmed.slice(prefix.length).trim();
300 return refName.length > 0 ? refName : null;
301}
302
303function createGitCommandError(
304 operation: string,
305 cwd: string,
306 args: readonly string[],
307 detail: string,
308 cause?: unknown,
309): GitCommandError {
310 return new GitCommandError({
311 operation,
312 command: commandLabel(args),
313 cwd,
314 detail,
315 ...(cause !== undefined ? { cause } : {}),
316 });
317}
318
319function quoteGitCommand(args: ReadonlyArray<string>): string {
320 return `git ${args.join(" ")}`;
321}
322
323function isMissingGitCwdError(error: GitCommandError): boolean {
324 const normalized = `${error.detail}\n${error.message}`.toLowerCase();
325 return (
326 normalized.includes("no such file or directory") ||
327 normalized.includes("notfound: filesystem.access") ||
328 normalized.includes("enoent") ||
329 normalized.includes("not a directory")
330 );
331}
332
333function toGitCommandError(
334 input: Pick<ExecuteGitInput, "operation" | "cwd" | "args">,
335 detail: string,
336) {
337 return (cause: unknown) =>
338 Schema.is(GitCommandError)(cause)
339 ? cause
340 : new GitCommandError({
341 operation: input.operation,
342 command: quoteGitCommand(input.args),
343 cwd: input.cwd,
344 detail: `${cause instanceof Error && cause.message.length > 0 ? cause.message : "Unknown error"} - ${detail}`,
345 ...(cause !== undefined ? { cause } : {}),
346 });
347}
348
349interface Trace2Monitor {
350 readonly env: NodeJS.ProcessEnv;
351 readonly flush: Effect.Effect<void, never>;
352}
353
354const nowUnixNano = DateTime.now.pipe(
355 Effect.map((now) => BigInt(DateTime.toEpochMillis(now)) * 1_000_000n),
356);
357
358const addCurrentSpanEvent = (name: string, attributes: Record<string, unknown>) =>
359 Effect.gen(function* () {
360 const span = yield* Effect.currentSpan;
361 const timestamp = yield* nowUnixNano;
362 yield* Effect.sync(() => {
363 span.event(name, timestamp, compactTraceAttributes(attributes));
364 });
365 }).pipe(Effect.catch(() => Effect.void));
366
367function trace2ChildKey(record: Record<string, unknown>): string | null {
368 const childId = record.child_id;
369 if (typeof childId === "number" || typeof childId === "string") {
370 return String(childId);
371 }
372 const hookName = record.hook_name;
373 return typeof hookName === "string" && hookName.trim().length > 0 ? hookName.trim() : null;
374}
375
376const Trace2Record = Schema.Record(Schema.String, Schema.Unknown);
377
378const createTrace2Monitor = Effect.fn("createTrace2Monitor")(function* (
379 input: Pick<ExecuteGitInput, "operation" | "cwd" | "args">,
380 progress: ExecuteGitProgress | undefined,
381): Effect.fn.Return<
382 Trace2Monitor,
383 PlatformError.PlatformError,
384 Scope.Scope | FileSystem.FileSystem | Path.Path
385> {
386 if (!progress?.onHookStarted && !progress?.onHookFinished) {
387 return {
388 env: {},
389 flush: Effect.void,
390 };
391 }
392
393 const fs = yield* FileSystem.FileSystem;
394 const path = yield* Path.Path;
395 const traceFilePath = yield* fs.makeTempFileScoped({
396 prefix: `t3code-git-trace2-${process.pid}-`,
397 suffix: ".json",
398 });
399 const hookStartByChildKey = new Map<string, { hookName: string; startedAtMs: number }>();
400 const traceTailState = yield* Ref.make<TraceTailState>({
401 processedChars: 0,
402 remainder: "",
403 });
404
405 const handleTraceLine = Effect.fn("handleTraceLine")(function* (line: string) {
406 const trimmedLine = line.trim();
407 if (trimmedLine.length === 0) {
408 return;
409 }
410
411 const traceRecord = decodeJsonResult(Trace2Record)(trimmedLine);
412 if (Result.isFailure(traceRecord)) {
413 yield* Effect.logDebug(
414 `GitVcsDriver.trace2: failed to parse trace line for ${quoteGitCommand(input.args)} in ${input.cwd}`,
415 traceRecord.failure,
416 );
417 return;
418 }
419
420 if (traceRecord.success.child_class !== "hook") {
421 return;
422 }
423
424 const event = traceRecord.success.event;
425 const childKey = trace2ChildKey(traceRecord.success);
426 if (childKey === null) {
427 return;
428 }
429 const started = hookStartByChildKey.get(childKey);
430 const hookNameFromEvent =
431 typeof traceRecord.success.hook_name === "string" ? traceRecord.success.hook_name.trim() : "";
432 const hookName = hookNameFromEvent.length > 0 ? hookNameFromEvent : (started?.hookName ?? "");
433 if (hookName.length === 0) {
434 return;
435 }
436
437 if (event === "child_start") {
438 const now = yield* DateTime.now;
439 hookStartByChildKey.set(childKey, { hookName, startedAtMs: DateTime.toEpochMillis(now) });
440 yield* addCurrentSpanEvent("git.hook.started", {
441 hookName,
442 });
443 if (progress.onHookStarted) {
444 yield* progress.onHookStarted(hookName);
445 }
446 return;
447 }
448
449 if (event === "child_exit") {
450 hookStartByChildKey.delete(childKey);
451 const code = traceRecord.success.exitCode;
452 const exitCode = typeof code === "number" && Number.isInteger(code) ? code : null;
453 const now = yield* DateTime.now;
454 const durationMs = started
455 ? Math.max(0, DateTime.toEpochMillis(now) - started.startedAtMs)
456 : null;
457 yield* addCurrentSpanEvent("git.hook.finished", {
458 hookName: started?.hookName ?? hookName,
459 exitCode,
460 durationMs,
461 });
462 if (progress.onHookFinished) {
463 yield* progress.onHookFinished({
464 hookName: started?.hookName ?? hookName,
465 exitCode,
466 durationMs,
467 });
468 }
469 }
470 });
471
472 const deltaMutex = yield* Semaphore.make(1);
473 const readTraceDelta = deltaMutex.withPermit(
474 fs.readFileString(traceFilePath).pipe(
475 Effect.flatMap((contents) =>
476 Effect.uninterruptible(
477 Ref.modify(traceTailState, ({ processedChars, remainder }) => {
478 if (contents.length <= processedChars) {
479 return [[], { processedChars, remainder }];
480 }
481
482 const appended = contents.slice(processedChars);
483 const combined = remainder + appended;
484 const lines = combined.split("\n");
485 const nextRemainder = lines.pop() ?? "";
486
487 return [
488 lines.map((line) => line.replace(/\r$/, "")),
489 {
490 processedChars: contents.length,
491 remainder: nextRemainder,
492 },
493 ];
494 }).pipe(
495 Effect.flatMap((lines) => Effect.forEach(lines, handleTraceLine, { discard: true })),
496 ),
497 ),
498 ),
499 Effect.ignore({ log: true }),
500 ),
501 );
502 const traceFileName = path.basename(traceFilePath);
503 yield* Stream.runForEach(fs.watch(traceFilePath), (event) => {
504 const eventPath = event.path;
505 const isTargetTraceEvent =
506 eventPath === traceFilePath ||
507 eventPath === traceFileName ||
508 path.basename(eventPath) === traceFileName;
509 if (!isTargetTraceEvent) return Effect.void;
510 return readTraceDelta;
511 }).pipe(Effect.ignoreCause({ log: true }), Effect.forkScoped);
512
513 const finalizeTrace2Monitor = Effect.fn("finalizeTrace2Monitor")(function* () {
514 yield* readTraceDelta;
515 const finalLine = yield* Ref.modify(traceTailState, ({ processedChars, remainder }) => [
516 remainder.trim(),
517 {
518 processedChars,
519 remainder: "",
520 },
521 ]);
522 if (finalLine.length > 0) {
523 yield* handleTraceLine(finalLine);
524 }
525 });
526
527 yield* Effect.addFinalizer(finalizeTrace2Monitor);
528
529 return {
530 env: {
531 GIT_TRACE2_EVENT: traceFilePath,
532 },
533 flush: readTraceDelta,
534 };
535});
536
537const collectOutput = Effect.fn("collectOutput")(function* <E>(
538 input: Pick<ExecuteGitInput, "operation" | "cwd" | "args">,
539 stream: Stream.Stream<Uint8Array, E>,
540 maxOutputBytes: number,
541 truncateOutputAtMaxBytes: boolean,
542 onLine: ((line: string) => Effect.Effect<void, never>) | undefined,
543): Effect.fn.Return<{ readonly text: string; readonly truncated: boolean }, GitCommandError> {
544 const decoder = new TextDecoder();
545 let bytes = 0;
546 let text = "";
547 let lineBuffer = "";
548 let truncated = false;
549
550 const emitCompleteLines = Effect.fn("emitCompleteLines")(function* (flush: boolean) {
551 let newlineIndex = lineBuffer.indexOf("\n");
552 while (newlineIndex >= 0) {
553 const line = lineBuffer.slice(0, newlineIndex).replace(/\r$/, "");
554 lineBuffer = lineBuffer.slice(newlineIndex + 1);
555 if (line.length > 0 && onLine) {
556 yield* onLine(line);
557 }
558 newlineIndex = lineBuffer.indexOf("\n");
559 }
560
561 if (flush) {
562 const trailing = lineBuffer.replace(/\r$/, "");
563 lineBuffer = "";
564 if (trailing.length > 0 && onLine) {
565 yield* onLine(trailing);
566 }
567 }
568 });
569
570 const processChunk = Effect.fn("processChunk")(function* (chunk: Uint8Array) {
571 if (truncateOutputAtMaxBytes && truncated) {
572 return;
573 }
574 const nextBytes = bytes + chunk.byteLength;
575 if (!truncateOutputAtMaxBytes && nextBytes > maxOutputBytes) {
576 return yield* new GitCommandError({
577 operation: input.operation,
578 command: quoteGitCommand(input.args),
579 cwd: input.cwd,
580 detail: `${quoteGitCommand(input.args)} output exceeded ${maxOutputBytes} bytes and was truncated.`,
581 });
582 }
583
584 const chunkToDecode =
585 truncateOutputAtMaxBytes && nextBytes > maxOutputBytes
586 ? chunk.subarray(0, Math.max(0, maxOutputBytes - bytes))
587 : chunk;
588 bytes += chunkToDecode.byteLength;
589 truncated = truncateOutputAtMaxBytes && nextBytes > maxOutputBytes;
590
591 const decoded = decoder.decode(chunkToDecode, { stream: !truncated });
592 text += decoded;
593 lineBuffer += decoded;
594 yield* emitCompleteLines(false);
595 });
596
597 yield* Stream.runForEach(stream, processChunk).pipe(
598 Effect.mapError(toGitCommandError(input, "output stream failed.")),
599 );
600
601 const remainder = truncated ? "" : decoder.decode();
602 text += remainder;
603 lineBuffer += remainder;
604 yield* emitCompleteLines(true);
605 return {
606 text,
607 truncated,
608 };
609});
610
611export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* (options?: {
612 executeOverride?: GitVcsDriverShape["execute"];
613}) {
614 const fileSystem = yield* FileSystem.FileSystem;
615 const path = yield* Path.Path;
616 const { worktreesDir } = yield* ServerConfig;
617
618 let executeRaw: GitVcsDriverShape["execute"];
619
620 if (options?.executeOverride) {
621 executeRaw = options.executeOverride;
622 } else {
623 const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
624 executeRaw = Effect.fnUntraced(function* (input) {
625 const commandInput = {
626 ...input,
627 args: [...input.args],
628 } as const;
629 const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
630 const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
631 const truncateOutputAtMaxBytes = input.truncateOutputAtMaxBytes ?? false;
632
633 const runGitCommand = Effect.fn("runGitCommand")(function* () {
634 const trace2Monitor = yield* createTrace2Monitor(commandInput, input.progress).pipe(
635 Effect.provideService(Path.Path, path),
636 Effect.provideService(FileSystem.FileSystem, fileSystem),
637 Effect.mapError(toGitCommandError(commandInput, "failed to create trace2 monitor.")),
638 );
639 const child = yield* commandSpawner
640 .spawn(
641 ChildProcess.make("git", commandInput.args, {
642 cwd: commandInput.cwd,
643 env: {
644 ...process.env,
645 ...input.env,
646 ...trace2Monitor.env,
647 },
648 }),
649 )
650 .pipe(Effect.mapError(toGitCommandError(commandInput, "failed to spawn.")));
651
652 const [stdout, stderr, exitCode] = yield* Effect.all(
653 [
654 collectOutput(
655 commandInput,
656 child.stdout,
657 maxOutputBytes,
658 truncateOutputAtMaxBytes,
659 input.progress?.onStdoutLine,
660 ),
661 collectOutput(
662 commandInput,
663 child.stderr,
664 maxOutputBytes,
665 truncateOutputAtMaxBytes,
666 input.progress?.onStderrLine,
667 ),
668 child.exitCode.pipe(
669 Effect.mapError(toGitCommandError(commandInput, "failed to report exit code.")),
670 ),
671 input.stdin === undefined
672 ? Effect.void
673 : Stream.run(Stream.encodeText(Stream.make(input.stdin)), child.stdin).pipe(
674 Effect.mapError(toGitCommandError(commandInput, "failed to write stdin.")),
675 ),
676 ],
677 { concurrency: "unbounded" },
678 ).pipe(Effect.map(([stdout, stderr, exitCode]) => [stdout, stderr, exitCode] as const));
679 yield* trace2Monitor.flush;
680
681 if (!input.allowNonZeroExit && exitCode !== 0) {
682 const trimmedStderr = stderr.text.trim();
683 return yield* new GitCommandError({
684 operation: commandInput.operation,
685 command: quoteGitCommand(commandInput.args),
686 cwd: commandInput.cwd,
687 detail:
688 trimmedStderr.length > 0
689 ? `${quoteGitCommand(commandInput.args)} failed: ${trimmedStderr}`
690 : `${quoteGitCommand(commandInput.args)} failed with code ${exitCode}.`,
691 });
692 }
693
694 return {
695 exitCode,
696 stdout: stdout.text,
697 stderr: stderr.text,
698 stdoutTruncated: stdout.truncated,
699 stderrTruncated: stderr.truncated,
700 } satisfies ExecuteGitResult;
701 });
702
703 return yield* runGitCommand().pipe(
704 Effect.scoped,
705 Effect.timeoutOption(timeoutMs),
706 Effect.flatMap((result) =>
707 Option.match(result, {
708 onNone: () =>
709 Effect.fail(
710 new GitCommandError({
711 operation: commandInput.operation,
712 command: quoteGitCommand(commandInput.args),
713 cwd: commandInput.cwd,
714 detail: `${quoteGitCommand(commandInput.args)} timed out.`,
715 }),
716 ),
717 onSome: Effect.succeed,
718 }),
719 ),
720 );
721 });
722 }
723
724 const execute: GitVcsDriverShape["execute"] = (input) =>
725 executeRaw(input).pipe(
726 withMetrics({
727 counter: gitCommandsTotal,
728 timer: gitCommandDuration,
729 attributes: {
730 operation: input.operation,
731 },
732 }),
733 Effect.withSpan(input.operation, {
734 kind: "client",
735 attributes: {
736 "git.operation": input.operation,
737 "git.cwd": input.cwd,
738 "git.args_count": input.args.length,
739 },
740 }),
741 );
742
743 const executeGit = (
744 operation: string,
745 cwd: string,
746 args: readonly string[],
747 options: ExecuteGitOptions = {},
748 ): Effect.Effect<ExecuteGitResult, GitCommandError> =>
749 execute({
750 operation,
751 cwd,
752 args,
753 ...(options.stdin !== undefined ? { stdin: options.stdin } : {}),
754 allowNonZeroExit: true,
755 ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
756 ...(options.maxOutputBytes !== undefined ? { maxOutputBytes: options.maxOutputBytes } : {}),
757 ...(options.truncateOutputAtMaxBytes !== undefined
758 ? { truncateOutputAtMaxBytes: options.truncateOutputAtMaxBytes }
759 : {}),
760 ...(options.progress ? { progress: options.progress } : {}),
761 }).pipe(
762 Effect.flatMap((result) => {
763 if (options.allowNonZeroExit || result.exitCode === 0) {
764 return Effect.succeed(result);
765 }
766 const stderr = result.stderr.trim();
767 if (stderr.length > 0) {
768 return Effect.fail(createGitCommandError(operation, cwd, args, stderr));
769 }
770 if (options.fallbackErrorMessage) {
771 return Effect.fail(
772 createGitCommandError(operation, cwd, args, options.fallbackErrorMessage),
773 );
774 }
775 return Effect.fail(
776 createGitCommandError(
777 operation,
778 cwd,
779 args,
780 `${commandLabel(args)} failed: code=${result.exitCode ?? "null"}`,
781 ),
782 );
783 }),
784 );
785
786 const runGit = (
787 operation: string,
788 cwd: string,
789 args: readonly string[],
790 allowNonZeroExit = false,
791 ): Effect.Effect<void, GitCommandError> =>
792 executeGit(operation, cwd, args, { allowNonZeroExit }).pipe(Effect.asVoid);
793
794 const runGitStdout = (
795 operation: string,
796 cwd: string,
797 args: readonly string[],
798 allowNonZeroExit = false,
799 ): Effect.Effect<string, GitCommandError> =>
800 executeGit(operation, cwd, args, { allowNonZeroExit }).pipe(
801 Effect.map((result) => result.stdout),
802 );
803
804 const runGitStdoutWithOptions = (
805 operation: string,
806 cwd: string,
807 args: readonly string[],
808 options: ExecuteGitOptions = {},
809 ): Effect.Effect<string, GitCommandError> =>
810 executeGit(operation, cwd, args, options).pipe(
811 Effect.map((result) =>
812 result.stdoutTruncated ? `${result.stdout}${OUTPUT_TRUNCATED_MARKER}` : result.stdout,
813 ),
814 );
815
816 const branchExists = (cwd: string, refName: string): Effect.Effect<boolean, GitCommandError> =>
817 executeGit(
818 "GitVcsDriver.branchExists",
819 cwd,
820 ["show-ref", "--verify", "--quiet", `refs/heads/${refName}`],
821 {
822 allowNonZeroExit: true,
823 timeoutMs: 5_000,
824 },
825 ).pipe(Effect.map((result) => result.exitCode === 0));
826
827 const resolveAvailableBranchName = Effect.fn("resolveAvailableBranchName")(function* (
828 cwd: string,
829 desiredBranch: string,
830 ) {
831 const isDesiredTaken = yield* branchExists(cwd, desiredBranch);
832 if (!isDesiredTaken) {
833 return desiredBranch;
834 }
835
836 for (let suffix = 1; suffix <= 100; suffix += 1) {
837 const candidate = `${desiredBranch}-${suffix}`;
838 const isCandidateTaken = yield* branchExists(cwd, candidate);
839 if (!isCandidateTaken) {
840 return candidate;
841 }
842 }
843
844 return yield* createGitCommandError(
845 "GitVcsDriver.renameBranch",
846 cwd,
847 ["branch", "-m", "--", desiredBranch],
848 `Could not find an available branch name for '${desiredBranch}'.`,
849 );
850 });
851
852 const resolveCurrentUpstream = Effect.fn("resolveCurrentUpstream")(function* (cwd: string) {
853 const upstreamRef = yield* runGitStdout(
854 "GitVcsDriver.resolveCurrentUpstream",
855 cwd,
856 ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"],
857 true,
858 ).pipe(Effect.map((stdout) => stdout.trim()));
859
860 if (upstreamRef.length === 0 || upstreamRef === "@{upstream}") {
861 return null;
862 }
863
864 const remoteNames = yield* runGitStdout("GitVcsDriver.listRemoteNames", cwd, ["remote"]).pipe(
865 Effect.map(parseRemoteNames),
866 Effect.catch(() => Effect.succeed<ReadonlyArray<string>>([])),
867 );
868 return (
869 parseUpstreamRefWithRemoteNames(upstreamRef, remoteNames) ??
870 parseUpstreamRefByFirstSeparator(upstreamRef)
871 );
872 });
873
874 const fetchRemoteForStatus = (
875 gitCommonDir: string,
876 remoteName: string,
877 ): Effect.Effect<void, GitCommandError> => {
878 const fetchCwd =
879 path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir;
880 return executeGit(
881 "GitVcsDriver.fetchRemoteForStatus",
882 fetchCwd,
883 ["--git-dir", gitCommonDir, "fetch", "--quiet", "--no-tags", remoteName],
884 {
885 allowNonZeroExit: true,
886 timeoutMs: Duration.toMillis(STATUS_UPSTREAM_REFRESH_TIMEOUT),
887 },
888 ).pipe(Effect.asVoid);
889 };
890
891 const resolveGitCommonDir = Effect.fn("resolveGitCommonDir")(function* (cwd: string) {
892 const gitCommonDir = yield* runGitStdout("GitVcsDriver.resolveGitCommonDir", cwd, [
893 "rev-parse",
894 "--git-common-dir",
895 ]).pipe(Effect.map((stdout) => stdout.trim()));
896 return path.isAbsolute(gitCommonDir) ? gitCommonDir : path.resolve(cwd, gitCommonDir);
897 });
898
899 const refreshStatusRemoteCacheEntry = Effect.fn("refreshStatusRemoteCacheEntry")(function* (
900 cacheKey: StatusRemoteRefreshCacheKey,
901 ) {
902 yield* fetchRemoteForStatus(cacheKey.gitCommonDir, cacheKey.remoteName);
903 return true as const;
904 });
905
906 const statusRemoteRefreshCache = yield* Cache.makeWith(refreshStatusRemoteCacheEntry, {
907 capacity: STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY,
908 // Keep successful refreshes warm and briefly back off failed refreshes to avoid retry storms.
909 timeToLive: (exit) =>
910 Exit.isSuccess(exit)
911 ? STATUS_UPSTREAM_REFRESH_INTERVAL
912 : STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN,
913 });
914
915 const refreshStatusUpstreamIfStale = Effect.fn("refreshStatusUpstreamIfStale")(function* (
916 cwd: string,
917 ) {
918 const upstream = yield* resolveCurrentUpstream(cwd);
919 if (!upstream) return;
920 const gitCommonDir = yield* resolveGitCommonDir(cwd);
921 yield* Cache.get(
922 statusRemoteRefreshCache,
923 new StatusRemoteRefreshCacheKey({
924 gitCommonDir,
925 remoteName: upstream.remoteName,
926 }),
927 );
928 });
929
930 const resolveDefaultBranchName = (
931 cwd: string,
932 remoteName: string,
933 ): Effect.Effect<string | null, GitCommandError> =>
934 executeGit(
935 "GitVcsDriver.resolveDefaultBranchName",
936 cwd,
937 ["symbolic-ref", `refs/remotes/${remoteName}/HEAD`],
938 { allowNonZeroExit: true },
939 ).pipe(
940 Effect.map((result) => {
941 if (result.exitCode !== 0) {
942 return null;
943 }
944 return parseDefaultBranchFromRemoteHeadRef(result.stdout, remoteName);
945 }),
946 );
947
948 const remoteBranchExists = (
949 cwd: string,
950 remoteName: string,
951 refName: string,
952 ): Effect.Effect<boolean, GitCommandError> =>
953 executeGit(
954 "GitVcsDriver.remoteBranchExists",
955 cwd,
956 ["show-ref", "--verify", "--quiet", `refs/remotes/${remoteName}/${refName}`],
957 {
958 allowNonZeroExit: true,
959 },
960 ).pipe(Effect.map((result) => result.exitCode === 0));
961
962 const originRemoteExists = (cwd: string): Effect.Effect<boolean, GitCommandError> =>
963 executeGit("GitVcsDriver.originRemoteExists", cwd, ["remote", "get-url", "origin"], {
964 allowNonZeroExit: true,
965 }).pipe(Effect.map((result) => result.exitCode === 0));
966
967 const listRemoteNames = (cwd: string): Effect.Effect<ReadonlyArray<string>, GitCommandError> =>
968 runGitStdout("GitVcsDriver.listRemoteNames", cwd, ["remote"]).pipe(
969 Effect.map(parseRemoteNamesInGitOrder),
970 );
971
972 const resolvePrimaryRemoteName = Effect.fn("resolvePrimaryRemoteName")(function* (cwd: string) {
973 if (yield* originRemoteExists(cwd)) {
974 return "origin";
975 }
976 const remotes = yield* listRemoteNames(cwd);
977 const [firstRemote] = remotes;
978 if (firstRemote) {
979 return firstRemote;
980 }
981 return yield* createGitCommandError(
982 "GitVcsDriver.resolvePrimaryRemoteName",
983 cwd,
984 ["remote"],
985 "No git remote is configured for this repository.",
986 );
987 });
988
989 const resolvePushRemoteName = Effect.fn("resolvePushRemoteName")(function* (
990 cwd: string,
991 refName: string,
992 ) {
993 const branchPushRemote = yield* runGitStdout(
994 "GitVcsDriver.resolvePushRemoteName.branchPushRemote",
995 cwd,
996 ["config", "--get", `branch.${refName}.pushRemote`],
997 true,
998 ).pipe(Effect.map((stdout) => stdout.trim()));
999 if (branchPushRemote.length > 0) {
1000 return branchPushRemote;
1001 }
1002
1003 const pushDefaultRemote = yield* runGitStdout(
1004 "GitVcsDriver.resolvePushRemoteName.remotePushDefault",
1005 cwd,
1006 ["config", "--get", "remote.pushDefault"],
1007 true,
1008 ).pipe(Effect.map((stdout) => stdout.trim()));
1009 if (pushDefaultRemote.length > 0) {
1010 return pushDefaultRemote;
1011 }
1012
1013 return yield* resolvePrimaryRemoteName(cwd).pipe(Effect.catch(() => Effect.succeed(null)));
1014 });
1015
1016 const ensureRemote: GitVcsDriverShape["ensureRemote"] = Effect.fn("ensureRemote")(
1017 function* (input) {
1018 const preferredName = sanitizeRemoteName(input.preferredName);
1019 const normalizedTargetUrl = normalizeRemoteUrl(input.url);
1020 const remoteFetchUrls = yield* runGitStdout(
1021 "GitVcsDriver.ensureRemote.listRemoteUrls",
1022 input.cwd,
1023 ["remote", "-v"],
1024 ).pipe(Effect.map((stdout) => parseRemoteFetchUrls(stdout)));
1025
1026 for (const [remoteName, remoteUrl] of remoteFetchUrls.entries()) {
1027 if (normalizeRemoteUrl(remoteUrl) === normalizedTargetUrl) {
1028 return remoteName;
1029 }
1030 }
1031
1032 let remoteName = preferredName;
1033 let suffix = 1;
1034 while (remoteFetchUrls.has(remoteName)) {
1035 remoteName = `${preferredName}-${suffix}`;
1036 suffix += 1;
1037 }
1038
1039 yield* runGit("GitVcsDriver.ensureRemote.add", input.cwd, [
1040 "remote",
1041 "add",
1042 remoteName,
1043 input.url,
1044 ]);
1045 return remoteName;
1046 },
1047 );
1048
1049 const resolveBaseBranchForNoUpstream = Effect.fn("resolveBaseBranchForNoUpstream")(function* (
1050 cwd: string,
1051 refName: string,
1052 ) {
1053 const configuredBaseBranch = yield* runGitStdout(
1054 "GitVcsDriver.resolveBaseBranchForNoUpstream.config",
1055 cwd,
1056 ["config", "--get", `branch.${refName}.gh-merge-base`],
1057 true,
1058 ).pipe(Effect.map((stdout) => stdout.trim()));
1059
1060 const primaryRemoteName = yield* resolvePrimaryRemoteName(cwd).pipe(
1061 Effect.catch(() => Effect.succeed(null)),
1062 );
1063 const defaultBranch =
1064 primaryRemoteName === null ? null : yield* resolveDefaultBranchName(cwd, primaryRemoteName);
1065 const candidates = [
1066 configuredBaseBranch.length > 0 ? configuredBaseBranch : null,
1067 defaultBranch,
1068 ...DEFAULT_BASE_BRANCH_CANDIDATES,
1069 ];
1070
1071 for (const candidate of candidates) {
1072 if (!candidate) {
1073 continue;
1074 }
1075
1076 const remotePrefix =
1077 primaryRemoteName && primaryRemoteName !== "origin" ? `${primaryRemoteName}/` : null;
1078 const normalizedCandidate = candidate.startsWith("origin/")
1079 ? candidate.slice("origin/".length)
1080 : remotePrefix && candidate.startsWith(remotePrefix)
1081 ? candidate.slice(remotePrefix.length)
1082 : candidate;
1083 if (normalizedCandidate.length === 0 || normalizedCandidate === refName) {
1084 continue;
1085 }
1086
1087 if (yield* branchExists(cwd, normalizedCandidate)) {
1088 return normalizedCandidate;
1089 }
1090
1091 if (
1092 primaryRemoteName &&
1093 (yield* remoteBranchExists(cwd, primaryRemoteName, normalizedCandidate))
1094 ) {
1095 return `${primaryRemoteName}/${normalizedCandidate}`;
1096 }
1097 }
1098
1099 return null;
1100 });
1101
1102 const computeAheadCountAgainstBase = Effect.fn("computeAheadCountAgainstBase")(function* (
1103 cwd: string,
1104 refName: string,
1105 ) {
1106 const baseRef = yield* resolveBaseBranchForNoUpstream(cwd, refName);
1107 if (!baseRef) {
1108 return 0;
1109 }
1110
1111 const result = yield* executeGit(
1112 "GitVcsDriver.computeAheadCountAgainstBase",
1113 cwd,
1114 ["rev-list", "--count", `${baseRef}..HEAD`],
1115 { allowNonZeroExit: true },
1116 );
1117 if (result.exitCode !== 0) {
1118 return 0;
1119 }
1120
1121 const parsed = Number.parseInt(result.stdout.trim(), 10);
1122 return Number.isFinite(parsed) ? Math.max(0, parsed) : 0;
1123 });
1124
1125 const readBranchRecency = Effect.fn("readBranchRecency")(function* (cwd: string) {
1126 const branchRecency = yield* executeGit(
1127 "GitVcsDriver.readBranchRecency",
1128 cwd,
1129 [
1130 "for-each-ref",
1131 "--format=%(refname:short)%09%(committerdate:unix)",
1132 "refs/heads",
1133 "refs/remotes",
1134 ],
1135 {
1136 timeoutMs: 15_000,
1137 allowNonZeroExit: true,
1138 },
1139 );
1140
1141 const branchLastCommit = new Map<string, number>();
1142 if (branchRecency.exitCode !== 0) {
1143 return branchLastCommit;
1144 }
1145
1146 for (const line of branchRecency.stdout.split("\n")) {
1147 if (line.length === 0) {
1148 continue;
1149 }
1150 const [name, lastCommitRaw] = line.split("\t");
1151 if (!name) {
1152 continue;
1153 }
1154 const lastCommit = Number.parseInt(lastCommitRaw ?? "0", 10);
1155 branchLastCommit.set(name, Number.isFinite(lastCommit) ? lastCommit : 0);
1156 }
1157
1158 return branchLastCommit;
1159 });
1160
1161 const readStatusDetailsLocal = Effect.fn("readStatusDetailsLocal")(function* (cwd: string) {
1162 const statusResult = yield* executeGit(
1163 "GitVcsDriver.statusDetails.status",
1164 cwd,
1165 ["status", "--porcelain=2", "--branch"],
1166 {
1167 allowNonZeroExit: true,
1168 },
1169 ).pipe(Effect.catchIf(isMissingGitCwdError, () => Effect.succeed(null)));
1170
1171 if (statusResult === null) {
1172 return NON_REPOSITORY_STATUS_DETAILS;
1173 }
1174
1175 if (statusResult.exitCode !== 0) {
1176 const stderr = statusResult.stderr.trim();
1177 return yield* createGitCommandError(
1178 "GitVcsDriver.statusDetails.status",
1179 cwd,
1180 ["status", "--porcelain=2", "--branch"],
1181 stderr || "git status failed",
1182 );
1183 }
1184
1185 const [unstagedNumstatStdout, stagedNumstatStdout, defaultRefResult, hasPrimaryRemote] =
1186 yield* Effect.all(
1187 [
1188 runGitStdout("GitVcsDriver.statusDetails.unstagedNumstat", cwd, ["diff", "--numstat"]),
1189 runGitStdout("GitVcsDriver.statusDetails.stagedNumstat", cwd, [
1190 "diff",
1191 "--cached",
1192 "--numstat",
1193 ]),
1194 executeGit(
1195 "GitVcsDriver.statusDetails.defaultRef",
1196 cwd,
1197 ["symbolic-ref", "refs/remotes/origin/HEAD"],
1198 {
1199 allowNonZeroExit: true,
1200 },
1201 ),
1202 originRemoteExists(cwd).pipe(Effect.catch(() => Effect.succeed(false))),
1203 ],
1204 { concurrency: "unbounded" },
1205 );
1206 const statusStdout = statusResult.stdout;
1207 const defaultBranch =
1208 defaultRefResult.exitCode === 0
1209 ? defaultRefResult.stdout.trim().replace(/^refs\/remotes\/origin\//, "")
1210 : null;
1211
1212 let refName: string | null = null;
1213 let upstreamRef: string | null = null;
1214 let aheadCount = 0;
1215 let behindCount = 0;
1216 let hasWorkingTreeChanges = false;
1217 const changedFilesWithoutNumstat = new Set<string>();
1218
1219 for (const line of statusStdout.split(/\r?\n/g)) {
1220 if (line.startsWith("# branch.head ")) {
1221 const value = line.slice("# branch.head ".length).trim();
1222 refName = value.startsWith("(") ? null : value;
1223 continue;
1224 }
1225 if (line.startsWith("# branch.upstream ")) {
1226 const value = line.slice("# branch.upstream ".length).trim();
1227 upstreamRef = value.length > 0 ? value : null;
1228 continue;
1229 }
1230 if (line.startsWith("# branch.ab ")) {
1231 const value = line.slice("# branch.ab ".length).trim();
1232 const parsed = parseBranchAb(value);
1233 aheadCount = parsed.ahead;
1234 behindCount = parsed.behind;
1235 continue;
1236 }
1237 if (line.trim().length > 0 && !line.startsWith("#")) {
1238 hasWorkingTreeChanges = true;
1239 const pathValue = parsePorcelainPath(line);
1240 if (pathValue) changedFilesWithoutNumstat.add(pathValue);
1241 }
1242 }
1243
1244 if (!upstreamRef && refName) {
1245 aheadCount = yield* computeAheadCountAgainstBase(cwd, refName).pipe(
1246 Effect.catch(() => Effect.succeed(0)),
1247 );
1248 behindCount = 0;
1249 }
1250
1251 const stagedEntries = parseNumstatEntries(stagedNumstatStdout);
1252 const unstagedEntries = parseNumstatEntries(unstagedNumstatStdout);
1253 const fileStatMap = new Map<string, { insertions: number; deletions: number }>();
1254 for (const entry of [...stagedEntries, ...unstagedEntries]) {
1255 const existing = fileStatMap.get(entry.path) ?? { insertions: 0, deletions: 0 };
1256 existing.insertions += entry.insertions;
1257 existing.deletions += entry.deletions;
1258 fileStatMap.set(entry.path, existing);
1259 }
1260
1261 let insertions = 0;
1262 let deletions = 0;
1263 const files = Array.from(fileStatMap.entries())
1264 .map(([filePath, stat]) => {
1265 insertions += stat.insertions;
1266 deletions += stat.deletions;
1267 return { path: filePath, insertions: stat.insertions, deletions: stat.deletions };
1268 })
1269 .toSorted((a, b) => a.path.localeCompare(b.path));
1270
1271 for (const filePath of changedFilesWithoutNumstat) {
1272 if (fileStatMap.has(filePath)) continue;
1273 files.push({ path: filePath, insertions: 0, deletions: 0 });
1274 }
1275 files.sort((a, b) => a.path.localeCompare(b.path));
1276
1277 return {
1278 isRepo: true,
1279 hasOriginRemote: hasPrimaryRemote,
1280 isDefaultBranch:
1281 refName !== null &&
1282 (refName === defaultBranch ||
1283 (defaultBranch === null && (refName === "main" || refName === "master"))),
1284 branch: refName,
1285 upstreamRef,
1286 hasWorkingTreeChanges,
1287 workingTree: {
1288 files,
1289 insertions,
1290 deletions,
1291 },
1292 hasUpstream: upstreamRef !== null,
1293 aheadCount,
1294 behindCount,
1295 };
1296 });
1297
1298 const statusDetailsLocal: GitVcsDriverShape["statusDetailsLocal"] = Effect.fn(
1299 "statusDetailsLocal",
1300 )(function* (cwd) {
1301 return yield* readStatusDetailsLocal(cwd);
1302 });
1303
1304 const statusDetails: GitVcsDriverShape["statusDetails"] = Effect.fn("statusDetails")(
1305 function* (cwd) {
1306 yield* refreshStatusUpstreamIfStale(cwd).pipe(
1307 Effect.catchIf(isMissingGitCwdError, () => Effect.void),
1308 Effect.ignoreCause({ log: true }),
1309 );
1310 return yield* readStatusDetailsLocal(cwd);
1311 },
1312 );
1313
1314 const status: GitVcsDriverShape["status"] = (input) =>
1315 statusDetails(input.cwd).pipe(
1316 Effect.map((details) => ({
1317 isRepo: details.isRepo,
1318 hasPrimaryRemote: details.hasOriginRemote,
1319 isDefaultRef: details.isDefaultBranch,
1320 refName: details.branch,
1321 hasWorkingTreeChanges: details.hasWorkingTreeChanges,
1322 workingTree: details.workingTree,
1323 hasUpstream: details.hasUpstream,
1324 aheadCount: details.aheadCount,
1325 behindCount: details.behindCount,
1326 pr: null,
1327 })),
1328 );
1329
1330 const prepareCommitContext: GitVcsDriverShape["prepareCommitContext"] = Effect.fn(
1331 "prepareCommitContext",
1332 )(function* (cwd, filePaths) {
1333 if (filePaths && filePaths.length > 0) {
1334 yield* runGit("GitVcsDriver.prepareCommitContext.reset", cwd, ["reset"]).pipe(
1335 Effect.catch(() => Effect.void),
1336 );
1337 yield* runGit("GitVcsDriver.prepareCommitContext.addSelected", cwd, [
1338 "add",
1339 "-A",
1340 "--",
1341 ...filePaths,
1342 ]);
1343 } else {
1344 yield* runGit("GitVcsDriver.prepareCommitContext.addAll", cwd, ["add", "-A"]);
1345 }
1346
1347 const stagedSummary = yield* runGitStdout(
1348 "GitVcsDriver.prepareCommitContext.stagedSummary",
1349 cwd,
1350 ["diff", "--cached", "--name-status"],
1351 ).pipe(Effect.map((stdout) => stdout.trim()));
1352 if (stagedSummary.length === 0) {
1353 return null;
1354 }
1355
1356 const stagedPatch = yield* runGitStdoutWithOptions(
1357 "GitVcsDriver.prepareCommitContext.stagedPatch",
1358 cwd,
1359 ["diff", "--cached", "--patch", "--minimal"],
1360 {
1361 maxOutputBytes: PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES,
1362 truncateOutputAtMaxBytes: true,
1363 },
1364 );
1365
1366 return {
1367 stagedSummary,
1368 stagedPatch,
1369 };
1370 });
1371
1372 const commit: GitVcsDriverShape["commit"] = Effect.fn("commit")(function* (
1373 cwd,
1374 subject,
1375 body,
1376 options?: GitCommitOptions,
1377 ) {
1378 const args = ["commit", "-m", subject];
1379 const trimmedBody = body.trim();
1380 if (trimmedBody.length > 0) {
1381 args.push("-m", trimmedBody);
1382 }
1383 const progress =
1384 options?.progress?.onOutputLine === undefined
1385 ? options?.progress
1386 : {
1387 ...options.progress,
1388 onStdoutLine: (line: string) =>
1389 options.progress?.onOutputLine?.({ stream: "stdout", text: line }) ?? Effect.void,
1390 onStderrLine: (line: string) =>
1391 options.progress?.onOutputLine?.({ stream: "stderr", text: line }) ?? Effect.void,
1392 };
1393 yield* executeGit("GitVcsDriver.commit.commit", cwd, args, {
1394 ...(options?.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
1395 ...(progress ? { progress } : {}),
1396 }).pipe(Effect.asVoid);
1397 const commitSha = yield* runGitStdout("GitVcsDriver.commit.revParseHead", cwd, [
1398 "rev-parse",
1399 "HEAD",
1400 ]).pipe(Effect.map((stdout) => stdout.trim()));
1401
1402 return { commitSha };
1403 });
1404
1405 const pushCurrentBranch: GitVcsDriverShape["pushCurrentBranch"] = Effect.fn("pushCurrentBranch")(
1406 function* (cwd, fallbackBranch) {
1407 const details = yield* statusDetails(cwd);
1408 const branch = details.branch ?? fallbackBranch;
1409 if (!branch) {
1410 return yield* createGitCommandError(
1411 "GitVcsDriver.pushCurrentBranch",
1412 cwd,
1413 ["push"],
1414 "Cannot push from detached HEAD.",
1415 );
1416 }
1417
1418 const hasNoLocalDelta = details.aheadCount === 0 && details.behindCount === 0;
1419 if (hasNoLocalDelta) {
1420 if (details.hasUpstream) {
1421 return {
1422 status: "skipped_up_to_date" as const,
1423 branch,
1424 ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}),
1425 };
1426 }
1427
1428 const comparableBaseBranch = yield* resolveBaseBranchForNoUpstream(cwd, branch).pipe(
1429 Effect.catch(() => Effect.succeed(null)),
1430 );
1431 if (comparableBaseBranch) {
1432 const publishRemoteName = yield* resolvePushRemoteName(cwd, branch).pipe(
1433 Effect.catch(() => Effect.succeed(null)),
1434 );
1435 if (!publishRemoteName) {
1436 return {
1437 status: "skipped_up_to_date" as const,
1438 branch,
1439 };
1440 }
1441
1442 const hasRemoteBranch = yield* remoteBranchExists(cwd, publishRemoteName, branch).pipe(
1443 Effect.catch(() => Effect.succeed(false)),
1444 );
1445 if (hasRemoteBranch) {
1446 return {
1447 status: "skipped_up_to_date" as const,
1448 branch,
1449 };
1450 }
1451 }
1452 }
1453
1454 if (!details.hasUpstream) {
1455 const publishRemoteName = yield* resolvePushRemoteName(cwd, branch);
1456 if (!publishRemoteName) {
1457 return yield* createGitCommandError(
1458 "GitVcsDriver.pushCurrentBranch",
1459 cwd,
1460 ["push"],
1461 "Cannot push because no git remote is configured for this repository.",
1462 );
1463 }
1464 yield* runGit("GitVcsDriver.pushCurrentBranch.pushWithUpstream", cwd, [
1465 "push",
1466 "-u",
1467 publishRemoteName,
1468 `HEAD:refs/heads/${branch}`,
1469 ]);
1470 return {
1471 status: "pushed" as const,
1472 branch,
1473 upstreamBranch: `${publishRemoteName}/${branch}`,
1474 setUpstream: true,
1475 };
1476 }
1477
1478 const currentUpstream = yield* resolveCurrentUpstream(cwd).pipe(
1479 Effect.catch(() => Effect.succeed(null)),
1480 );
1481 if (currentUpstream) {
1482 yield* runGit("GitVcsDriver.pushCurrentBranch.pushUpstream", cwd, [
1483 "push",
1484 currentUpstream.remoteName,
1485 `HEAD:${currentUpstream.upstreamRef}`,
1486 ]);
1487 return {
1488 status: "pushed" as const,
1489 branch,
1490 upstreamBranch: currentUpstream.upstreamRef,
1491 setUpstream: false,
1492 };
1493 }
1494
1495 yield* runGit("GitVcsDriver.pushCurrentBranch.push", cwd, ["push"]);
1496 return {
1497 status: "pushed" as const,
1498 branch,
1499 ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}),
1500 setUpstream: false,
1501 };
1502 },
1503 );
1504
1505 const pullCurrentBranch: GitVcsDriverShape["pullCurrentBranch"] = Effect.fn("pullCurrentBranch")(
1506 function* (cwd) {
1507 const details = yield* statusDetails(cwd);
1508 const refName = details.branch;
1509 if (!refName) {
1510 return yield* createGitCommandError(
1511 "GitVcsDriver.pullCurrentBranch",
1512 cwd,
1513 ["pull", "--ff-only"],
1514 "Cannot pull from detached HEAD.",
1515 );
1516 }
1517 if (!details.hasUpstream) {
1518 return yield* createGitCommandError(
1519 "GitVcsDriver.pullCurrentBranch",
1520 cwd,
1521 ["pull", "--ff-only"],
1522 "Current branch has no upstream configured. Push with upstream first.",
1523 );
1524 }
1525 const beforeSha = yield* runGitStdout(
1526 "GitVcsDriver.pullCurrentBranch.beforeSha",
1527 cwd,
1528 ["rev-parse", "HEAD"],
1529 true,
1530 ).pipe(Effect.map((stdout) => stdout.trim()));
1531 yield* executeGit("GitVcsDriver.pullCurrentBranch.pull", cwd, ["pull", "--ff-only"], {
1532 timeoutMs: 30_000,
1533 fallbackErrorMessage: "git pull failed",
1534 });
1535 const afterSha = yield* runGitStdout(
1536 "GitVcsDriver.pullCurrentBranch.afterSha",
1537 cwd,
1538 ["rev-parse", "HEAD"],
1539 true,
1540 ).pipe(Effect.map((stdout) => stdout.trim()));
1541
1542 const refreshed = yield* statusDetails(cwd);
1543 return {
1544 status: beforeSha.length > 0 && beforeSha === afterSha ? "skipped_up_to_date" : "pulled",
1545 refName,
1546 upstreamRef: refreshed.upstreamRef,
1547 };
1548 },
1549 );
1550
1551 const readRangeContext: GitVcsDriverShape["readRangeContext"] = Effect.fn("readRangeContext")(
1552 function* (cwd, baseRef) {
1553 const range = `${baseRef}..HEAD`;
1554 const [commitSummary, diffSummary, diffPatch] = yield* Effect.all(
1555 [
1556 runGitStdoutWithOptions(
1557 "GitVcsDriver.readRangeContext.log",
1558 cwd,
1559 ["log", "--oneline", range],
1560 {
1561 maxOutputBytes: RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES,
1562 truncateOutputAtMaxBytes: true,
1563 },
1564 ),
1565 runGitStdoutWithOptions(
1566 "GitVcsDriver.readRangeContext.diffStat",
1567 cwd,
1568 ["diff", "--stat", range],
1569 {
1570 maxOutputBytes: RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES,
1571 truncateOutputAtMaxBytes: true,
1572 },
1573 ),
1574 runGitStdoutWithOptions(
1575 "GitVcsDriver.readRangeContext.diffPatch",
1576 cwd,
1577 ["diff", "--patch", "--minimal", range],
1578 {
1579 maxOutputBytes: RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES,
1580 truncateOutputAtMaxBytes: true,
1581 },
1582 ),
1583 ],
1584 { concurrency: "unbounded" },
1585 );
1586
1587 return {
1588 commitSummary,
1589 diffSummary,
1590 diffPatch,
1591 };
1592 },
1593 );
1594
1595 const readConfigValue: GitVcsDriverShape["readConfigValue"] = (cwd, key) =>
1596 runGitStdout("GitVcsDriver.readConfigValue", cwd, ["config", "--get", key], true).pipe(
1597 Effect.map((stdout) => stdout.trim()),
1598 Effect.map((trimmed) => (trimmed.length > 0 ? trimmed : null)),
1599 );
1600
1601 const listRefs: GitVcsDriverShape["listRefs"] = Effect.fn("listRefs")(function* (input) {
1602 const branchRecencyPromise = readBranchRecency(input.cwd).pipe(
1603 Effect.catch(() => Effect.succeed(new Map<string, number>())),
1604 );
1605 const localBranchResult = yield* executeGit(
1606 "GitVcsDriver.listRefs.branchNoColor",
1607 input.cwd,
1608 ["branch", "--no-color", "--no-column"],
1609 {
1610 timeoutMs: 10_000,
1611 allowNonZeroExit: true,
1612 },
1613 ).pipe(
1614 Effect.catchIf(isMissingGitCwdError, () =>
1615 Effect.succeed({
1616 exitCode: ChildProcessSpawner.ExitCode(128),
1617 stdout: "",
1618 stderr: "fatal: not a git repository",
1619 stdoutTruncated: false,
1620 stderrTruncated: false,
1621 }),
1622 ),
1623 );
1624
1625 if (localBranchResult.exitCode !== 0) {
1626 const stderr = localBranchResult.stderr.trim();
1627 if (stderr.toLowerCase().includes("not a git repository")) {
1628 return {
1629 refs: [],
1630 isRepo: false,
1631 hasPrimaryRemote: false,
1632 nextCursor: null,
1633 totalCount: 0,
1634 };
1635 }
1636 return yield* createGitCommandError(
1637 "GitVcsDriver.listRefs",
1638 input.cwd,
1639 ["branch", "--no-color", "--no-column"],
1640 stderr || "git branch failed",
1641 );
1642 }
1643
1644 const remoteBranchResultEffect = executeGit(
1645 "GitVcsDriver.listRefs.remoteBranches",
1646 input.cwd,
1647 ["branch", "--no-color", "--no-column", "--remotes"],
1648 {
1649 timeoutMs: 10_000,
1650 allowNonZeroExit: true,
1651 },
1652 ).pipe(
1653 Effect.catch((error) =>
1654 Effect.logWarning(
1655 `GitVcsDriver.listRefs: remote refName lookup failed for ${input.cwd}: ${error.message}. Falling back to an empty remote refName list.`,
1656 ).pipe(
1657 Effect.as({
1658 exitCode: ChildProcessSpawner.ExitCode(1),
1659 stdout: "",
1660 stderr: "",
1661 stdoutTruncated: false,
1662 stderrTruncated: false,
1663 } satisfies ExecuteGitResult),
1664 ),
1665 ),
1666 );
1667
1668 const remoteNamesResultEffect = executeGit(
1669 "GitVcsDriver.listRefs.remoteNames",
1670 input.cwd,
1671 ["remote"],
1672 {
1673 timeoutMs: 5_000,
1674 allowNonZeroExit: true,
1675 },
1676 ).pipe(
1677 Effect.catch((error) =>
1678 Effect.logWarning(
1679 `GitVcsDriver.listRefs: remote name lookup failed for ${input.cwd}: ${error.message}. Falling back to an empty remote name list.`,
1680 ).pipe(
1681 Effect.as({
1682 exitCode: ChildProcessSpawner.ExitCode(1),
1683 stdout: "",
1684 stderr: "",
1685 stdoutTruncated: false,
1686 stderrTruncated: false,
1687 } satisfies ExecuteGitResult),
1688 ),
1689 ),
1690 );
1691
1692 const [defaultRef, worktreeList, remoteBranchResult, remoteNamesResult, branchLastCommit] =
1693 yield* Effect.all(
1694 [
1695 executeGit(
1696 "GitVcsDriver.listRefs.defaultRef",
1697 input.cwd,
1698 ["symbolic-ref", "refs/remotes/origin/HEAD"],
1699 {
1700 timeoutMs: 5_000,
1701 allowNonZeroExit: true,
1702 },
1703 ),
1704 executeGit(
1705 "GitVcsDriver.listRefs.worktreeList",
1706 input.cwd,
1707 ["worktree", "list", "--porcelain"],
1708 {
1709 timeoutMs: 5_000,
1710 allowNonZeroExit: true,
1711 },
1712 ),
1713 remoteBranchResultEffect,
1714 remoteNamesResultEffect,
1715 branchRecencyPromise,
1716 ],
1717 { concurrency: "unbounded" },
1718 );
1719
1720 const remoteNames =
1721 remoteNamesResult.exitCode === 0 ? parseRemoteNames(remoteNamesResult.stdout) : [];
1722 if (remoteBranchResult.exitCode !== 0 && remoteBranchResult.stderr.trim().length > 0) {
1723 yield* Effect.logWarning(
1724 `GitVcsDriver.listRefs: remote refName lookup returned code ${remoteBranchResult.exitCode} for ${input.cwd}: ${remoteBranchResult.stderr.trim()}. Falling back to an empty remote refName list.`,
1725 );
1726 }
1727 if (remoteNamesResult.exitCode !== 0 && remoteNamesResult.stderr.trim().length > 0) {
1728 yield* Effect.logWarning(
1729 `GitVcsDriver.listRefs: remote name lookup returned code ${remoteNamesResult.exitCode} for ${input.cwd}: ${remoteNamesResult.stderr.trim()}. Falling back to an empty remote name list.`,
1730 );
1731 }
1732
1733 const defaultBranch =
1734 defaultRef.exitCode === 0
1735 ? defaultRef.stdout.trim().replace(/^refs\/remotes\/origin\//, "")
1736 : null;
1737
1738 const worktreeMap = new Map<string, string>();
1739 if (worktreeList.exitCode === 0) {
1740 let currentPath: string | null = null;
1741 for (const line of worktreeList.stdout.split("\n")) {
1742 if (line.startsWith("worktree ")) {
1743 const candidatePath = line.slice("worktree ".length);
1744 const exists = yield* fileSystem.stat(candidatePath).pipe(
1745 Effect.map(() => true),
1746 Effect.catch(() => Effect.succeed(false)),
1747 );
1748 currentPath = exists ? candidatePath : null;
1749 } else if (line.startsWith("branch refs/heads/") && currentPath) {
1750 worktreeMap.set(line.slice("branch refs/heads/".length), currentPath);
1751 } else if (line === "") {
1752 currentPath = null;
1753 }
1754 }
1755 }
1756
1757 const localBranches = localBranchResult.stdout
1758 .split("\n")
1759 .map(parseBranchLine)
1760 .filter((refName): refName is { name: string; current: boolean } => refName !== null)
1761 .map((refName) => ({
1762 name: refName.name,
1763 current: refName.current,
1764 isRemote: false,
1765 isDefault: refName.name === defaultBranch,
1766 worktreePath: worktreeMap.get(refName.name) ?? null,
1767 }))
1768 .toSorted((a, b) => {
1769 const aPriority = a.current ? 0 : a.isDefault ? 1 : 2;
1770 const bPriority = b.current ? 0 : b.isDefault ? 1 : 2;
1771 if (aPriority !== bPriority) return aPriority - bPriority;
1772
1773 const aLastCommit = branchLastCommit.get(a.name) ?? 0;
1774 const bLastCommit = branchLastCommit.get(b.name) ?? 0;
1775 if (aLastCommit !== bLastCommit) return bLastCommit - aLastCommit;
1776 return a.name.localeCompare(b.name);
1777 });
1778
1779 const remoteBranches =
1780 remoteBranchResult.exitCode === 0
1781 ? remoteBranchResult.stdout
1782 .split("\n")
1783 .map(parseBranchLine)
1784 .filter((refName): refName is { name: string; current: boolean } => refName !== null)
1785 .map((refName) => {
1786 const parsedRemoteRef = parseRemoteRefWithRemoteNames(refName.name, remoteNames);
1787 const remoteBranch: {
1788 name: string;
1789 current: boolean;
1790 isRemote: boolean;
1791 remoteName?: string;
1792 isDefault: boolean;
1793 worktreePath: string | null;
1794 } = {
1795 name: refName.name,
1796 current: false,
1797 isRemote: true,
1798 isDefault: false,
1799 worktreePath: null,
1800 };
1801 if (parsedRemoteRef) {
1802 remoteBranch.remoteName = parsedRemoteRef.remoteName;
1803 }
1804 return remoteBranch;
1805 })
1806 .toSorted((a, b) => {
1807 const aLastCommit = branchLastCommit.get(a.name) ?? 0;
1808 const bLastCommit = branchLastCommit.get(b.name) ?? 0;
1809 if (aLastCommit !== bLastCommit) return bLastCommit - aLastCommit;
1810 return a.name.localeCompare(b.name);
1811 })
1812 : [];
1813
1814 const refs = paginateBranches({
1815 refs: filterBranchesForListQuery(
1816 dedupeRemoteBranchesWithLocalMatches([...localBranches, ...remoteBranches]),
1817 input.query,
1818 ),
1819 cursor: input.cursor,
1820 limit: input.limit,
1821 });
1822
1823 return {
1824 refs: [...refs.refs],
1825 isRepo: true,
1826 hasPrimaryRemote: remoteNames.includes("origin"),
1827 nextCursor: refs.nextCursor,
1828 totalCount: refs.totalCount,
1829 };
1830 });
1831
1832 const createWorktree: GitVcsDriverShape["createWorktree"] = Effect.fn("createWorktree")(
1833 function* (input) {
1834 const targetBranch = input.newRefName ?? input.refName;
1835 const sanitizedBranch = targetBranch.replace(/\//g, "-");
1836 const repoName = path.basename(input.cwd);
1837 const worktreePath = input.path ?? path.join(worktreesDir, repoName, sanitizedBranch);
1838 const args = input.newRefName
1839 ? ["worktree", "add", "-b", input.newRefName, worktreePath, input.refName]
1840 : ["worktree", "add", worktreePath, input.refName];
1841
1842 yield* executeGit("GitVcsDriver.createWorktree", input.cwd, args, {
1843 fallbackErrorMessage: "git worktree add failed",
1844 });
1845
1846 return {
1847 worktree: {
1848 path: worktreePath,
1849 refName: targetBranch,
1850 },
1851 };
1852 },
1853 );
1854
1855 const fetchPullRequestBranch: GitVcsDriverShape["fetchPullRequestBranch"] = Effect.fn(
1856 "fetchPullRequestBranch",
1857 )(function* (input) {
1858 const remoteName = yield* resolvePrimaryRemoteName(input.cwd);
1859 yield* executeGit(
1860 "GitVcsDriver.fetchPullRequestBranch",
1861 input.cwd,
1862 [
1863 "fetch",
1864 "--quiet",
1865 "--no-tags",
1866 remoteName,
1867 `+refs/pull/${input.prNumber}/head:refs/heads/${input.branch}`,
1868 ],
1869 {
1870 fallbackErrorMessage: "git fetch pull request branch failed",
1871 },
1872 );
1873 });
1874
1875 const fetchRemoteBranch: GitVcsDriverShape["fetchRemoteBranch"] = Effect.fn("fetchRemoteBranch")(
1876 function* (input) {
1877 yield* runGit("GitVcsDriver.fetchRemoteBranch.fetch", input.cwd, [
1878 "fetch",
1879 "--quiet",
1880 "--no-tags",
1881 input.remoteName,
1882 `+refs/heads/${input.remoteBranch}:refs/remotes/${input.remoteName}/${input.remoteBranch}`,
1883 ]);
1884
1885 const localBranchAlreadyExists = yield* branchExists(input.cwd, input.localBranch);
1886 const targetRef = `${input.remoteName}/${input.remoteBranch}`;
1887 yield* runGit(
1888 "GitVcsDriver.fetchRemoteBranch.materialize",
1889 input.cwd,
1890 localBranchAlreadyExists
1891 ? ["branch", "--force", input.localBranch, targetRef]
1892 : ["branch", input.localBranch, targetRef],
1893 );
1894 },
1895 );
1896
1897 const setBranchUpstream: GitVcsDriverShape["setBranchUpstream"] = (input) =>
1898 runGit("GitVcsDriver.setBranchUpstream", input.cwd, [
1899 "branch",
1900 "--set-upstream-to",
1901 `${input.remoteName}/${input.remoteBranch}`,
1902 input.branch,
1903 ]);
1904
1905 const removeWorktree: GitVcsDriverShape["removeWorktree"] = Effect.fn("removeWorktree")(
1906 function* (input) {
1907 const args = ["worktree", "remove"];
1908 if (input.force) {
1909 args.push("--force");
1910 }
1911 args.push(input.path);
1912 yield* executeGit("GitVcsDriver.removeWorktree", input.cwd, args, {
1913 timeoutMs: 15_000,
1914 fallbackErrorMessage: "git worktree remove failed",
1915 }).pipe(
1916 Effect.mapError((error) =>
1917 createGitCommandError(
1918 "GitVcsDriver.removeWorktree",
1919 input.cwd,
1920 args,
1921 `${commandLabel(args)} failed (cwd: ${input.cwd}): ${error.message}`,
1922 error,
1923 ),
1924 ),
1925 );
1926 },
1927 );
1928
1929 const renameBranch: GitVcsDriverShape["renameBranch"] = Effect.fn("renameBranch")(
1930 function* (input) {
1931 if (input.oldBranch === input.newBranch) {
1932 return { branch: input.newBranch };
1933 }
1934 const targetBranch = yield* resolveAvailableBranchName(input.cwd, input.newBranch);
1935
1936 yield* executeGit(
1937 "GitVcsDriver.renameBranch",
1938 input.cwd,
1939 ["branch", "-m", "--", input.oldBranch, targetBranch],
1940 {
1941 timeoutMs: 10_000,
1942 fallbackErrorMessage: "git branch rename failed",
1943 },
1944 );
1945
1946 return { branch: targetBranch };
1947 },
1948 );
1949
1950 const switchRef: GitVcsDriverShape["switchRef"] = Effect.fn("switchRef")(function* (input) {
1951 const [localInputExists, remoteExists] = yield* Effect.all(
1952 [
1953 executeGit(
1954 "GitVcsDriver.switchRef.localInputExists",
1955 input.cwd,
1956 ["show-ref", "--verify", "--quiet", `refs/heads/${input.refName}`],
1957 {
1958 timeoutMs: 5_000,
1959 allowNonZeroExit: true,
1960 },
1961 ).pipe(Effect.map((result) => result.exitCode === 0)),
1962 executeGit(
1963 "GitVcsDriver.switchRef.remoteExists",
1964 input.cwd,
1965 ["show-ref", "--verify", "--quiet", `refs/remotes/${input.refName}`],
1966 {
1967 timeoutMs: 5_000,
1968 allowNonZeroExit: true,
1969 },
1970 ).pipe(Effect.map((result) => result.exitCode === 0)),
1971 ],
1972 { concurrency: "unbounded" },
1973 );
1974
1975 const localTrackingBranch = remoteExists
1976 ? yield* executeGit(
1977 "GitVcsDriver.switchRef.localTrackingBranch",
1978 input.cwd,
1979 ["for-each-ref", "--format=%(refname:short)\t%(upstream:short)", "refs/heads"],
1980 {
1981 timeoutMs: 5_000,
1982 allowNonZeroExit: true,
1983 },
1984 ).pipe(
1985 Effect.map((result) =>
1986 result.exitCode === 0
1987 ? parseTrackingBranchByUpstreamRef(result.stdout, input.refName)
1988 : null,
1989 ),
1990 )
1991 : null;
1992
1993 const localTrackedBranchCandidate = deriveLocalBranchNameFromRemoteRef(input.refName);
1994 const localTrackedBranchTargetExists =
1995 remoteExists && localTrackedBranchCandidate
1996 ? yield* executeGit(
1997 "GitVcsDriver.switchRef.localTrackedBranchTargetExists",
1998 input.cwd,
1999 ["show-ref", "--verify", "--quiet", `refs/heads/${localTrackedBranchCandidate}`],
2000 {
2001 timeoutMs: 5_000,
2002 allowNonZeroExit: true,
2003 },
2004 ).pipe(Effect.map((result) => result.exitCode === 0))
2005 : false;
2006
2007 const checkoutArgs = localInputExists
2008 ? ["checkout", input.refName]
2009 : remoteExists && !localTrackingBranch && localTrackedBranchTargetExists
2010 ? ["checkout", input.refName]
2011 : remoteExists && !localTrackingBranch
2012 ? ["checkout", "--track", input.refName]
2013 : remoteExists && localTrackingBranch
2014 ? ["checkout", localTrackingBranch]
2015 : ["checkout", input.refName];
2016
2017 yield* executeGit("GitVcsDriver.switchRef.checkout", input.cwd, checkoutArgs, {
2018 timeoutMs: 10_000,
2019 fallbackErrorMessage: "git checkout failed",
2020 });
2021
2022 const refName = yield* runGitStdout("GitVcsDriver.switchRef.currentBranch", input.cwd, [
2023 "branch",
2024 "--show-current",
2025 ]).pipe(Effect.map((stdout) => stdout.trim() || null));
2026
2027 return { refName };
2028 });
2029
2030 const createRef: GitVcsDriverShape["createRef"] = Effect.fn("createRef")(function* (input) {
2031 yield* executeGit("GitVcsDriver.createRef", input.cwd, ["branch", input.refName], {
2032 timeoutMs: 10_000,
2033 fallbackErrorMessage: "git branch create failed",
2034 });
2035 if (input.switchRef) {
2036 yield* switchRef({ cwd: input.cwd, refName: input.refName });
2037 }
2038
2039 return { refName: input.refName };
2040 });
2041
2042 const initRepo: GitVcsDriverShape["initRepo"] = (input) =>
2043 executeGit("GitVcsDriver.initRepo", input.cwd, ["init"], {
2044 timeoutMs: 10_000,
2045 fallbackErrorMessage: "git init failed",
2046 }).pipe(Effect.asVoid);
2047
2048 const listLocalBranchNames: GitVcsDriverShape["listLocalBranchNames"] = (cwd) =>
2049 runGitStdout("GitVcsDriver.listLocalBranchNames", cwd, [
2050 "branch",
2051 "--list",
2052 "--no-column",
2053 "--format=%(refname:short)",
2054 ]).pipe(
2055 Effect.map((stdout) =>
2056 stdout
2057 .split("\n")
2058 .map((line) => line.trim())
2059 .filter((line) => line.length > 0),
2060 ),
2061 );
2062
2063 return {
2064 execute,
2065 status,
2066 statusDetails,
2067 statusDetailsLocal,
2068 prepareCommitContext,
2069 commit,
2070 pushCurrentBranch,
2071 pullCurrentBranch,
2072 readRangeContext,
2073 readConfigValue,
2074 listRefs,
2075 createWorktree,
2076 fetchPullRequestBranch,
2077 ensureRemote,
2078 fetchRemoteBranch,
2079 setBranchUpstream,
2080 removeWorktree,
2081 renameBranch,
2082 createRef,
2083 switchRef,
2084 initRepo,
2085 listLocalBranchNames,
2086 } satisfies GitVcsDriverShape;
2087});
apps/server/src/vcs/VcsDriver.ts created+31
......@@ -0,0 +1,31 @@
1import { Context, type Effect } from "effect";
2
3import type {
4 VcsDriverCapabilities,
5 VcsError,
6 VcsInitInput,
7 VcsListRemotesResult,
8 VcsListWorkspaceFilesResult,
9 VcsRepositoryIdentity,
10} from "@t3tools/contracts";
11import type { VcsProcessInput, VcsProcessOutput } from "./VcsProcess.ts";
12
13export interface VcsDriverShape {
14 readonly capabilities: VcsDriverCapabilities;
15 readonly execute: (
16 input: Omit<VcsProcessInput, "command">,
17 ) => Effect.Effect<VcsProcessOutput, VcsError>;
18 readonly detectRepository: (cwd: string) => Effect.Effect<VcsRepositoryIdentity | null, VcsError>;
19 readonly isInsideWorkTree: (cwd: string) => Effect.Effect<boolean, VcsError>;
20 readonly listWorkspaceFiles: (
21 cwd: string,
22 ) => Effect.Effect<VcsListWorkspaceFilesResult, VcsError>;
23 readonly listRemotes: (cwd: string) => Effect.Effect<VcsListRemotesResult, VcsError>;
24 readonly filterIgnoredPaths: (
25 cwd: string,
26 relativePaths: ReadonlyArray<string>,
27 ) => Effect.Effect<ReadonlyArray<string>, VcsError>;
28 readonly initRepository: (input: VcsInitInput) => Effect.Effect<void, VcsError>;
29}
30
31export class VcsDriver extends Context.Service<VcsDriver, VcsDriverShape>()("t3/vcs/VcsDriver") {}
apps/server/src/vcs/VcsDriverRegistry.test.ts created+87
......@@ -0,0 +1,87 @@
1import { assert, it } from "@effect/vitest";
2import { Effect, Layer } from "effect";
3import { ChildProcessSpawner } from "effect/unstable/process";
4import { describe } from "vitest";
5
6import { VcsProcess, type VcsProcessInput, type VcsProcessOutput } from "./VcsProcess.ts";
7import { VcsProjectConfig } from "./VcsProjectConfig.ts";
8import { VcsDriverRegistry, make as makeVcsDriverRegistry } from "./VcsDriverRegistry.ts";
9
10const processOutput = (stdout: string): VcsProcessOutput => ({
11 exitCode: ChildProcessSpawner.ExitCode(0),
12 stdout,
13 stderr: "",
14 stdoutTruncated: false,
15 stderrTruncated: false,
16});
17
18describe("VcsDriverRegistry", () => {
19 it.effect("routes directly by VCS driver kind for non-repository workflows", () => {
20 const layer = Layer.effect(VcsDriverRegistry, makeVcsDriverRegistry()).pipe(
21 Layer.provide(
22 Layer.mock(VcsProjectConfig)({
23 resolveKind: (input) => Effect.succeed(input.requestedKind ?? "auto"),
24 }),
25 ),
26 Layer.provide(
27 Layer.mock(VcsProcess)({
28 run: () => Effect.succeed(processOutput("")),
29 }),
30 ),
31 );
32
33 return Effect.gen(function* () {
34 const registry = yield* VcsDriverRegistry;
35 const driver = yield* registry.get("git");
36
37 assert.strictEqual(driver.capabilities.kind, "git");
38 }).pipe(Effect.provide(layer));
39 });
40
41 it.effect("caches repository detection for repeated resolves in the same cwd and kind", () => {
42 const calls: VcsProcessInput[] = [];
43 const layer = Layer.effect(VcsDriverRegistry, makeVcsDriverRegistry()).pipe(
44 Layer.provide(
45 Layer.mock(VcsProjectConfig)({
46 resolveKind: (input) => Effect.succeed(input.requestedKind ?? "auto"),
47 }),
48 ),
49 Layer.provide(
50 Layer.mock(VcsProcess)({
51 run: (input) =>
52 Effect.sync(() => {
53 calls.push(input);
54 const command = input.args.join(" ");
55 if (command === "rev-parse --is-inside-work-tree") {
56 return processOutput("true\n");
57 }
58 if (command === "rev-parse --show-toplevel") {
59 return processOutput("/repo\n");
60 }
61 if (command === "rev-parse --git-common-dir") {
62 return processOutput("/repo/.git\n");
63 }
64 return processOutput("");
65 }),
66 }),
67 ),
68 );
69
70 return Effect.gen(function* () {
71 const registry = yield* VcsDriverRegistry;
72 const first = yield* registry.resolve({ cwd: "/repo", requestedKind: "git" });
73 const second = yield* registry.resolve({ cwd: "/repo", requestedKind: "git" });
74
75 assert.equal(first.repository.rootPath, "/repo");
76 assert.equal(second.repository.rootPath, "/repo");
77 assert.deepStrictEqual(
78 calls.map((call) => call.args.join(" ")),
79 [
80 "rev-parse --is-inside-work-tree",
81 "rev-parse --show-toplevel",
82 "rev-parse --git-common-dir",
83 ],
84 );
85 }).pipe(Effect.provide(layer));
86 });
87});
apps/server/src/vcs/VcsDriverRegistry.ts created+155
......@@ -0,0 +1,155 @@
1import { Cache, Context, Duration, Effect, Exit, Layer } from "effect";
2
3import type { VcsDriverKind, VcsError, VcsRepositoryIdentity } from "@t3tools/contracts";
4import { VcsUnsupportedOperationError } from "@t3tools/contracts";
5import * as GitVcsDriver from "./GitVcsDriver.ts";
6import * as VcsProjectConfig from "./VcsProjectConfig.ts";
7import type { VcsDriverShape } from "./VcsDriver.ts";
8
9const DETECTION_CACHE_CAPACITY = 2_048;
10const DETECTION_CACHE_TTL = Duration.seconds(2);
11
12export interface VcsDriverResolveInput {
13 readonly cwd: string;
14 readonly requestedKind?: VcsDriverKind | "auto";
15}
16
17export interface VcsDriverHandle {
18 readonly kind: VcsDriverKind;
19 readonly repository: VcsRepositoryIdentity;
20 readonly driver: VcsDriverShape;
21}
22
23export interface VcsDriverRegistryShape {
24 readonly get: (kind: VcsDriverKind) => Effect.Effect<VcsDriverShape, VcsError>;
25 readonly detect: (
26 input: VcsDriverResolveInput,
27 ) => Effect.Effect<VcsDriverHandle | null, VcsError>;
28 readonly resolve: (input: VcsDriverResolveInput) => Effect.Effect<VcsDriverHandle, VcsError>;
29}
30
31export class VcsDriverRegistry extends Context.Service<VcsDriverRegistry, VcsDriverRegistryShape>()(
32 "t3/vcs/VcsDriverRegistry",
33) {}
34
35const unsupported = (operation: string, kind: VcsDriverKind, detail: string) =>
36 new VcsUnsupportedOperationError({
37 operation,
38 kind,
39 detail,
40 });
41
42function detectionCacheKey(input: {
43 readonly cwd: string;
44 readonly requestedKind: VcsDriverKind | "auto";
45}): string {
46 return `${input.requestedKind}\0${input.cwd}`;
47}
48
49function parseDetectionCacheKey(key: string): {
50 readonly cwd: string;
51 readonly requestedKind: VcsDriverKind | "auto";
52} {
53 const separatorIndex = key.indexOf("\0");
54 if (separatorIndex === -1) {
55 return {
56 cwd: key,
57 requestedKind: "auto",
58 };
59 }
60 return {
61 requestedKind: key.slice(0, separatorIndex) as VcsDriverKind | "auto",
62 cwd: key.slice(separatorIndex + 1),
63 };
64}
65
66export const make = Effect.fn("makeVcsDriverRegistry")(function* () {
67 const projectConfig = yield* VcsProjectConfig.VcsProjectConfig;
68 const git = yield* GitVcsDriver.makeVcsDriverShape();
69 const drivers: Partial<Record<VcsDriverKind, VcsDriverShape>> = {
70 git,
71 };
72
73 const get: VcsDriverRegistryShape["get"] = (kind) => {
74 const driver = drivers[kind];
75 if (!driver) {
76 return Effect.fail(
77 unsupported("VcsDriverRegistry.get", kind, `No ${kind} VCS driver is registered.`),
78 );
79 }
80 return Effect.succeed(driver);
81 };
82
83 const detectWithDriver = Effect.fn("VcsDriverRegistry.detectWithDriver")(function* (
84 kind: VcsDriverKind,
85 driver: VcsDriverShape,
86 cwd: string,
87 ) {
88 const repository = yield* driver.detectRepository(cwd);
89 if (!repository) {
90 return null;
91 }
92 return {
93 kind,
94 repository,
95 driver,
96 } satisfies VcsDriverHandle;
97 });
98
99 const detectResolvedKind = Effect.fn("VcsDriverRegistry.detectResolvedKind")(function* (input: {
100 readonly cwd: string;
101 readonly requestedKind: VcsDriverKind | "auto";
102 }) {
103 const requestedKind = input.requestedKind;
104
105 if (requestedKind !== "auto" && requestedKind !== "unknown") {
106 const driver = yield* get(requestedKind);
107 return yield* detectWithDriver(requestedKind, driver, input.cwd);
108 }
109
110 return yield* detectWithDriver("git", git, input.cwd);
111 });
112
113 const detectionCache = yield* Cache.makeWith<string, VcsDriverHandle | null, VcsError>(
114 (key) => detectResolvedKind(parseDetectionCacheKey(key)),
115 {
116 capacity: DETECTION_CACHE_CAPACITY,
117 timeToLive: (exit) => (Exit.isSuccess(exit) ? DETECTION_CACHE_TTL : Duration.zero),
118 },
119 );
120
121 const detect: VcsDriverRegistryShape["detect"] = Effect.fn("VcsDriverRegistry.detect")(
122 function* (input) {
123 const requestedKind = yield* projectConfig.resolveKind(input);
124 return yield* Cache.get(detectionCache, detectionCacheKey({ cwd: input.cwd, requestedKind }));
125 },
126 );
127
128 const resolve: VcsDriverRegistryShape["resolve"] = Effect.fn("VcsDriverRegistry.resolve")(
129 function* (input) {
130 const detected = yield* detect(input);
131 if (detected) {
132 return detected;
133 }
134
135 const requestedKind = input.requestedKind ?? "auto";
136 return yield* unsupported(
137 "VcsDriverRegistry.resolve",
138 requestedKind === "auto" ? "unknown" : requestedKind,
139 requestedKind === "auto"
140 ? `No supported VCS repository was detected at ${input.cwd}.`
141 : `No ${requestedKind} repository was detected at ${input.cwd}.`,
142 );
143 },
144 );
145
146 return VcsDriverRegistry.of({
147 get,
148 detect,
149 resolve,
150 });
151});
152
153export const layer = Layer.effect(VcsDriverRegistry, make()).pipe(
154 Layer.provide(VcsProjectConfig.layer),
155);
apps/server/src/vcs/VcsProcess.ts created+259
......@@ -0,0 +1,259 @@
1import { Duration, Context, Effect, Layer, Option, PlatformError, Sink, Stream } from "effect";
2import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
3
4import {
5 VcsOutputDecodeError,
6 type VcsError,
7 VcsProcessExitError,
8 VcsProcessSpawnError,
9 VcsProcessTimeoutError,
10} from "@t3tools/contracts";
11
12export interface VcsProcessInput {
13 readonly operation: string;
14 readonly command: string;
15 readonly args: ReadonlyArray<string>;
16 readonly cwd: string;
17 readonly stdin?: string;
18 readonly env?: NodeJS.ProcessEnv;
19 readonly allowNonZeroExit?: boolean;
20 readonly timeoutMs?: number;
21 readonly maxOutputBytes?: number;
22 readonly truncateOutputAtMaxBytes?: boolean;
23}
24
25export interface VcsProcessOutput {
26 readonly exitCode: ChildProcessSpawner.ExitCode;
27 readonly stdout: string;
28 readonly stderr: string;
29 readonly stdoutTruncated: boolean;
30 readonly stderrTruncated: boolean;
31}
32
33export interface VcsProcessCollectedText {
34 readonly text: string;
35 readonly truncated: boolean;
36}
37
38export interface VcsProcessHandle {
39 readonly pid: ChildProcessSpawner.ProcessId;
40 readonly stdin: Sink.Sink<void, Uint8Array, never, VcsError>;
41 readonly stdout: Stream.Stream<Uint8Array, VcsError>;
42 readonly stderr: Stream.Stream<Uint8Array, VcsError>;
43 readonly exitCode: Effect.Effect<ChildProcessSpawner.ExitCode, VcsError>;
44 readonly writeStdin: (input: string) => Effect.Effect<void, VcsError>;
45}
46
47export interface VcsProcessShape {
48 readonly withProcess: <A, E, R>(
49 input: VcsProcessInput,
50 use: (handle: VcsProcessHandle) => Effect.Effect<A, E, R>,
51 ) => Effect.Effect<A, E | VcsError, R>;
52 readonly run: (input: VcsProcessInput) => Effect.Effect<VcsProcessOutput, VcsError>;
53}
54
55export class VcsProcess extends Context.Service<VcsProcess, VcsProcessShape>()(
56 "t3/vcs/VcsProcess",
57) {}
58
59const DEFAULT_TIMEOUT_MS = 30_000;
60const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000;
61const OUTPUT_TRUNCATED_MARKER = "\n\n[truncated]";
62
63function commandLabel(command: string, args: ReadonlyArray<string>): string {
64 return [command, ...args].join(" ");
65}
66
67function outputDecodeError(
68 input: VcsProcessInput,
69 detail: string,
70 cause: unknown,
71): VcsOutputDecodeError {
72 return new VcsOutputDecodeError({
73 operation: input.operation,
74 command: commandLabel(input.command, input.args),
75 cwd: input.cwd,
76 detail,
77 cause,
78 });
79}
80
81export const collectText = Effect.fn("VcsProcess.collectText")(function* (input: {
82 readonly operation: string;
83 readonly command: string;
84 readonly cwd: string;
85 readonly stream: Stream.Stream<Uint8Array, VcsError>;
86 readonly maxOutputBytes?: number;
87 readonly truncateOutputAtMaxBytes?: boolean;
88}) {
89 const decoder = new TextDecoder();
90 let text = "";
91 let bytes = 0;
92 let truncated = false;
93 const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
94 const truncateOutputAtMaxBytes = input.truncateOutputAtMaxBytes ?? false;
95
96 yield* Stream.runForEach(input.stream, (chunk) =>
97 Effect.sync(() => {
98 if (truncated) return;
99
100 const remainingBytes = maxOutputBytes - bytes;
101 if (remainingBytes <= 0) {
102 truncated = true;
103 if (truncateOutputAtMaxBytes) {
104 text += OUTPUT_TRUNCATED_MARKER;
105 }
106 return;
107 }
108
109 const nextChunk = chunk.byteLength > remainingBytes ? chunk.slice(0, remainingBytes) : chunk;
110 text += decoder.decode(nextChunk, { stream: true });
111 bytes += nextChunk.byteLength;
112
113 if (chunk.byteLength > remainingBytes) {
114 truncated = true;
115 if (truncateOutputAtMaxBytes) {
116 text += OUTPUT_TRUNCATED_MARKER;
117 }
118 }
119 }),
120 );
121
122 if (!truncated) {
123 text += decoder.decode();
124 }
125
126 return { text, truncated } satisfies VcsProcessCollectedText;
127});
128
129export const make = Effect.fn("makeVcsProcess")(function* () {
130 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
131
132 const spawn = Effect.fn("VcsProcess.spawn")(function* (input: VcsProcessInput) {
133 const label = commandLabel(input.command, input.args);
134 const child = yield* spawner
135 .spawn(
136 ChildProcess.make(input.command, [...input.args], {
137 cwd: input.cwd,
138 env: {
139 ...process.env,
140 ...input.env,
141 },
142 }),
143 )
144 .pipe(
145 Effect.mapError(
146 (cause) =>
147 new VcsProcessSpawnError({
148 operation: input.operation,
149 command: label,
150 cwd: input.cwd,
151 cause,
152 }),
153 ),
154 );
155 yield* Effect.addFinalizer(() => child.kill().pipe(Effect.ignore));
156
157 const mapStreamError = (streamName: "stdout" | "stderr") =>
158 Stream.mapError((cause: PlatformError.PlatformError) =>
159 outputDecodeError(input, `failed to read process ${streamName}`, cause),
160 );
161 const mapEffectError = (detail: string) =>
162 Effect.mapError((cause: PlatformError.PlatformError) =>
163 outputDecodeError(input, detail, cause),
164 );
165 const writeStdin = (stdin: string) =>
166 Stream.run(Stream.encodeText(Stream.make(stdin)), child.stdin).pipe(
167 mapEffectError("failed to write process stdin"),
168 );
169
170 return {
171 pid: child.pid,
172 stdin: child.stdin.pipe(
173 Sink.mapError((cause) => outputDecodeError(input, "failed to write process stdin", cause)),
174 ),
175 stdout: child.stdout.pipe(mapStreamError("stdout")),
176 stderr: child.stderr.pipe(mapStreamError("stderr")),
177 exitCode: child.exitCode.pipe(mapEffectError("failed to read process exit code")),
178 writeStdin,
179 } satisfies VcsProcessHandle;
180 });
181
182 const withProcess: VcsProcessShape["withProcess"] = (input, use) =>
183 Effect.scoped(spawn(input).pipe(Effect.flatMap(use)));
184
185 const run = Effect.fn("VcsProcess.run")(function* (input: VcsProcessInput) {
186 const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
187 const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
188 const label = commandLabel(input.command, input.args);
189
190 const runProcess = Effect.gen(function* () {
191 const [stdout, stderr, exitCode] = yield* withProcess(input, (child) =>
192 Effect.all(
193 [
194 collectText({
195 operation: input.operation,
196 command: label,
197 cwd: input.cwd,
198 stream: child.stdout,
199 maxOutputBytes,
200 truncateOutputAtMaxBytes: input.truncateOutputAtMaxBytes ?? false,
201 }),
202 collectText({
203 operation: input.operation,
204 command: label,
205 cwd: input.cwd,
206 stream: child.stderr,
207 maxOutputBytes,
208 truncateOutputAtMaxBytes: input.truncateOutputAtMaxBytes ?? false,
209 }),
210 child.exitCode,
211 input.stdin === undefined ? Effect.void : child.writeStdin(input.stdin),
212 ],
213 { concurrency: "unbounded" },
214 ),
215 ).pipe(Effect.map(([stdout, stderr, exitCode]) => [stdout, stderr, exitCode] as const));
216
217 if (!input.allowNonZeroExit && exitCode !== 0) {
218 return yield* new VcsProcessExitError({
219 operation: input.operation,
220 command: label,
221 cwd: input.cwd,
222 exitCode,
223 detail: stderr.text.trim() || `${label} exited with code ${exitCode}.`,
224 });
225 }
226
227 return {
228 exitCode,
229 stdout: stdout.text,
230 stderr: stderr.text,
231 stdoutTruncated: stdout.truncated,
232 stderrTruncated: stderr.truncated,
233 } satisfies VcsProcessOutput;
234 });
235
236 return yield* runProcess.pipe(
237 Effect.scoped,
238 Effect.timeoutOption(Duration.millis(timeoutMs)),
239 Effect.flatMap((result) =>
240 Option.match(result, {
241 onSome: Effect.succeed,
242 onNone: () =>
243 Effect.fail(
244 new VcsProcessTimeoutError({
245 operation: input.operation,
246 command: label,
247 cwd: input.cwd,
248 timeoutMs,
249 }),
250 ),
251 }),
252 ),
253 );
254 });
255
256 return VcsProcess.of({ withProcess, run });
257});
258
259export const layer = Layer.effect(VcsProcess, make());
apps/server/src/vcs/VcsProjectConfig.test.ts created+67
......@@ -0,0 +1,67 @@
1import { assert, it } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { Effect, FileSystem, Layer, Path } from "effect";
4import { describe } from "vitest";
5
6import { VcsProjectConfig, layer as VcsProjectConfigLayer } from "./VcsProjectConfig.ts";
7
8const TestLayer = VcsProjectConfigLayer.pipe(
9 Layer.provide(NodeServices.layer),
10 Layer.provideMerge(NodeServices.layer),
11);
12
13describe("VcsProjectConfig", () => {
14 it.layer(TestLayer)("uses an explicit requested VCS kind before config", (it) => {
15 it.effect("returns the requested kind", () =>
16 Effect.gen(function* () {
17 const config = yield* VcsProjectConfig;
18 const kind = yield* config.resolveKind({
19 cwd: "/repo",
20 requestedKind: "jj",
21 });
22
23 assert.equal(kind, "jj");
24 }),
25 );
26 });
27
28 it.layer(TestLayer)("discovers .t3code/vcs.json from nested workspaces", (it) => {
29 it.effect("returns the configured kind", () =>
30 Effect.gen(function* () {
31 const fileSystem = yield* FileSystem.FileSystem;
32 const path = yield* Path.Path;
33 const root = yield* fileSystem.makeTempDirectoryScoped({
34 prefix: "t3-vcs-config-test-",
35 });
36 const configDir = path.join(root, ".t3code");
37 const nested = path.join(root, "packages", "app");
38 yield* fileSystem.makeDirectory(configDir, { recursive: true });
39 yield* fileSystem.makeDirectory(nested, { recursive: true });
40 yield* fileSystem.writeFileString(
41 path.join(configDir, "vcs.json"),
42 JSON.stringify({ vcs: { kind: "jj" } }),
43 );
44
45 const config = yield* VcsProjectConfig;
46 const kind = yield* config.resolveKind({ cwd: nested });
47
48 assert.equal(kind, "jj");
49 }),
50 );
51 });
52
53 it.layer(TestLayer)("falls back to auto when no config exists", (it) => {
54 it.effect("returns auto", () =>
55 Effect.gen(function* () {
56 const fileSystem = yield* FileSystem.FileSystem;
57 const root = yield* fileSystem.makeTempDirectoryScoped({
58 prefix: "t3-vcs-config-test-",
59 });
60 const config = yield* VcsProjectConfig;
61 const kind = yield* config.resolveKind({ cwd: root });
62
63 assert.equal(kind, "auto");
64 }),
65 );
66 });
67});
apps/server/src/vcs/VcsProjectConfig.ts created+117
......@@ -0,0 +1,117 @@
1import { Context, Effect, FileSystem, Layer, Path, Schema } from "effect";
2
3import { VcsDriverKind, type VcsDriverKind as VcsDriverKindType } from "@t3tools/contracts";
4
5const ProjectVcsConfig = Schema.Struct({
6 vcs: Schema.optional(
7 Schema.Struct({
8 kind: Schema.optional(VcsDriverKind),
9 }),
10 ),
11 vcsKind: Schema.optional(VcsDriverKind),
12});
13
14interface ProjectVcsConfigFile {
15 readonly vcs?:
16 | {
17 readonly kind?: VcsDriverKindType | undefined;
18 }
19 | undefined;
20 readonly vcsKind?: VcsDriverKindType | undefined;
21}
22
23export interface VcsProjectConfigResolveInput {
24 readonly cwd: string;
25 readonly requestedKind?: VcsDriverKindType | "auto";
26}
27
28export interface VcsProjectConfigShape {
29 readonly resolveKind: (
30 input: VcsProjectConfigResolveInput,
31 ) => Effect.Effect<VcsDriverKindType | "auto">;
32}
33
34export class VcsProjectConfig extends Context.Service<VcsProjectConfig, VcsProjectConfigShape>()(
35 "t3/vcs/VcsProjectConfig",
36) {}
37
38function configuredKind(config: ProjectVcsConfigFile): VcsDriverKindType | "auto" {
39 return config.vcs?.kind ?? config.vcsKind ?? "auto";
40}
41
42function parseConfig(raw: string): ProjectVcsConfigFile | null {
43 try {
44 const parsed = JSON.parse(raw) as unknown;
45 return Schema.is(ProjectVcsConfig)(parsed) ? parsed : null;
46 } catch {
47 return null;
48 }
49}
50
51export const make = Effect.fn("makeVcsProjectConfig")(function* () {
52 const fileSystem = yield* FileSystem.FileSystem;
53 const path = yield* Path.Path;
54
55 const findConfigPath = Effect.fn("VcsProjectConfig.findConfigPath")(function* (cwd: string) {
56 let current = cwd;
57 while (true) {
58 const candidate = path.join(current, ".t3code", "vcs.json");
59 if (yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false))) {
60 return candidate;
61 }
62
63 const parent = path.dirname(current);
64 if (parent === current) {
65 return null;
66 }
67 current = parent;
68 }
69 });
70
71 const readConfiguredKind = Effect.fn("VcsProjectConfig.readConfiguredKind")(function* (
72 configPath: string,
73 ) {
74 const raw = yield* fileSystem.readFileString(configPath).pipe(
75 Effect.catch((error) =>
76 Effect.logWarning("failed to read VCS project config", {
77 configPath,
78 error,
79 }).pipe(Effect.as(null)),
80 ),
81 );
82 if (raw === null) {
83 return "auto" as const;
84 }
85
86 const parsed = parseConfig(raw);
87 if (parsed === null) {
88 yield* Effect.logWarning("invalid VCS project config", {
89 configPath,
90 });
91 return "auto" as const;
92 }
93
94 return configuredKind(parsed);
95 });
96
97 const resolveKind: VcsProjectConfigShape["resolveKind"] = Effect.fn(
98 "VcsProjectConfig.resolveKind",
99 )(function* (input) {
100 if (input.requestedKind !== undefined && input.requestedKind !== "auto") {
101 return input.requestedKind;
102 }
103
104 const configPath = yield* findConfigPath(input.cwd);
105 if (configPath === null) {
106 return "auto";
107 }
108
109 return yield* readConfiguredKind(configPath);
110 });
111
112 return VcsProjectConfig.of({
113 resolveKind,
114 });
115});
116
117export const layer = Layer.effect(VcsProjectConfig, make());
apps/server/src/vcs/VcsProvisioningService.test.ts created+94
......@@ -0,0 +1,94 @@
1import { assert, it } from "@effect/vitest";
2import { DateTime, Effect, Layer, Option } from "effect";
3import { ChildProcessSpawner } from "effect/unstable/process";
4
5import type { VcsDriverShape } from "./VcsDriver.ts";
6import { VcsDriverRegistry } from "./VcsDriverRegistry.ts";
7import { VcsProvisioningService, layer } from "./VcsProvisioningService.ts";
8
9const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z");
10
11function makeDriver(calls: string[]): VcsDriverShape {
12 return {
13 capabilities: {
14 kind: "git",
15 supportsWorktrees: true,
16 supportsBookmarks: false,
17 supportsAtomicSnapshot: false,
18 supportsPushDefaultRemote: true,
19 ignoreClassifier: "native",
20 },
21 execute: () =>
22 Effect.succeed({
23 exitCode: ChildProcessSpawner.ExitCode(0),
24 stdout: "",
25 stderr: "",
26 stdoutTruncated: false,
27 stderrTruncated: false,
28 }),
29 detectRepository: () => Effect.succeed(null),
30 isInsideWorkTree: () => Effect.succeed(false),
31 listWorkspaceFiles: () =>
32 Effect.succeed({
33 paths: [],
34 truncated: false,
35 freshness: {
36 source: "live-local",
37 observedAt: TEST_EPOCH,
38 expiresAt: Option.none(),
39 },
40 }),
41 listRemotes: () =>
42 Effect.succeed({
43 remotes: [],
44 freshness: {
45 source: "live-local",
46 observedAt: TEST_EPOCH,
47 expiresAt: Option.none(),
48 },
49 }),
50 filterIgnoredPaths: (_cwd, relativePaths) => Effect.succeed(relativePaths),
51 initRepository: (input) =>
52 Effect.sync(() => {
53 calls.push(`${input.kind ?? "default"}:${input.cwd}`);
54 }),
55 };
56}
57
58it.effect("routes repository initialization through an explicit VCS driver kind", () => {
59 const calls: string[] = [];
60 const driver = makeDriver(calls);
61 const testLayer = layer.pipe(
62 Layer.provide(
63 Layer.mock(VcsDriverRegistry)({
64 get: (kind) => (kind === "git" ? Effect.succeed(driver) : Effect.die("unexpected kind")),
65 }),
66 ),
67 );
68
69 return Effect.gen(function* () {
70 const provisioning = yield* VcsProvisioningService;
71 yield* provisioning.initRepository({ cwd: "/repo", kind: "git" });
72
73 assert.deepStrictEqual(calls, ["git:/repo"]);
74 }).pipe(Effect.provide(testLayer));
75});
76
77it.effect("defaults repository initialization to Git until callers choose a VCS kind", () => {
78 const calls: string[] = [];
79 const driver = makeDriver(calls);
80 const testLayer = layer.pipe(
81 Layer.provide(
82 Layer.mock(VcsDriverRegistry)({
83 get: (kind) => (kind === "git" ? Effect.succeed(driver) : Effect.die("unexpected kind")),
84 }),
85 ),
86 );
87
88 return Effect.gen(function* () {
89 const provisioning = yield* VcsProvisioningService;
90 yield* provisioning.initRepository({ cwd: "/repo" });
91
92 assert.deepStrictEqual(calls, ["default:/repo"]);
93 }).pipe(Effect.provide(testLayer));
94});
apps/server/src/vcs/VcsProvisioningService.ts created+54
......@@ -0,0 +1,54 @@
1import { Context, Effect, Layer } from "effect";
2
3import {
4 type VcsDriverKind,
5 type VcsError,
6 type VcsInitInput,
7 VcsUnsupportedOperationError,
8} from "@t3tools/contracts";
9import { VcsDriverRegistry } from "./VcsDriverRegistry.ts";
10
11export interface VcsProvisioningServiceShape {
12 readonly initRepository: (input: VcsInitInput) => Effect.Effect<void, VcsError>;
13}
14
15export class VcsProvisioningService extends Context.Service<
16 VcsProvisioningService,
17 VcsProvisioningServiceShape
18>()("t3/vcs/VcsProvisioningService") {}
19
20function resolveRequestedKind(
21 kind: VcsDriverKind | undefined,
22): Effect.Effect<VcsDriverKind, VcsUnsupportedOperationError> {
23 if (kind === undefined) {
24 return Effect.succeed("git");
25 }
26 if (kind === "unknown") {
27 return Effect.fail(
28 new VcsUnsupportedOperationError({
29 operation: "VcsProvisioningService.resolveRequestedKind",
30 kind,
31 detail: "A concrete VCS driver kind is required for repository provisioning.",
32 }),
33 );
34 }
35 return Effect.succeed(kind);
36}
37
38export const make = Effect.fn("makeVcsProvisioningService")(function* () {
39 const registry = yield* VcsDriverRegistry;
40
41 const initRepository: VcsProvisioningServiceShape["initRepository"] = Effect.fn(
42 "VcsProvisioningService.initRepository",
43 )(function* (input) {
44 const kind = yield* resolveRequestedKind(input.kind);
45 const driver = yield* registry.get(kind);
46 return yield* driver.initRepository(input);
47 });
48
49 return VcsProvisioningService.of({
50 initRepository,
51 });
52});
53
54export const layer = Layer.effect(VcsProvisioningService, make());
apps/server/src/vcs/VcsStatusBroadcaster.test.ts created+364
......@@ -0,0 +1,364 @@
1import { assert, it } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { Deferred, Effect, Exit, FileSystem, Layer, Option, Path, Scope, Stream } from "effect";
4import type {
5 VcsStatusLocalResult,
6 VcsStatusRemoteResult,
7 VcsStatusResult,
8 VcsStatusStreamEvent,
9} from "@t3tools/contracts";
10import { describe } from "vitest";
11
12import {
13 VcsStatusBroadcaster,
14 layer as VcsStatusBroadcasterLayer,
15} from "./VcsStatusBroadcaster.ts";
16import { GitWorkflowService, type GitWorkflowServiceShape } from "../git/GitWorkflowService.ts";
17
18const baseLocalStatus: VcsStatusLocalResult = {
19 isRepo: true,
20 sourceControlProvider: {
21 kind: "github",
22 name: "GitHub",
23 baseUrl: "https://github.com",
24 },
25 hasPrimaryRemote: true,
26 isDefaultRef: false,
27 refName: "feature/status-broadcast",
28 hasWorkingTreeChanges: false,
29 workingTree: { files: [], insertions: 0, deletions: 0 },
30};
31
32const baseRemoteStatus: VcsStatusRemoteResult = {
33 hasUpstream: true,
34 aheadCount: 0,
35 behindCount: 0,
36 pr: null,
37};
38
39const baseStatus: VcsStatusResult = {
40 ...baseLocalStatus,
41 ...baseRemoteStatus,
42};
43
44function makeTestLayer(state: {
45 currentLocalStatus: VcsStatusLocalResult;
46 currentRemoteStatus: VcsStatusRemoteResult | null;
47 localStatusCalls: number;
48 remoteStatusCalls: number;
49 localInvalidationCalls: number;
50 remoteInvalidationCalls: number;
51}) {
52 return VcsStatusBroadcasterLayer.pipe(
53 Layer.provide(
54 Layer.mock(GitWorkflowService)({
55 localStatus: () =>
56 Effect.sync(() => {
57 state.localStatusCalls += 1;
58 return state.currentLocalStatus;
59 }),
60 remoteStatus: () =>
61 Effect.sync(() => {
62 state.remoteStatusCalls += 1;
63 return state.currentRemoteStatus;
64 }),
65 invalidateLocalStatus: () =>
66 Effect.sync(() => {
67 state.localInvalidationCalls += 1;
68 }),
69 invalidateRemoteStatus: () =>
70 Effect.sync(() => {
71 state.remoteInvalidationCalls += 1;
72 }),
73 }),
74 ),
75 );
76}
77
78describe("VcsStatusBroadcaster", () => {
79 it.effect("reuses the cached VCS status across repeated reads", () => {
80 const state = {
81 currentLocalStatus: baseLocalStatus,
82 currentRemoteStatus: baseRemoteStatus,
83 localStatusCalls: 0,
84 remoteStatusCalls: 0,
85 localInvalidationCalls: 0,
86 remoteInvalidationCalls: 0,
87 };
88
89 return Effect.gen(function* () {
90 const broadcaster = yield* VcsStatusBroadcaster;
91
92 const first = yield* broadcaster.getStatus({ cwd: "/repo" });
93 const second = yield* broadcaster.getStatus({ cwd: "/repo" });
94
95 assert.deepStrictEqual(first, baseStatus);
96 assert.deepStrictEqual(second, baseStatus);
97 assert.equal(state.localStatusCalls, 1);
98 assert.equal(state.remoteStatusCalls, 1);
99 assert.equal(state.localInvalidationCalls, 0);
100 assert.equal(state.remoteInvalidationCalls, 0);
101 }).pipe(Effect.provide(makeTestLayer(state)));
102 });
103
104 it.effect("refreshes the cached snapshot after explicit invalidation", () => {
105 const state = {
106 currentLocalStatus: baseLocalStatus,
107 currentRemoteStatus: baseRemoteStatus,
108 localStatusCalls: 0,
109 remoteStatusCalls: 0,
110 localInvalidationCalls: 0,
111 remoteInvalidationCalls: 0,
112 };
113
114 return Effect.gen(function* () {
115 const broadcaster = yield* VcsStatusBroadcaster;
116 const initial = yield* broadcaster.getStatus({ cwd: "/repo" });
117
118 state.currentLocalStatus = {
119 ...baseLocalStatus,
120 refName: "feature/updated-status",
121 };
122 state.currentRemoteStatus = {
123 ...baseRemoteStatus,
124 aheadCount: 2,
125 };
126 const refreshed = yield* broadcaster.refreshStatus("/repo");
127 const cached = yield* broadcaster.getStatus({ cwd: "/repo" });
128
129 assert.deepStrictEqual(initial, baseStatus);
130 assert.deepStrictEqual(refreshed, {
131 ...state.currentLocalStatus,
132 ...state.currentRemoteStatus,
133 });
134 assert.deepStrictEqual(cached, {
135 ...state.currentLocalStatus,
136 ...state.currentRemoteStatus,
137 });
138 assert.equal(state.localStatusCalls, 2);
139 assert.equal(state.remoteStatusCalls, 2);
140 assert.equal(state.localInvalidationCalls, 1);
141 assert.equal(state.remoteInvalidationCalls, 1);
142 }).pipe(Effect.provide(makeTestLayer(state)));
143 });
144
145 it.effect("refreshes only the cached local snapshot when requested", () => {
146 const state = {
147 currentLocalStatus: baseLocalStatus,
148 currentRemoteStatus: baseRemoteStatus,
149 localStatusCalls: 0,
150 remoteStatusCalls: 0,
151 localInvalidationCalls: 0,
152 remoteInvalidationCalls: 0,
153 };
154
155 return Effect.gen(function* () {
156 const broadcaster = yield* VcsStatusBroadcaster;
157 const initial = yield* broadcaster.getStatus({ cwd: "/repo" });
158
159 state.currentLocalStatus = {
160 ...baseLocalStatus,
161 refName: "feature/local-only-refresh",
162 hasWorkingTreeChanges: true,
163 };
164
165 const refreshedLocal = yield* broadcaster.refreshLocalStatus("/repo");
166 const cached = yield* broadcaster.getStatus({ cwd: "/repo" });
167
168 assert.deepStrictEqual(initial, baseStatus);
169 assert.deepStrictEqual(refreshedLocal, state.currentLocalStatus);
170 assert.deepStrictEqual(cached, {
171 ...state.currentLocalStatus,
172 ...baseRemoteStatus,
173 });
174 assert.equal(state.localStatusCalls, 2);
175 assert.equal(state.remoteStatusCalls, 1);
176 assert.equal(state.localInvalidationCalls, 1);
177 assert.equal(state.remoteInvalidationCalls, 0);
178 }).pipe(Effect.provide(makeTestLayer(state)));
179 });
180
181 it.effect("normalizes symlinked CWDs before cache lookup and workflow calls", () => {
182 const seenCwds: string[] = [];
183 const state = {
184 currentLocalStatus: baseLocalStatus,
185 currentRemoteStatus: baseRemoteStatus,
186 localStatusCalls: 0,
187 remoteStatusCalls: 0,
188 localInvalidationCalls: 0,
189 remoteInvalidationCalls: 0,
190 };
191 const testLayer = VcsStatusBroadcasterLayer.pipe(
192 Layer.provide(
193 Layer.mock(GitWorkflowService)({
194 localStatus: (input) =>
195 Effect.sync(() => {
196 seenCwds.push(input.cwd);
197 state.localStatusCalls += 1;
198 return state.currentLocalStatus;
199 }),
200 remoteStatus: (input) =>
201 Effect.sync(() => {
202 seenCwds.push(input.cwd);
203 state.remoteStatusCalls += 1;
204 return state.currentRemoteStatus;
205 }),
206 invalidateLocalStatus: () =>
207 Effect.sync(() => {
208 state.localInvalidationCalls += 1;
209 }),
210 invalidateRemoteStatus: () =>
211 Effect.sync(() => {
212 state.remoteInvalidationCalls += 1;
213 }),
214 } satisfies Partial<GitWorkflowServiceShape>),
215 ),
216 );
217
218 return Effect.gen(function* () {
219 const fileSystem = yield* FileSystem.FileSystem;
220 const path = yield* Path.Path;
221 const realDir = yield* fileSystem.makeTempDirectoryScoped({
222 prefix: "t3-vcs-status-real-",
223 });
224 const linkParent = yield* fileSystem.makeTempDirectoryScoped({
225 prefix: "t3-vcs-status-link-",
226 });
227 const linkDir = path.join(linkParent, "repo-link");
228 yield* fileSystem.symlink(realDir, linkDir);
229 const realPath = yield* fileSystem.realPath(realDir);
230
231 const broadcaster = yield* VcsStatusBroadcaster;
232 yield* broadcaster.getStatus({ cwd: linkDir });
233 yield* broadcaster.getStatus({ cwd: realDir });
234
235 assert.deepStrictEqual(seenCwds, [realPath, realPath]);
236 assert.equal(state.localStatusCalls, 1);
237 assert.equal(state.remoteStatusCalls, 1);
238 }).pipe(Effect.provide(Layer.mergeAll(testLayer, NodeServices.layer)));
239 });
240
241 it.effect("streams a local snapshot first and remote updates later", () => {
242 const state = {
243 currentLocalStatus: baseLocalStatus,
244 currentRemoteStatus: baseRemoteStatus,
245 localStatusCalls: 0,
246 remoteStatusCalls: 0,
247 localInvalidationCalls: 0,
248 remoteInvalidationCalls: 0,
249 };
250
251 return Effect.gen(function* () {
252 const broadcaster = yield* VcsStatusBroadcaster;
253 const snapshotDeferred = yield* Deferred.make<VcsStatusStreamEvent>();
254 const remoteUpdatedDeferred = yield* Deferred.make<VcsStatusStreamEvent>();
255 yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) => {
256 if (event._tag === "snapshot") {
257 return Deferred.succeed(snapshotDeferred, event).pipe(Effect.ignore);
258 }
259 if (event._tag === "remoteUpdated") {
260 return Deferred.succeed(remoteUpdatedDeferred, event).pipe(Effect.ignore);
261 }
262 return Effect.void;
263 }).pipe(Effect.forkScoped);
264
265 const snapshot = yield* Deferred.await(snapshotDeferred);
266 yield* broadcaster.refreshStatus("/repo");
267 const remoteUpdated = yield* Deferred.await(remoteUpdatedDeferred);
268
269 assert.deepStrictEqual(snapshot, {
270 _tag: "snapshot",
271 local: baseLocalStatus,
272 remote: null,
273 } satisfies VcsStatusStreamEvent);
274 assert.deepStrictEqual(remoteUpdated, {
275 _tag: "remoteUpdated",
276 remote: baseRemoteStatus,
277 } satisfies VcsStatusStreamEvent);
278 }).pipe(Effect.provide(makeTestLayer(state)));
279 });
280
281 it.effect("stops the remote poller after the last stream subscriber disconnects", () => {
282 const state = {
283 currentLocalStatus: baseLocalStatus,
284 currentRemoteStatus: baseRemoteStatus,
285 localStatusCalls: 0,
286 remoteStatusCalls: 0,
287 localInvalidationCalls: 0,
288 remoteInvalidationCalls: 0,
289 };
290 let remoteInterruptedDeferred: Deferred.Deferred<void, never> | null = null;
291 let remoteStartedDeferred: Deferred.Deferred<void, never> | null = null;
292 const testLayer = VcsStatusBroadcasterLayer.pipe(
293 Layer.provide(
294 Layer.mock(GitWorkflowService)({
295 localStatus: () =>
296 Effect.sync(() => {
297 state.localStatusCalls += 1;
298 return state.currentLocalStatus;
299 }),
300 remoteStatus: () =>
301 Effect.sync(() => {
302 state.remoteStatusCalls += 1;
303 }).pipe(
304 Effect.andThen(
305 remoteStartedDeferred
306 ? Deferred.succeed(remoteStartedDeferred, undefined).pipe(Effect.ignore)
307 : Effect.void,
308 ),
309 Effect.andThen(Effect.never as Effect.Effect<VcsStatusRemoteResult | null, never>),
310 Effect.onInterrupt(() =>
311 remoteInterruptedDeferred
312 ? Deferred.succeed(remoteInterruptedDeferred, undefined).pipe(Effect.ignore)
313 : Effect.void,
314 ),
315 ),
316 invalidateLocalStatus: () =>
317 Effect.sync(() => {
318 state.localInvalidationCalls += 1;
319 }),
320 invalidateRemoteStatus: () =>
321 Effect.sync(() => {
322 state.remoteInvalidationCalls += 1;
323 }),
324 } satisfies Partial<GitWorkflowServiceShape>),
325 ),
326 );
327
328 return Effect.gen(function* () {
329 const remoteInterrupted = yield* Deferred.make<void>();
330 const remoteStarted = yield* Deferred.make<void>();
331 remoteInterruptedDeferred = remoteInterrupted;
332 remoteStartedDeferred = remoteStarted;
333
334 const broadcaster = yield* VcsStatusBroadcaster;
335 const firstSnapshot = yield* Deferred.make<VcsStatusStreamEvent>();
336 const secondSnapshot = yield* Deferred.make<VcsStatusStreamEvent>();
337 const firstScope = yield* Scope.make();
338 const secondScope = yield* Scope.make();
339 yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) =>
340 event._tag === "snapshot"
341 ? Deferred.succeed(firstSnapshot, event).pipe(Effect.ignore)
342 : Effect.void,
343 ).pipe(Effect.forkIn(firstScope));
344 yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) =>
345 event._tag === "snapshot"
346 ? Deferred.succeed(secondSnapshot, event).pipe(Effect.ignore)
347 : Effect.void,
348 ).pipe(Effect.forkIn(secondScope));
349
350 yield* Deferred.await(firstSnapshot);
351 yield* Deferred.await(secondSnapshot);
352 yield* Deferred.await(remoteStarted);
353
354 assert.equal(state.remoteStatusCalls, 1);
355
356 yield* Scope.close(firstScope, Exit.void);
357 assert.isTrue(Option.isNone(yield* Deferred.poll(remoteInterrupted)));
358
359 yield* Scope.close(secondScope, Exit.void).pipe(Effect.forkScoped);
360 yield* Deferred.await(remoteInterrupted);
361 assert.isTrue(Option.isSome(yield* Deferred.poll(remoteInterrupted)));
362 }).pipe(Effect.provide(testLayer));
363 });
364});
apps/server/src/vcs/VcsStatusBroadcaster.ts created+344
......@@ -0,0 +1,344 @@
1import { realpathSync } from "node:fs";
2
3import {
4 Duration,
5 Effect,
6 Exit,
7 Fiber,
8 Layer,
9 PubSub,
10 Ref,
11 Scope,
12 Stream,
13 SynchronizedRef,
14} from "effect";
15import type {
16 GitManagerServiceError,
17 VcsStatusInput,
18 VcsStatusLocalResult,
19 VcsStatusRemoteResult,
20 VcsStatusResult,
21 VcsStatusStreamEvent,
22} from "@t3tools/contracts";
23import { Context } from "effect";
24import { mergeGitStatusParts } from "@t3tools/shared/git";
25
26import { GitWorkflowService } from "../git/GitWorkflowService.ts";
27
28const VCS_STATUS_REFRESH_INTERVAL = Duration.seconds(30);
29
30interface VcsStatusChange {
31 readonly cwd: string;
32 readonly event: VcsStatusStreamEvent;
33}
34
35interface CachedValue<T> {
36 readonly fingerprint: string;
37 readonly value: T;
38}
39
40interface CachedVcsStatus {
41 readonly local: CachedValue<VcsStatusLocalResult> | null;
42 readonly remote: CachedValue<VcsStatusRemoteResult | null> | null;
43}
44
45interface ActiveRemotePoller {
46 readonly fiber: Fiber.Fiber<void, never>;
47 readonly subscriberCount: number;
48}
49
50export interface VcsStatusBroadcasterShape {
51 readonly getStatus: (
52 input: VcsStatusInput,
53 ) => Effect.Effect<VcsStatusResult, GitManagerServiceError>;
54 readonly refreshLocalStatus: (
55 cwd: string,
56 ) => Effect.Effect<VcsStatusLocalResult, GitManagerServiceError>;
57 readonly refreshStatus: (cwd: string) => Effect.Effect<VcsStatusResult, GitManagerServiceError>;
58 readonly streamStatus: (
59 input: VcsStatusInput,
60 ) => Stream.Stream<VcsStatusStreamEvent, GitManagerServiceError>;
61}
62
63export class VcsStatusBroadcaster extends Context.Service<
64 VcsStatusBroadcaster,
65 VcsStatusBroadcasterShape
66>()("t3/vcs/VcsStatusBroadcaster") {}
67
68function fingerprintStatusPart(status: unknown): string {
69 return JSON.stringify(status);
70}
71
72function normalizeCwd(cwd: string): string {
73 try {
74 return realpathSync.native(cwd);
75 } catch {
76 return cwd;
77 }
78}
79
80export const layer = Layer.effect(
81 VcsStatusBroadcaster,
82 Effect.gen(function* () {
83 const workflow = yield* GitWorkflowService;
84 const changesPubSub = yield* Effect.acquireRelease(
85 PubSub.unbounded<VcsStatusChange>(),
86 (pubsub) => PubSub.shutdown(pubsub),
87 );
88 const broadcasterScope = yield* Effect.acquireRelease(Scope.make(), (scope) =>
89 Scope.close(scope, Exit.void),
90 );
91 const cacheRef = yield* Ref.make(new Map<string, CachedVcsStatus>());
92 const pollersRef = yield* SynchronizedRef.make(new Map<string, ActiveRemotePoller>());
93
94 const getCachedStatus = Effect.fn("VcsStatusBroadcaster.getCachedStatus")(function* (
95 cwd: string,
96 ) {
97 return yield* Ref.get(cacheRef).pipe(Effect.map((cache) => cache.get(cwd) ?? null));
98 });
99
100 const updateCachedLocalStatus = Effect.fn("VcsStatusBroadcaster.updateCachedLocalStatus")(
101 function* (cwd: string, local: VcsStatusLocalResult, options?: { publish?: boolean }) {
102 const nextLocal = {
103 fingerprint: fingerprintStatusPart(local),
104 value: local,
105 } satisfies CachedValue<VcsStatusLocalResult>;
106 const shouldPublish = yield* Ref.modify(cacheRef, (cache) => {
107 const previous = cache.get(cwd) ?? { local: null, remote: null };
108 const nextCache = new Map(cache);
109 nextCache.set(cwd, {
110 ...previous,
111 local: nextLocal,
112 });
113 return [previous.local?.fingerprint !== nextLocal.fingerprint, nextCache] as const;
114 });
115
116 if (options?.publish && shouldPublish) {
117 yield* PubSub.publish(changesPubSub, {
118 cwd,
119 event: {
120 _tag: "localUpdated",
121 local,
122 },
123 });
124 }
125
126 return local;
127 },
128 );
129
130 const updateCachedRemoteStatus = Effect.fn("VcsStatusBroadcaster.updateCachedRemoteStatus")(
131 function* (
132 cwd: string,
133 remote: VcsStatusRemoteResult | null,
134 options?: { publish?: boolean },
135 ) {
136 const nextRemote = {
137 fingerprint: fingerprintStatusPart(remote),
138 value: remote,
139 } satisfies CachedValue<VcsStatusRemoteResult | null>;
140 const shouldPublish = yield* Ref.modify(cacheRef, (cache) => {
141 const previous = cache.get(cwd) ?? { local: null, remote: null };
142 const nextCache = new Map(cache);
143 nextCache.set(cwd, {
144 ...previous,
145 remote: nextRemote,
146 });
147 return [previous.remote?.fingerprint !== nextRemote.fingerprint, nextCache] as const;
148 });
149
150 if (options?.publish && shouldPublish) {
151 yield* PubSub.publish(changesPubSub, {
152 cwd,
153 event: {
154 _tag: "remoteUpdated",
155 remote,
156 },
157 });
158 }
159
160 return remote;
161 },
162 );
163
164 const loadLocalStatus = Effect.fn("VcsStatusBroadcaster.loadLocalStatus")(function* (
165 cwd: string,
166 ) {
167 const local = yield* workflow.localStatus({ cwd });
168 return yield* updateCachedLocalStatus(cwd, local);
169 });
170
171 const loadRemoteStatus = Effect.fn("VcsStatusBroadcaster.loadRemoteStatus")(function* (
172 cwd: string,
173 ) {
174 const remote = yield* workflow.remoteStatus({ cwd });
175 return yield* updateCachedRemoteStatus(cwd, remote);
176 });
177
178 const getOrLoadLocalStatus = Effect.fn("VcsStatusBroadcaster.getOrLoadLocalStatus")(function* (
179 cwd: string,
180 ) {
181 const cached = yield* getCachedStatus(cwd);
182 if (cached?.local) {
183 return cached.local.value;
184 }
185 return yield* loadLocalStatus(cwd);
186 });
187
188 const getOrLoadRemoteStatus = Effect.fn("VcsStatusBroadcaster.getOrLoadRemoteStatus")(
189 function* (cwd: string) {
190 const cached = yield* getCachedStatus(cwd);
191 if (cached?.remote) {
192 return cached.remote.value;
193 }
194 return yield* loadRemoteStatus(cwd);
195 },
196 );
197
198 const getStatus: VcsStatusBroadcasterShape["getStatus"] = Effect.fn(
199 "VcsStatusBroadcaster.getStatus",
200 )(function* (input) {
201 const cwd = normalizeCwd(input.cwd);
202 const [local, remote] = yield* Effect.all([
203 getOrLoadLocalStatus(cwd),
204 getOrLoadRemoteStatus(cwd),
205 ]);
206 return mergeGitStatusParts(local, remote);
207 });
208
209 const refreshLocalStatus: VcsStatusBroadcasterShape["refreshLocalStatus"] = Effect.fn(
210 "VcsStatusBroadcaster.refreshLocalStatus",
211 )(function* (rawCwd) {
212 const cwd = normalizeCwd(rawCwd);
213 yield* workflow.invalidateLocalStatus(cwd);
214 const local = yield* workflow.localStatus({ cwd });
215 return yield* updateCachedLocalStatus(cwd, local, { publish: true });
216 });
217
218 const refreshRemoteStatus = Effect.fn("VcsStatusBroadcaster.refreshRemoteStatus")(function* (
219 cwd: string,
220 ) {
221 yield* workflow.invalidateRemoteStatus(cwd);
222 const remote = yield* workflow.remoteStatus({ cwd });
223 return yield* updateCachedRemoteStatus(cwd, remote, { publish: true });
224 });
225
226 const refreshStatus: VcsStatusBroadcasterShape["refreshStatus"] = Effect.fn(
227 "VcsStatusBroadcaster.refreshStatus",
228 )(function* (rawCwd) {
229 const cwd = normalizeCwd(rawCwd);
230 const [local, remote] = yield* Effect.all([
231 refreshLocalStatus(cwd),
232 refreshRemoteStatus(cwd),
233 ]);
234 return mergeGitStatusParts(local, remote);
235 });
236
237 const makeRemoteRefreshLoop = (cwd: string) => {
238 const logRefreshFailure = (error: Error) =>
239 Effect.logWarning("VCS remote status refresh failed", {
240 cwd,
241 detail: error.message,
242 });
243
244 return refreshRemoteStatus(cwd).pipe(
245 Effect.catch(logRefreshFailure),
246 Effect.andThen(
247 Effect.forever(
248 Effect.sleep(VCS_STATUS_REFRESH_INTERVAL).pipe(
249 Effect.andThen(refreshRemoteStatus(cwd).pipe(Effect.catch(logRefreshFailure))),
250 ),
251 ),
252 ),
253 );
254 };
255
256 const retainRemotePoller = Effect.fn("VcsStatusBroadcaster.retainRemotePoller")(function* (
257 cwd: string,
258 ) {
259 yield* SynchronizedRef.modifyEffect(pollersRef, (activePollers) => {
260 const existing = activePollers.get(cwd);
261 if (existing) {
262 const nextPollers = new Map(activePollers);
263 nextPollers.set(cwd, {
264 ...existing,
265 subscriberCount: existing.subscriberCount + 1,
266 });
267 return Effect.succeed([undefined, nextPollers] as const);
268 }
269
270 return makeRemoteRefreshLoop(cwd).pipe(
271 Effect.forkIn(broadcasterScope),
272 Effect.map((fiber) => {
273 const nextPollers = new Map(activePollers);
274 nextPollers.set(cwd, {
275 fiber,
276 subscriberCount: 1,
277 });
278 return [undefined, nextPollers] as const;
279 }),
280 );
281 });
282 });
283
284 const releaseRemotePoller = Effect.fn("VcsStatusBroadcaster.releaseRemotePoller")(function* (
285 cwd: string,
286 ) {
287 const pollerToInterrupt = yield* SynchronizedRef.modify(pollersRef, (activePollers) => {
288 const existing = activePollers.get(cwd);
289 if (!existing) {
290 return [null, activePollers] as const;
291 }
292
293 if (existing.subscriberCount > 1) {
294 const nextPollers = new Map(activePollers);
295 nextPollers.set(cwd, {
296 ...existing,
297 subscriberCount: existing.subscriberCount - 1,
298 });
299 return [null, nextPollers] as const;
300 }
301
302 const nextPollers = new Map(activePollers);
303 nextPollers.delete(cwd);
304 return [existing.fiber, nextPollers] as const;
305 });
306
307 if (pollerToInterrupt) {
308 yield* Fiber.interrupt(pollerToInterrupt).pipe(Effect.ignore);
309 }
310 });
311
312 const streamStatus: VcsStatusBroadcasterShape["streamStatus"] = (input) =>
313 Stream.unwrap(
314 Effect.gen(function* () {
315 const cwd = normalizeCwd(input.cwd);
316 const subscription = yield* PubSub.subscribe(changesPubSub);
317 const initialLocal = yield* getOrLoadLocalStatus(cwd);
318 const initialRemote = (yield* getCachedStatus(cwd))?.remote?.value ?? null;
319 yield* retainRemotePoller(cwd);
320
321 const release = releaseRemotePoller(cwd).pipe(Effect.ignore, Effect.asVoid);
322
323 return Stream.concat(
324 Stream.make({
325 _tag: "snapshot" as const,
326 local: initialLocal,
327 remote: initialRemote,
328 }),
329 Stream.fromSubscription(subscription).pipe(
330 Stream.filter((event) => event.cwd === cwd),
331 Stream.map((event) => event.event),
332 ),
333 ).pipe(Stream.ensuring(release));
334 }),
335 );
336
337 return VcsStatusBroadcaster.of({
338 getStatus,
339 refreshLocalStatus,
340 refreshStatus,
341 streamStatus,
342 });
343 }),
344);
apps/server/src/vcs/testing/VcsDriverContractHarness.ts created+152
......@@ -0,0 +1,152 @@
1import { assert, it } from "@effect/vitest";
2import { DateTime, Option } from "effect";
3import { Effect, FileSystem, Layer, Path, type PlatformError, type Scope } from "effect";
4import { describe } from "vitest";
5
6import type { VcsDriverKind } from "@t3tools/contracts";
7import { VcsDriver } from "../VcsDriver.ts";
8
9export interface VcsDriverFixture<R, E> {
10 readonly createRepo: (cwd: string) => Effect.Effect<void, E, R>;
11 readonly writeFile: (
12 cwd: string,
13 relativePath: string,
14 contents: string,
15 ) => Effect.Effect<void, E, R | FileSystem.FileSystem | Path.Path>;
16 readonly trackFile?: (cwd: string, relativePath: string) => Effect.Effect<void, E, R>;
17 readonly commit?: (cwd: string, message: string) => Effect.Effect<void, E, R>;
18 readonly ignorePath: (
19 cwd: string,
20 pattern: string,
21 ) => Effect.Effect<void, E, R | FileSystem.FileSystem | Path.Path>;
22}
23
24export interface VcsDriverContractSuiteInput<R, E> {
25 readonly name: string;
26 readonly kind: VcsDriverKind;
27 readonly layer: Layer.Layer<VcsDriver | R | FileSystem.FileSystem | Path.Path, E, never>;
28 readonly fixture: VcsDriverFixture<R, E>;
29}
30
31export function runVcsDriverContractSuite<R, E>(input: VcsDriverContractSuiteInput<R, E>) {
32 const makeTmpDir = (
33 prefix = `t3-${input.kind}-vcs-contract-`,
34 ): Effect.Effect<string, PlatformError.PlatformError, FileSystem.FileSystem | Scope.Scope> =>
35 Effect.gen(function* () {
36 const fileSystem = yield* FileSystem.FileSystem;
37 return yield* fileSystem.makeTempDirectoryScoped({ prefix });
38 });
39
40 it.layer(input.layer)(`${input.name} VCS driver contract`, (it) => {
41 describe("repository detection", () => {
42 it.effect("returns null outside a repository", () =>
43 Effect.gen(function* () {
44 const cwd = yield* makeTmpDir();
45 const driver = yield* VcsDriver;
46
47 assert.equal(yield* driver.detectRepository(cwd), null);
48 assert.equal(yield* driver.isInsideWorkTree(cwd), false);
49 }),
50 );
51
52 it.effect("detects repository identity inside a repository and nested directories", () =>
53 Effect.gen(function* () {
54 const cwd = yield* makeTmpDir();
55 const driver = yield* VcsDriver;
56
57 yield* input.fixture.createRepo(cwd);
58 yield* input.fixture.writeFile(cwd, "src/index.ts", "export const value = 1;\n");
59 const identity = yield* driver.detectRepository(cwd);
60 assert.equal(identity?.kind, input.kind);
61 assert.isTrue(identity?.rootPath.endsWith(cwd));
62 assert.equal(identity?.freshness.source, "live-local");
63 assert.isTrue(DateTime.isDateTime(identity?.freshness.observedAt));
64 assert.isTrue(Option.isNone(identity?.freshness.expiresAt ?? Option.none()));
65 assert.equal(yield* driver.isInsideWorkTree(cwd), true);
66
67 const path = yield* Path.Path;
68 const nestedDir = path.join(cwd, "src");
69 const nestedIdentity = yield* driver.detectRepository(nestedDir);
70 assert.equal(nestedIdentity?.rootPath, identity?.rootPath);
71 assert.equal(yield* driver.isInsideWorkTree(nestedDir), true);
72 }),
73 );
74 });
75
76 describe("workspace files", () => {
77 it.effect("lists tracked and untracked non-ignored files", () =>
78 Effect.gen(function* () {
79 const cwd = yield* makeTmpDir();
80 const driver = yield* VcsDriver;
81
82 yield* input.fixture.createRepo(cwd);
83 yield* input.fixture.writeFile(cwd, "tracked.ts", "export const tracked = true;\n");
84 if (input.fixture.trackFile && input.fixture.commit) {
85 yield* input.fixture.trackFile(cwd, "tracked.ts");
86 yield* input.fixture.commit(cwd, "Track file");
87 }
88 yield* input.fixture.writeFile(cwd, "untracked.ts", "export const untracked = true;\n");
89
90 const result = yield* driver.listWorkspaceFiles(cwd);
91
92 assert.include(result.paths, "tracked.ts");
93 assert.include(result.paths, "untracked.ts");
94 assert.equal(result.truncated, false);
95 assert.equal(result.freshness.source, "live-local");
96 assert.isTrue(DateTime.isDateTime(result.freshness.observedAt));
97 assert.isTrue(Option.isNone(result.freshness.expiresAt));
98 }),
99 );
100
101 it.effect("excludes ignored files from workspace listing", () =>
102 Effect.gen(function* () {
103 const cwd = yield* makeTmpDir();
104 const driver = yield* VcsDriver;
105
106 yield* input.fixture.createRepo(cwd);
107 yield* input.fixture.ignorePath(cwd, "*.log");
108 yield* input.fixture.writeFile(cwd, "included.ts", "export const included = true;\n");
109 yield* input.fixture.writeFile(cwd, "debug.log", "ignore me\n");
110 yield* input.fixture.writeFile(cwd, "nested/error.log", "ignore me too\n");
111
112 const result = yield* driver.listWorkspaceFiles(cwd);
113
114 assert.include(result.paths, "included.ts");
115 assert.notInclude(result.paths, "debug.log");
116 assert.notInclude(result.paths, "nested/error.log");
117 }),
118 );
119 });
120
121 describe("ignored path filtering", () => {
122 it.effect("filters ignored paths", () =>
123 Effect.gen(function* () {
124 const cwd = yield* makeTmpDir();
125 const driver = yield* VcsDriver;
126
127 yield* input.fixture.createRepo(cwd);
128 yield* input.fixture.ignorePath(cwd, "*.log");
129
130 const result = yield* driver.filterIgnoredPaths(cwd, [
131 "keep.ts",
132 "debug.log",
133 "nested/error.log",
134 ]);
135
136 assert.deepStrictEqual(result, ["keep.ts"]);
137 }),
138 );
139
140 it.effect("returns empty input unchanged", () =>
141 Effect.gen(function* () {
142 const cwd = yield* makeTmpDir();
143 const driver = yield* VcsDriver;
144
145 yield* input.fixture.createRepo(cwd);
146
147 assert.deepStrictEqual(yield* driver.filterIgnoredPaths(cwd, []), []);
148 }),
149 );
150 });
151 });
152}
apps/server/src/workspace/Layers/WorkspaceEntries.test.ts+8-7
......@@ -5,8 +5,8 @@ import { it, afterEach, describe, expect, vi } from "@effect/vitest";
55import { Effect, FileSystem, Layer, Path, PlatformError } from "effect";
66
77import { ServerConfig } from "../../config.ts";
8import { GitCoreLive } from "../../git/Layers/GitCore.ts";
9import { GitCore } from "../../git/Services/GitCore.ts";
8import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts";
9import * as VcsProcess from "../../vcs/VcsProcess.ts";
1010import { WorkspaceEntries } from "../Services/WorkspaceEntries.ts";
1111import { WorkspaceEntriesLive } from "./WorkspaceEntries.ts";
1212import { WorkspacePathsLive } from "./WorkspacePaths.ts";
......@@ -14,7 +14,8 @@ import { WorkspacePathsLive } from "./WorkspacePaths.ts";
1414const TestLayer = Layer.empty.pipe(
1515 Layer.provideMerge(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))),
1616 Layer.provideMerge(WorkspacePathsLive),
17 Layer.provideMerge(GitCoreLive),
17 Layer.provideMerge(VcsProcess.layer),
18 Layer.provideMerge(VcsDriverRegistry.layer.pipe(Layer.provide(VcsProcess.layer))),
1819 Layer.provide(
1920 ServerConfig.layerTest(process.cwd(), {
2021 prefix: "t3-workspace-entries-test-",
......@@ -25,12 +26,11 @@ const TestLayer = Layer.empty.pipe(
2526
2627const makeTempDir = Effect.fn(function* (opts?: { prefix?: string; git?: boolean }) {
2728 const fileSystem = yield* FileSystem.FileSystem;
28 const gitCore = yield* GitCore;
2929 const dir = yield* fileSystem.makeTempDirectoryScoped({
3030 prefix: opts?.prefix ?? "t3code-workspace-entries-",
3131 });
3232 if (opts?.git) {
33 yield* gitCore.initRepo({ cwd: dir });
33 yield* git(dir, ["init"]);
3434 }
3535 return dir;
3636});
......@@ -51,9 +51,10 @@ function writeTextFile(
5151
5252const git = (cwd: string, args: ReadonlyArray<string>, env?: NodeJS.ProcessEnv) =>
5353 Effect.gen(function* () {
54 const gitCore = yield* GitCore;
55 const result = yield* gitCore.execute({
54 const process = yield* VcsProcess.VcsProcess;
55 const result = yield* process.run({
5656 operation: "WorkspaceEntries.test.git",
57 command: "git",
5758 cwd,
5859 args,
5960 ...(env ? { env } : {}),
apps/server/src/workspace/Layers/WorkspaceEntries.ts+38-32
......@@ -2,7 +2,7 @@ import * as OS from "node:os";
22import fsPromises from "node:fs/promises";
33import type { Dirent } from "node:fs";
44
5import { Cache, Duration, Effect, Exit, Layer, Option, Path } from "effect";
5import { Cache, DateTime, Duration, Effect, Exit, Layer, Path } from "effect";
66
77import { type FilesystemBrowseInput, type ProjectEntry } from "@t3tools/contracts";
88import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/path";
......@@ -13,7 +13,7 @@ import {
1313 type RankedSearchResult,
1414} from "@t3tools/shared/searchRanking";
1515
16import { GitCore } from "../../git/Services/GitCore.ts";
16import { VcsDriverRegistry } from "../../vcs/VcsDriverRegistry.ts";
1717import {
1818 WorkspaceEntries,
1919 WorkspaceEntriesBrowseError,
......@@ -174,38 +174,39 @@ const resolveBrowseTarget = (
174174
175175export const makeWorkspaceEntries = Effect.gen(function* () {
176176 const path = yield* Path.Path;
177 const gitOption = yield* Effect.serviceOption(GitCore);
177 const vcsRegistry = yield* VcsDriverRegistry;
178178 const workspacePaths = yield* WorkspacePaths;
179179
180 const isInsideGitWorkTree = (cwd: string): Effect.Effect<boolean> =>
181 Option.match(gitOption, {
182 onSome: (git) => git.isInsideWorkTree(cwd).pipe(Effect.catch(() => Effect.succeed(false))),
183 onNone: () => Effect.succeed(false),
184 });
180 const isInsideVcsWorkTree = (cwd: string): Effect.Effect<boolean> =>
181 vcsRegistry.detect({ cwd }).pipe(
182 Effect.map((handle) => handle !== null),
183 Effect.catch(() => Effect.succeed(false)),
184 );
185185
186 const filterGitIgnoredPaths = (
186 const filterVcsIgnoredPaths = (
187187 cwd: string,
188188 relativePaths: string[],
189189 ): Effect.Effect<string[], never> =>
190 Option.match(gitOption, {
191 onSome: (git) =>
192 git.filterIgnoredPaths(cwd, relativePaths).pipe(
193 Effect.map((paths) => [...paths]),
194 Effect.catch(() => Effect.succeed(relativePaths)),
195 ),
196 onNone: () => Effect.succeed(relativePaths),
197 });
198
199 const buildWorkspaceIndexFromGit = Effect.fn("WorkspaceEntries.buildWorkspaceIndexFromGit")(
190 vcsRegistry.detect({ cwd }).pipe(
191 Effect.flatMap((handle) =>
192 handle
193 ? handle.driver.filterIgnoredPaths(cwd, relativePaths).pipe(
194 Effect.map((paths) => [...paths]),
195 Effect.catch(() => Effect.succeed(relativePaths)),
196 )
197 : Effect.succeed(relativePaths),
198 ),
199 Effect.catch(() => Effect.succeed(relativePaths)),
200 );
201
202 const buildWorkspaceIndexFromVcs = Effect.fn("WorkspaceEntries.buildWorkspaceIndexFromVcs")(
200203 function* (cwd: string) {
201 if (Option.isNone(gitOption)) {
202 return null;
203 }
204 if (!(yield* isInsideGitWorkTree(cwd))) {
204 const vcs = yield* vcsRegistry.detect({ cwd }).pipe(Effect.catch(() => Effect.succeed(null)));
205 if (!vcs) {
205206 return null;
206207 }
207208
208 const listedFiles = yield* gitOption.value
209 const listedFiles = yield* vcs.driver
209210 .listWorkspaceFiles(cwd)
210211 .pipe(Effect.catch(() => Effect.succeed(null)));
211212
......@@ -216,7 +217,10 @@ export const makeWorkspaceEntries = Effect.gen(function* () {
216217 const listedPaths = [...listedFiles.paths]
217218 .map((entry) => toPosixPath(entry))
218219 .filter((entry) => entry.length > 0 && !isPathInIgnoredDirectory(entry));
219 const filePaths = yield* filterGitIgnoredPaths(cwd, listedPaths);
220 const filePaths = yield* vcs.driver.filterIgnoredPaths(cwd, listedPaths).pipe(
221 Effect.map((paths) => [...paths]),
222 Effect.catch(() => filterVcsIgnoredPaths(cwd, listedPaths)),
223 );
220224
221225 const directorySet = new Set<string>();
222226 for (const filePath of filePaths) {
......@@ -248,9 +252,10 @@ export const makeWorkspaceEntries = Effect.gen(function* () {
248252 )
249253 .map(toSearchableWorkspaceEntry);
250254
255 const now = yield* DateTime.now;
251256 const entries = [...directoryEntries, ...fileEntries];
252257 return {
253 scannedAt: Date.now(),
258 scannedAt: now.epochMilliseconds,
254259 entries: entries.slice(0, WORKSPACE_INDEX_MAX_ENTRIES),
255260 truncated: listedFiles.truncated || entries.length > WORKSPACE_INDEX_MAX_ENTRIES,
256261 };
......@@ -288,7 +293,7 @@ export const makeWorkspaceEntries = Effect.gen(function* () {
288293 const buildWorkspaceIndexFromFilesystem = Effect.fn(
289294 "WorkspaceEntries.buildWorkspaceIndexFromFilesystem",
290295 )(function* (cwd: string): Effect.fn.Return<WorkspaceIndex, WorkspaceEntriesError> {
291 const shouldFilterWithGitIgnore = yield* isInsideGitWorkTree(cwd);
296 const shouldFilterWithGitIgnore = yield* isInsideVcsWorkTree(cwd);
292297
293298 let pendingDirectories: string[] = [""];
294299 const entries: SearchableWorkspaceEntry[] = [];
......@@ -336,7 +341,7 @@ export const makeWorkspaceEntries = Effect.gen(function* () {
336341 candidateEntries.map((entry) => entry.relativePath),
337342 );
338343 const allowedPathSet = shouldFilterWithGitIgnore
339 ? new Set(yield* filterGitIgnoredPaths(cwd, candidatePaths))
344 ? new Set(yield* filterVcsIgnoredPaths(cwd, candidatePaths))
340345 : null;
341346
342347 for (const candidateEntries of candidateEntriesByDirectory) {
......@@ -368,8 +373,9 @@ export const makeWorkspaceEntries = Effect.gen(function* () {
368373 }
369374 }
370375
376 const now = yield* DateTime.now;
371377 return {
372 scannedAt: Date.now(),
378 scannedAt: now.epochMilliseconds,
373379 entries,
374380 truncated,
375381 };
......@@ -378,9 +384,9 @@ export const makeWorkspaceEntries = Effect.gen(function* () {
378384 const buildWorkspaceIndex = Effect.fn("WorkspaceEntries.buildWorkspaceIndex")(function* (
379385 cwd: string,
380386 ): Effect.fn.Return<WorkspaceIndex, WorkspaceEntriesError> {
381 const gitIndexed = yield* buildWorkspaceIndexFromGit(cwd);
382 if (gitIndexed) {
383 return gitIndexed;
387 const vcsIndexed = yield* buildWorkspaceIndexFromVcs(cwd);
388 if (vcsIndexed) {
389 return vcsIndexed;
384390 }
385391 return yield* buildWorkspaceIndexFromFilesystem(cwd);
386392 });
apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts+3-2
......@@ -3,7 +3,8 @@ import { it, describe, expect } from "@effect/vitest";
33import { Effect, FileSystem, Layer, Path } from "effect";
44
55import { ServerConfig } from "../../config.ts";
6import { GitCoreLive } from "../../git/Layers/GitCore.ts";
6import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts";
7import * as VcsProcess from "../../vcs/VcsProcess.ts";
78import { WorkspaceEntries } from "../Services/WorkspaceEntries.ts";
89import { WorkspaceFileSystem } from "../Services/WorkspaceFileSystem.ts";
910import { WorkspaceEntriesLive } from "./WorkspaceEntries.ts";
......@@ -19,7 +20,7 @@ const TestLayer = Layer.empty.pipe(
1920 Layer.provideMerge(ProjectLayer),
2021 Layer.provideMerge(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))),
2122 Layer.provideMerge(WorkspacePathsLive),
22 Layer.provideMerge(GitCoreLive),
23 Layer.provideMerge(VcsDriverRegistry.layer.pipe(Layer.provide(VcsProcess.layer))),
2324 Layer.provide(
2425 ServerConfig.layerTest(process.cwd(), {
2526 prefix: "t3-workspace-files-test-",
apps/server/src/ws.ts+74-54
......@@ -29,9 +29,6 @@ import { RpcSerialization, RpcServer } from "effect/unstable/rpc";
2929
3030import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery.ts";
3131import { ServerConfig } from "./config.ts";
32import { GitCore } from "./git/Services/GitCore.ts";
33import { GitManager } from "./git/Services/GitManager.ts";
34import { GitStatusBroadcaster } from "./git/Services/GitStatusBroadcaster.ts";
3532import { Keybindings } from "./keybindings.ts";
3633import { Open, resolveAvailableEditors } from "./open.ts";
3734import { normalizeDispatchCommand } from "./orchestration/Normalizer.ts";
......@@ -50,10 +47,15 @@ import { TerminalManager } from "./terminal/Services/Manager.ts";
5047import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries.ts";
5148import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts";
5249import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePaths.ts";
50import { VcsStatusBroadcaster } from "./vcs/VcsStatusBroadcaster.ts";
51import { VcsProvisioningService } from "./vcs/VcsProvisioningService.ts";
52import { GitWorkflowService } from "./git/GitWorkflowService.ts";
5353import { ProjectSetupScriptRunner } from "./project/Services/ProjectSetupScriptRunner.ts";
5454import { RepositoryIdentityResolver } from "./project/Services/RepositoryIdentityResolver.ts";
5555import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts";
5656import { ServerAuth } from "./auth/Services/ServerAuth.ts";
57import * as SourceControlDiscoveryLayer from "./sourceControl/SourceControlDiscovery.ts";
58import * as VcsProcess from "./vcs/VcsProcess.ts";
5759import {
5860 BootstrapCredentialService,
5961 type BootstrapCredentialChange,
......@@ -136,9 +138,9 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>
136138 const checkpointDiffQuery = yield* CheckpointDiffQuery;
137139 const keybindings = yield* Keybindings;
138140 const open = yield* Open;
139 const gitManager = yield* GitManager;
140 const git = yield* GitCore;
141 const gitStatusBroadcaster = yield* GitStatusBroadcaster;
141 const gitWorkflow = yield* GitWorkflowService;
142 const vcsProvisioning = yield* VcsProvisioningService;
143 const vcsStatusBroadcaster = yield* VcsStatusBroadcaster;
142144 const terminalManager = yield* TerminalManager;
143145 const providerRegistry = yield* ProviderRegistry;
144146 const config = yield* ServerConfig;
......@@ -151,6 +153,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>
151153 const repositoryIdentityResolver = yield* RepositoryIdentityResolver;
152154 const serverEnvironment = yield* ServerEnvironment;
153155 const serverAuth = yield* ServerAuth;
156 const sourceControlDiscovery = yield* SourceControlDiscoveryLayer.SourceControlDiscovery;
154157 const bootstrapCredentials = yield* BootstrapCredentialService;
155158 const sessions = yield* SessionCredentialService;
156159 const serverCommandId = (tag: string) =>
......@@ -453,10 +456,10 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>
453456 }
454457
455458 if (bootstrap?.prepareWorktree) {
456 const worktree = yield* git.createWorktree({
459 const worktree = yield* gitWorkflow.createWorktree({
457460 cwd: bootstrap.prepareWorktree.projectCwd,
458 branch: bootstrap.prepareWorktree.baseBranch,
459 newBranch: bootstrap.prepareWorktree.branch,
461 refName: bootstrap.prepareWorktree.baseBranch,
462 newRefName: bootstrap.prepareWorktree.branch,
460463 path: null,
461464 });
462465 targetWorktreePath = worktree.worktree.path;
......@@ -464,7 +467,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>
464467 type: "thread.meta.update",
465468 commandId: serverCommandId("bootstrap-thread-meta-update"),
466469 threadId: command.threadId,
467 branch: worktree.worktree.branch,
470 branch: worktree.worktree.refName,
468471 worktreePath: targetWorktreePath,
469472 });
470473 yield* refreshGitStatus(targetWorktreePath);
......@@ -540,7 +543,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>
540543 });
541544
542545 const refreshGitStatus = (cwd: string) =>
543 gitStatusBroadcaster
546 vcsStatusBroadcaster
544547 .refreshStatus(cwd)
545548 .pipe(Effect.ignoreCause({ log: true }), Effect.forkDetach, Effect.asVoid);
546549
......@@ -783,6 +786,14 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>
783786 "rpc.aggregate": "server",
784787 },
785788 ),
789 [WS_METHODS.serverDiscoverSourceControl]: (_input) =>
790 observeRpcEffect(
791 WS_METHODS.serverDiscoverSourceControl,
792 sourceControlDiscovery.discover,
793 {
794 "rpc.aggregate": "server",
795 },
796 ),
786797 [WS_METHODS.projectsSearchEntries]: (input) =>
787798 observeRpcEffect(
788799 WS_METHODS.projectsSearchEntries,
......@@ -831,26 +842,26 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>
831842 ),
832843 { "rpc.aggregate": "workspace" },
833844 ),
834 [WS_METHODS.subscribeGitStatus]: (input) =>
845 [WS_METHODS.subscribeVcsStatus]: (input) =>
835846 observeRpcStream(
836 WS_METHODS.subscribeGitStatus,
837 gitStatusBroadcaster.streamStatus(input),
847 WS_METHODS.subscribeVcsStatus,
848 vcsStatusBroadcaster.streamStatus(input),
838849 {
839 "rpc.aggregate": "git",
850 "rpc.aggregate": "vcs",
840851 },
841852 ),
842 [WS_METHODS.gitRefreshStatus]: (input) =>
853 [WS_METHODS.vcsRefreshStatus]: (input) =>
843854 observeRpcEffect(
844 WS_METHODS.gitRefreshStatus,
845 gitStatusBroadcaster.refreshStatus(input.cwd),
855 WS_METHODS.vcsRefreshStatus,
856 vcsStatusBroadcaster.refreshStatus(input.cwd),
846857 {
847 "rpc.aggregate": "git",
858 "rpc.aggregate": "vcs",
848859 },
849860 ),
850 [WS_METHODS.gitPull]: (input) =>
861 [WS_METHODS.vcsPull]: (input) =>
851862 observeRpcEffect(
852 WS_METHODS.gitPull,
853 git.pullCurrentBranch(input.cwd).pipe(
863 WS_METHODS.vcsPull,
864 gitWorkflow.pullCurrentBranch(input.cwd).pipe(
854865 Effect.matchCauseEffect({
855866 onFailure: (cause) => Effect.failCause(cause),
856867 onSuccess: (result) =>
......@@ -863,7 +874,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>
863874 observeRpcStream(
864875 WS_METHODS.gitRunStackedAction,
865876 Stream.callback<GitActionProgressEvent, GitManagerServiceError>((queue) =>
866 gitManager
877 gitWorkflow
867878 .runStackedAction(input, {
868879 actionId: input.actionId,
869880 progressReporter: {
......@@ -880,55 +891,59 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) =>
880891 }),
881892 ),
882893 ),
883 { "rpc.aggregate": "git" },
894 { "rpc.aggregate": "vcs" },
884895 ),
885896 [WS_METHODS.gitResolvePullRequest]: (input) =>
886 observeRpcEffect(WS_METHODS.gitResolvePullRequest, gitManager.resolvePullRequest(input), {
887 "rpc.aggregate": "git",
888 }),
897 observeRpcEffect(
898 WS_METHODS.gitResolvePullRequest,
899 gitWorkflow.resolvePullRequest(input),
900 {
901 "rpc.aggregate": "git",
902 },
903 ),
889904 [WS_METHODS.gitPreparePullRequestThread]: (input) =>
890905 observeRpcEffect(
891906 WS_METHODS.gitPreparePullRequestThread,
892 gitManager
907 gitWorkflow
893908 .preparePullRequestThread(input)
894909 .pipe(Effect.tap(() => refreshGitStatus(input.cwd))),
895910 { "rpc.aggregate": "git" },
896911 ),
897 [WS_METHODS.gitListBranches]: (input) =>
898 observeRpcEffect(WS_METHODS.gitListBranches, git.listBranches(input), {
899 "rpc.aggregate": "git",
912 [WS_METHODS.vcsListRefs]: (input) =>
913 observeRpcEffect(WS_METHODS.vcsListRefs, gitWorkflow.listRefs(input), {
914 "rpc.aggregate": "vcs",
900915 }),
901 [WS_METHODS.gitCreateWorktree]: (input) =>
916 [WS_METHODS.vcsCreateWorktree]: (input) =>
902917 observeRpcEffect(
903 WS_METHODS.gitCreateWorktree,
904 git.createWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))),
905 { "rpc.aggregate": "git" },
918 WS_METHODS.vcsCreateWorktree,
919 gitWorkflow.createWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))),
920 { "rpc.aggregate": "vcs" },
906921 ),
907 [WS_METHODS.gitRemoveWorktree]: (input) =>
922 [WS_METHODS.vcsRemoveWorktree]: (input) =>
908923 observeRpcEffect(
909 WS_METHODS.gitRemoveWorktree,
910 git.removeWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))),
911 { "rpc.aggregate": "git" },
924 WS_METHODS.vcsRemoveWorktree,
925 gitWorkflow.removeWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))),
926 { "rpc.aggregate": "vcs" },
912927 ),
913 [WS_METHODS.gitCreateBranch]: (input) =>
928 [WS_METHODS.vcsCreateRef]: (input) =>
914929 observeRpcEffect(
915 WS_METHODS.gitCreateBranch,
916 git.createBranch(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))),
917 { "rpc.aggregate": "git" },
930 WS_METHODS.vcsCreateRef,
931 gitWorkflow.createRef(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))),
932 { "rpc.aggregate": "vcs" },
918933 ),
919 [WS_METHODS.gitCheckout]: (input) =>
934 [WS_METHODS.vcsSwitchRef]: (input) =>
920935 observeRpcEffect(
921 WS_METHODS.gitCheckout,
922 Effect.scoped(git.checkoutBranch(input)).pipe(
923 Effect.tap(() => refreshGitStatus(input.cwd)),
924 ),
925 { "rpc.aggregate": "git" },
936 WS_METHODS.vcsSwitchRef,
937 gitWorkflow.switchRef(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))),
938 { "rpc.aggregate": "vcs" },
926939 ),
927 [WS_METHODS.gitInit]: (input) =>
940 [WS_METHODS.vcsInit]: (input) =>
928941 observeRpcEffect(
929 WS_METHODS.gitInit,
930 git.initRepo(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))),
931 { "rpc.aggregate": "git" },
942 WS_METHODS.vcsInit,
943 vcsProvisioning
944 .initRepository(input)
945 .pipe(Effect.tap(() => refreshGitStatus(input.cwd))),
946 { "rpc.aggregate": "vcs" },
932947 ),
933948 [WS_METHODS.terminalOpen]: (input) =>
934949 observeRpcEffect(WS_METHODS.terminalOpen, terminalManager.open(input), {
......@@ -1084,7 +1099,12 @@ export const websocketRpcRouteLayer = Layer.unwrap(
10841099 },
10851100 }).pipe(
10861101 Effect.provide(
1087 makeWsRpcLayer(session.sessionId).pipe(Layer.provideMerge(RpcSerialization.layerJson)),
1102 makeWsRpcLayer(session.sessionId).pipe(
1103 Layer.provideMerge(RpcSerialization.layerJson),
1104 Layer.provide(
1105 SourceControlDiscoveryLayer.layer.pipe(Layer.provide(VcsProcess.layer)),
1106 ),
1107 ),
10881108 ),
10891109 );
10901110 return yield* Effect.acquireUseRelease(
apps/web/src/components/BranchToolbar.logic.test.ts+26-26
......@@ -1,4 +1,4 @@
1import { EnvironmentId, type GitBranch } from "@t3tools/contracts";
1import { EnvironmentId, type VcsRef } from "@t3tools/contracts";
22import { describe, expect, it } from "vitest";
33import {
44 dedupeRemoteBranchesWithLocalMatches,
......@@ -28,7 +28,7 @@ describe("resolveDraftEnvModeAfterBranchChange", () => {
2828 ).toBe("local");
2929 });
3030
31 it("keeps new-worktree mode when selecting a base branch before worktree creation", () => {
31 it("keeps new-worktree mode when selecting a base ref before worktree creation", () => {
3232 expect(
3333 resolveDraftEnvModeAfterBranchChange({
3434 nextWorktreePath: null,
......@@ -38,7 +38,7 @@ describe("resolveDraftEnvModeAfterBranchChange", () => {
3838 ).toBe("worktree");
3939 });
4040
41 it("uses worktree mode when selecting a branch already attached to a worktree", () => {
41 it("uses worktree mode when selecting a ref already attached to a worktree", () => {
4242 expect(
4343 resolveDraftEnvModeAfterBranchChange({
4444 nextWorktreePath: "/repo/.t3/worktrees/feature-a",
......@@ -50,7 +50,7 @@ describe("resolveDraftEnvModeAfterBranchChange", () => {
5050});
5151
5252describe("resolveBranchToolbarValue", () => {
53 it("defaults new-worktree mode to current git branch when no explicit base branch is set", () => {
53 it("defaults new-worktree mode to current git ref when no explicit base ref is set", () => {
5454 expect(
5555 resolveBranchToolbarValue({
5656 envMode: "worktree",
......@@ -61,7 +61,7 @@ describe("resolveBranchToolbarValue", () => {
6161 ).toBe("main");
6262 });
6363
64 it("keeps an explicitly selected worktree base branch", () => {
64 it("keeps an explicitly selected worktree base ref", () => {
6565 expect(
6666 resolveBranchToolbarValue({
6767 envMode: "worktree",
......@@ -72,7 +72,7 @@ describe("resolveBranchToolbarValue", () => {
7272 ).toBe("feature/base");
7373 });
7474
75 it("shows the actual checked-out branch when not selecting a new worktree base", () => {
75 it("shows the actual checked-out ref when not selecting a new worktree base", () => {
7676 expect(
7777 resolveBranchToolbarValue({
7878 envMode: "local",
......@@ -186,8 +186,8 @@ describe("deriveLocalBranchNameFromRemoteRef", () => {
186186});
187187
188188describe("dedupeRemoteBranchesWithLocalMatches", () => {
189 it("hides remote refs when the matching local branch exists", () => {
190 const input: GitBranch[] = [
189 it("hides remote refs when the matching local ref exists", () => {
190 const input: VcsRef[] = [
191191 {
192192 name: "feature/demo",
193193 current: false,
......@@ -212,14 +212,14 @@ describe("dedupeRemoteBranchesWithLocalMatches", () => {
212212 },
213213 ];
214214
215 expect(dedupeRemoteBranchesWithLocalMatches(input).map((branch) => branch.name)).toEqual([
215 expect(dedupeRemoteBranchesWithLocalMatches(input).map((ref) => ref.name)).toEqual([
216216 "feature/demo",
217217 "origin/feature/remote-only",
218218 ]);
219219 });
220220
221221 it("keeps all entries when no local match exists for a remote ref", () => {
222 const input: GitBranch[] = [
222 const input: VcsRef[] = [
223223 {
224224 name: "feature/local",
225225 current: false,
......@@ -236,14 +236,14 @@ describe("dedupeRemoteBranchesWithLocalMatches", () => {
236236 },
237237 ];
238238
239 expect(dedupeRemoteBranchesWithLocalMatches(input).map((branch) => branch.name)).toEqual([
239 expect(dedupeRemoteBranchesWithLocalMatches(input).map((ref) => ref.name)).toEqual([
240240 "feature/local",
241241 "origin/feature/remote-only",
242242 ]);
243243 });
244244
245 it("keeps non-origin remote refs visible even when a matching local branch exists", () => {
246 const input: GitBranch[] = [
245 it("keeps non-origin remote refs visible even when a matching local ref exists", () => {
246 const input: VcsRef[] = [
247247 {
248248 name: "feature/demo",
249249 current: false,
......@@ -260,14 +260,14 @@ describe("dedupeRemoteBranchesWithLocalMatches", () => {
260260 },
261261 ];
262262
263 expect(dedupeRemoteBranchesWithLocalMatches(input).map((branch) => branch.name)).toEqual([
263 expect(dedupeRemoteBranchesWithLocalMatches(input).map((ref) => ref.name)).toEqual([
264264 "feature/demo",
265265 "my-org/upstream/feature/demo",
266266 ]);
267267 });
268268
269269 it("keeps non-origin remote refs visible when git tracks with first-slash local naming", () => {
270 const input: GitBranch[] = [
270 const input: VcsRef[] = [
271271 {
272272 name: "upstream/feature",
273273 current: false,
......@@ -284,7 +284,7 @@ describe("dedupeRemoteBranchesWithLocalMatches", () => {
284284 },
285285 ];
286286
287 expect(dedupeRemoteBranchesWithLocalMatches(input).map((branch) => branch.name)).toEqual([
287 expect(dedupeRemoteBranchesWithLocalMatches(input).map((ref) => ref.name)).toEqual([
288288 "upstream/feature",
289289 "my-org/upstream/feature",
290290 ]);
......@@ -292,12 +292,12 @@ describe("dedupeRemoteBranchesWithLocalMatches", () => {
292292});
293293
294294describe("resolveBranchSelectionTarget", () => {
295 it("reuses an existing secondary worktree for the selected branch", () => {
295 it("reuses an existing secondary worktree for the selected ref", () => {
296296 expect(
297297 resolveBranchSelectionTarget({
298298 activeProjectCwd: "/repo",
299299 activeWorktreePath: "/repo/.t3/worktrees/feature-a",
300 branch: {
300 refName: {
301301 isDefault: false,
302302 worktreePath: "/repo/.t3/worktrees/feature-b",
303303 },
......@@ -309,12 +309,12 @@ describe("resolveBranchSelectionTarget", () => {
309309 });
310310 });
311311
312 it("switches back to the main repo when the branch already lives there", () => {
312 it("switches back to the main repo when the ref already lives there", () => {
313313 expect(
314314 resolveBranchSelectionTarget({
315315 activeProjectCwd: "/repo",
316316 activeWorktreePath: "/repo/.t3/worktrees/feature-a",
317 branch: {
317 refName: {
318318 isDefault: true,
319319 worktreePath: "/repo",
320320 },
......@@ -326,12 +326,12 @@ describe("resolveBranchSelectionTarget", () => {
326326 });
327327 });
328328
329 it("checks out the default branch in the main repo when leaving a secondary worktree", () => {
329 it("checks out the default ref in the main repo when leaving a secondary worktree", () => {
330330 expect(
331331 resolveBranchSelectionTarget({
332332 activeProjectCwd: "/repo",
333333 activeWorktreePath: "/repo/.t3/worktrees/feature-a",
334 branch: {
334 refName: {
335335 isDefault: true,
336336 worktreePath: null,
337337 },
......@@ -343,12 +343,12 @@ describe("resolveBranchSelectionTarget", () => {
343343 });
344344 });
345345
346 it("keeps checkout in the current worktree for non-default branches", () => {
346 it("keeps checkout in the current worktree for non-default refs", () => {
347347 expect(
348348 resolveBranchSelectionTarget({
349349 activeProjectCwd: "/repo",
350350 activeWorktreePath: "/repo/.t3/worktrees/feature-a",
351 branch: {
351 refName: {
352352 isDefault: false,
353353 worktreePath: null,
354354 },
......@@ -373,7 +373,7 @@ describe("shouldIncludeBranchPickerItem", () => {
373373 ).toBe(true);
374374 });
375375
376 it("keeps the synthetic create-branch item visible for arbitrary branch input", () => {
376 it("keeps the synthetic create-ref item visible for arbitrary ref input", () => {
377377 expect(
378378 shouldIncludeBranchPickerItem({
379379 itemValue: "__create_new_branch__:feature/demo",
......@@ -384,7 +384,7 @@ describe("shouldIncludeBranchPickerItem", () => {
384384 ).toBe(true);
385385 });
386386
387 it("still filters ordinary branch items by query text", () => {
387 it("still filters ordinary ref items by query text", () => {
388388 expect(
389389 shouldIncludeBranchPickerItem({
390390 itemValue: "main",
apps/web/src/components/BranchToolbar.logic.ts+7-7
......@@ -1,4 +1,4 @@
1import type { EnvironmentId, GitBranch, ProjectId } from "@t3tools/contracts";
1import type { EnvironmentId, VcsRef, ProjectId } from "@t3tools/contracts";
22import { Schema } from "effect";
33export {
44 dedupeRemoteBranchesWithLocalMatches,
......@@ -100,24 +100,24 @@ export function resolveBranchToolbarValue(input: {
100100export function resolveBranchSelectionTarget(input: {
101101 activeProjectCwd: string;
102102 activeWorktreePath: string | null;
103 branch: Pick<GitBranch, "isDefault" | "worktreePath">;
103 refName: Pick<VcsRef, "isDefault" | "worktreePath">;
104104}): {
105105 checkoutCwd: string;
106106 nextWorktreePath: string | null;
107107 reuseExistingWorktree: boolean;
108108} {
109 const { activeProjectCwd, activeWorktreePath, branch } = input;
109 const { activeProjectCwd, activeWorktreePath, refName } = input;
110110
111 if (branch.worktreePath) {
111 if (refName.worktreePath) {
112112 return {
113 checkoutCwd: branch.worktreePath,
114 nextWorktreePath: branch.worktreePath === activeProjectCwd ? null : branch.worktreePath,
113 checkoutCwd: refName.worktreePath,
114 nextWorktreePath: refName.worktreePath === activeProjectCwd ? null : refName.worktreePath,
115115 reuseExistingWorktree: true,
116116 };
117117 }
118118
119119 const nextWorktreePath =
120 activeWorktreePath !== null && branch.isDefault ? null : activeWorktreePath;
120 activeWorktreePath !== null && refName.isDefault ? null : activeWorktreePath;
121121
122122 return {
123123 checkoutCwd: nextWorktreePath ?? activeProjectCwd,
apps/web/src/components/BranchToolbarBranchSelector.tsx+61-50
......@@ -1,5 +1,5 @@
11import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime";
2import type { EnvironmentId, GitBranch, ThreadId } from "@t3tools/contracts";
2import type { EnvironmentId, VcsRef, ThreadId } from "@t3tools/contracts";
33import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
44import { LegendList, type LegendListRef } from "@legendapp/list/react";
55import { ChevronDownIcon } from "lucide-react";
......@@ -21,6 +21,7 @@ import { useGitStatus } from "../lib/gitStatusState";
2121import { newCommandId } from "../lib/utils";
2222import { cn } from "../lib/utils";
2323import { parsePullRequestReference } from "../pullRequestReference";
24import { getSourceControlPresentation } from "../sourceControlPresentation";
2425import { useStore } from "../store";
2526import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors";
2627import {
......@@ -53,7 +54,7 @@ interface BranchToolbarBranchSelectorProps {
5354 envLocked: boolean;
5455 effectiveEnvModeOverride?: "local" | "worktree";
5556 activeThreadBranchOverride?: string | null;
56 onActiveThreadBranchOverrideChange?: (branch: string | null) => void;
57 onActiveThreadBranchOverrideChange?: (refName: string | null) => void;
5758 onCheckoutPullRequestRequest?: (reference: string) => void;
5859 onComposerFocusRequest?: () => void;
5960}
......@@ -69,7 +70,7 @@ function getBranchTriggerLabel(input: {
6970}): string {
7071 const { activeWorktreePath, effectiveEnvMode, resolvedActiveBranch } = input;
7172 if (!resolvedActiveBranch) {
72 return "Select branch";
73 return "Select ref";
7374 }
7475 if (effectiveEnvMode === "worktree" && !activeWorktreePath) {
7576 return `From ${resolvedActiveBranch}`;
......@@ -193,7 +194,7 @@ export function BranchToolbarBranchSelector({
193194 );
194195
195196 // ---------------------------------------------------------------------------
196 // Git branch queries
197 // Git ref queries
197198 // ---------------------------------------------------------------------------
198199 const queryClient = useQueryClient();
199200 const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false);
......@@ -224,22 +225,27 @@ export function BranchToolbarBranchSelector({
224225 query: deferredTrimmedBranchQuery,
225226 }),
226227 );
227 const branches = useMemo(
228 () => branchesSearchData?.pages.flatMap((page) => page.branches) ?? [],
228 const refs = useMemo(
229 () => branchesSearchData?.pages.flatMap((page) => page.refs) ?? [],
229230 [branchesSearchData?.pages],
230231 );
231232 const currentGitBranch =
232 branchStatusQuery.data?.branch ?? branches.find((branch) => branch.current)?.name ?? null;
233 branchStatusQuery.data?.refName ?? refs.find((refName) => refName.current)?.name ?? null;
234 const sourceControlPresentation = useMemo(
235 () => getSourceControlPresentation(branchStatusQuery.data?.sourceControlProvider),
236 [branchStatusQuery.data?.sourceControlProvider],
237 );
238 const SourceControlIcon = sourceControlPresentation.Icon;
233239 const canonicalActiveBranch = resolveBranchToolbarValue({
234240 envMode: effectiveEnvMode,
235241 activeWorktreePath,
236242 activeThreadBranch,
237243 currentGitBranch,
238244 });
239 const branchNames = useMemo(() => branches.map((branch) => branch.name), [branches]);
245 const branchNames = useMemo(() => refs.map((refName) => refName.name), [refs]);
240246 const branchByName = useMemo(
241 () => new Map(branches.map((branch) => [branch.name, branch] as const)),
242 [branches],
247 () => new Map(refs.map((refName) => [refName.name, refName] as const)),
248 [refs],
243249 );
244250 const normalizedDeferredBranchQuery = deferredTrimmedBranchQuery.toLowerCase();
245251 const prReference = parsePullRequestReference(trimmedBranchQuery);
......@@ -289,11 +295,11 @@ export function BranchToolbarBranchSelector({
289295 const shouldVirtualizeBranchList = filteredBranchPickerItems.length > 40;
290296 const totalBranchCount = branchesSearchData?.pages[0]?.totalCount ?? 0;
291297 const branchStatusText = isBranchesSearchPending
292 ? "Loading branches..."
298 ? "Loading refs..."
293299 : isFetchingNextPage
294 ? "Loading more branches..."
300 ? "Loading more refs..."
295301 : hasNextPage
296 ? `Showing ${branches.length} of ${totalBranchCount} branches`
302 ? `Showing ${refs.length} of ${totalBranchCount} refs`
297303 : null;
298304
299305 // ---------------------------------------------------------------------------
......@@ -303,17 +309,17 @@ export function BranchToolbarBranchSelector({
303309 startBranchActionTransition(async () => {
304310 await action().catch(() => undefined);
305311 await queryClient
306 .invalidateQueries({ queryKey: gitQueryKeys.branches(environmentId, branchCwd) })
312 .invalidateQueries({ queryKey: gitQueryKeys.refs(environmentId, branchCwd) })
307313 .catch(() => undefined);
308314 });
309315 };
310316
311 const selectBranch = (branch: GitBranch) => {
317 const selectBranch = (refName: VcsRef) => {
312318 const api = readEnvironmentApi(environmentId);
313319 if (!api || !branchCwd || !activeProjectCwd || isBranchActionPending) return;
314320
315321 if (isSelectingWorktreeBase) {
316 setThreadBranch(branch.name, null);
322 setThreadBranch(refName.name, null);
317323 setIsBranchMenuOpen(false);
318324 onComposerFocusRequest?.();
319325 return;
......@@ -322,19 +328,19 @@ export function BranchToolbarBranchSelector({
322328 const selectionTarget = resolveBranchSelectionTarget({
323329 activeProjectCwd,
324330 activeWorktreePath,
325 branch,
331 refName,
326332 });
327333
328334 if (selectionTarget.reuseExistingWorktree) {
329 setThreadBranch(branch.name, selectionTarget.nextWorktreePath);
335 setThreadBranch(refName.name, selectionTarget.nextWorktreePath);
330336 setIsBranchMenuOpen(false);
331337 onComposerFocusRequest?.();
332338 return;
333339 }
334340
335 const selectedBranchName = branch.isRemote
336 ? deriveLocalBranchNameFromRemoteRef(branch.name)
337 : branch.name;
341 const selectedBranchName = refName.isRemote
342 ? deriveLocalBranchNameFromRemoteRef(refName.name)
343 : refName.name;
338344
339345 setIsBranchMenuOpen(false);
340346 onComposerFocusRequest?.();
......@@ -343,12 +349,12 @@ export function BranchToolbarBranchSelector({
343349 const previousBranch = resolvedActiveBranch;
344350 setOptimisticBranch(selectedBranchName);
345351 try {
346 const checkoutResult = await api.git.checkout({
352 const checkoutResult = await api.vcs.switchRef({
347353 cwd: selectionTarget.checkoutCwd,
348 branch: branch.name,
354 refName: refName.name,
349355 });
350 const nextBranchName = branch.isRemote
351 ? (checkoutResult.branch ?? selectedBranchName)
356 const nextBranchName = refName.isRemote
357 ? (checkoutResult.refName ?? selectedBranchName)
352358 : selectedBranchName;
353359 setOptimisticBranch(nextBranchName);
354360 setThreadBranch(nextBranchName, selectionTarget.nextWorktreePath);
......@@ -357,7 +363,7 @@ export function BranchToolbarBranchSelector({
357363 toastManager.add(
358364 stackedThreadToast({
359365 type: "error",
360 title: "Failed to checkout branch.",
366 title: "Failed to switch ref.",
361367 description: toBranchActionErrorMessage(error),
362368 }),
363369 );
......@@ -365,7 +371,7 @@ export function BranchToolbarBranchSelector({
365371 });
366372 };
367373
368 const createBranch = (rawName: string) => {
374 const createRef = (rawName: string) => {
369375 const name = rawName.trim();
370376 const api = readEnvironmentApi(environmentId);
371377 if (!api || !branchCwd || !name || isBranchActionPending) return;
......@@ -377,19 +383,19 @@ export function BranchToolbarBranchSelector({
377383 const previousBranch = resolvedActiveBranch;
378384 setOptimisticBranch(name);
379385 try {
380 const createBranchResult = await api.git.createBranch({
386 const createBranchResult = await api.vcs.createRef({
381387 cwd: branchCwd,
382 branch: name,
383 checkout: true,
388 refName: name,
389 switchRef: true,
384390 });
385 setOptimisticBranch(createBranchResult.branch);
386 setThreadBranch(createBranchResult.branch, activeWorktreePath);
391 setOptimisticBranch(createBranchResult.refName);
392 setThreadBranch(createBranchResult.refName, activeWorktreePath);
387393 } catch (error) {
388394 setOptimisticBranch(previousBranch);
389395 toastManager.add(
390396 stackedThreadToast({
391397 type: "error",
392 title: "Failed to create and checkout branch.",
398 title: "Failed to create and switch ref.",
393399 description: toBranchActionErrorMessage(error),
394400 }),
395401 );
......@@ -420,7 +426,7 @@ export function BranchToolbarBranchSelector({
420426 return;
421427 }
422428 void queryClient.invalidateQueries({
423 queryKey: gitQueryKeys.branches(environmentId, branchCwd),
429 queryKey: gitQueryKeys.refs(environmentId, branchCwd),
424430 });
425431 },
426432 [branchCwd, environmentId, queryClient],
......@@ -482,7 +488,7 @@ export function BranchToolbarBranchSelector({
482488 useEffect(() => {
483489 if (shouldVirtualizeBranchList) return;
484490 maybeFetchNextBranchPage();
485 }, [branches.length, maybeFetchNextBranchPage, shouldVirtualizeBranchList]);
491 }, [refs.length, maybeFetchNextBranchPage, shouldVirtualizeBranchList]);
486492
487493 const triggerLabel = getBranchTriggerLabel({
488494 activeWorktreePath,
......@@ -508,9 +514,14 @@ export function BranchToolbarBranchSelector({
508514 onCheckoutPullRequestRequest(prReference);
509515 }}
510516 >
511 <div className="flex min-w-0 flex-col items-start py-1">
512 <span className="truncate font-medium">Checkout Pull Request</span>
513 <span className="truncate text-muted-foreground text-xs">{prReference}</span>
517 <div className="flex min-w-0 items-center gap-2 py-1">
518 <SourceControlIcon className="size-3.5 shrink-0 text-muted-foreground" />
519 <span className="flex min-w-0 flex-col items-start">
520 <span className="truncate font-medium">
521 Checkout {sourceControlPresentation.terminology.singular}
522 </span>
523 <span className="truncate text-muted-foreground text-xs">{prReference}</span>
524 </span>
514525 </div>
515526 </ComboboxItem>
516527 );
......@@ -522,25 +533,25 @@ export function BranchToolbarBranchSelector({
522533 key={itemValue}
523534 index={index}
524535 value={itemValue}
525 onClick={() => createBranch(trimmedBranchQuery)}
536 onClick={() => createRef(trimmedBranchQuery)}
526537 >
527 <span className="truncate">Create new branch &quot;{trimmedBranchQuery}&quot;</span>
538 <span className="truncate">Create new ref &quot;{trimmedBranchQuery}&quot;</span>
528539 </ComboboxItem>
529540 );
530541 }
531542
532 const branch = branchByName.get(itemValue);
533 if (!branch) return null;
543 const refName = branchByName.get(itemValue);
544 if (!refName) return null;
534545
535546 const hasSecondaryWorktree =
536 branch.worktreePath && activeProjectCwd && branch.worktreePath !== activeProjectCwd;
537 const badge = branch.current
547 refName.worktreePath && activeProjectCwd && refName.worktreePath !== activeProjectCwd;
548 const badge = refName.current
538549 ? "current"
539550 : hasSecondaryWorktree
540551 ? "worktree"
541 : branch.isRemote
552 : refName.isRemote
542553 ? "remote"
543 : branch.isDefault
554 : refName.isDefault
544555 ? "default"
545556 : null;
546557 return (
......@@ -549,7 +560,7 @@ export function BranchToolbarBranchSelector({
549560 key={itemValue}
550561 index={index}
551562 value={itemValue}
552 onClick={() => selectBranch(branch)}
563 onClick={() => selectBranch(refName)}
553564 >
554565 <div className="flex w-full items-center justify-between gap-2">
555566 <span className="truncate">{itemValue}</span>
......@@ -581,7 +592,7 @@ export function BranchToolbarBranchSelector({
581592 <ComboboxTrigger
582593 render={<Button variant="ghost" size="xs" />}
583594 className={cn("min-w-0 text-muted-foreground/70 hover:text-foreground/80", className)}
584 disabled={(isBranchesSearchPending && branches.length === 0) || isBranchActionPending}
595 disabled={(isBranchesSearchPending && refs.length === 0) || isBranchActionPending}
585596 >
586597 <span className="min-w-0 max-w-[240px] truncate">{triggerLabel}</span>
587598 <ChevronDownIcon className="shrink-0" />
......@@ -591,14 +602,14 @@ export function BranchToolbarBranchSelector({
591602 <ComboboxInput
592603 className="[&_input]:font-sans rounded-md"
593604 inputClassName="ring-0"
594 placeholder="Search branches..."
605 placeholder="Search refs..."
595606 showTrigger={false}
596607 size="sm"
597608 value={branchQuery}
598609 onChange={(event) => setBranchQuery(event.target.value)}
599610 />
600611 </div>
601 <ComboboxEmpty>No branches found.</ComboboxEmpty>
612 <ComboboxEmpty>No refs found.</ComboboxEmpty>
602613
603614 {shouldVirtualizeBranchList ? (
604615 <ComboboxListVirtualized>
apps/web/src/components/ChatView.browser.tsx+26-25
......@@ -206,6 +206,7 @@ function createMockEnvironmentApi(input: {
206206 filesystem: {
207207 browse: input.browse,
208208 },
209 vcs: {} as EnvironmentApi["vcs"],
209210 git: {} as EnvironmentApi["git"],
210211 orchestration: {
211212 dispatchCommand: input.dispatchCommand,
......@@ -951,13 +952,13 @@ function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown {
951952 if (tag === WS_METHODS.serverGetConfig) {
952953 return fixture.serverConfig;
953954 }
954 if (tag === WS_METHODS.gitListBranches) {
955 if (tag === WS_METHODS.vcsListRefs) {
955956 return {
956957 isRepo: true,
957 hasOriginRemote: true,
958 hasPrimaryRemote: true,
958959 nextCursor: null,
959960 totalCount: 1,
960 branches: [
961 refs: [
961962 {
962963 name: "main",
963964 current: true,
......@@ -2287,16 +2288,16 @@ describe("ChatView timeline estimator parity (full app)", () => {
22872288 branchButton.click();
22882289
22892290 const branchInput = await waitForElement(
2290 () => document.querySelector<HTMLInputElement>('input[placeholder="Search branches..."]'),
2291 "Unable to find branch search input.",
2291 () => document.querySelector<HTMLInputElement>('input[placeholder="Search refs..."]'),
2292 "Unable to find ref search input.",
22922293 );
22932294 branchInput.focus();
2294 await page.getByPlaceholder("Search branches...").fill("1359");
2295 await page.getByPlaceholder("Search refs...").fill("1359");
22952296
22962297 const checkoutItem = await waitForElement(
22972298 () =>
22982299 Array.from(document.querySelectorAll("span")).find(
2299 (element) => element.textContent?.trim() === "Checkout Pull Request",
2300 (element) => element.textContent?.trim() === "Checkout pull request",
23002301 ) as HTMLSpanElement | null,
23012302 "Unable to find checkout pull request option.",
23022303 );
......@@ -2425,7 +2426,7 @@ describe("ChatView timeline estimator parity (full app)", () => {
24252426 { timeout: 8_000, interval: 16 },
24262427 );
24272428
2428 expect(wsRequests.some((request) => request._tag === WS_METHODS.gitCreateWorktree)).toBe(
2429 expect(wsRequests.some((request) => request._tag === WS_METHODS.vcsCreateWorktree)).toBe(
24292430 false,
24302431 );
24312432 expect(
......@@ -2572,13 +2573,13 @@ describe("ChatView timeline estimator parity (full app)", () => {
25722573 ),
25732574 },
25742575 resolveRpc: (body) => {
2575 if (body._tag === WS_METHODS.gitListBranches) {
2576 if (body._tag === WS_METHODS.vcsListRefs) {
25762577 return {
25772578 isRepo: true,
2578 hasOriginRemote: true,
2579 hasPrimaryRemote: true,
25792580 nextCursor: null,
25802581 totalCount: 1,
2581 branches: [
2582 refs: [
25822583 {
25832584 name: "main",
25842585 current: true,
......@@ -2665,13 +2666,13 @@ describe("ChatView timeline estimator parity (full app)", () => {
26652666 ),
26662667 },
26672668 resolveRpc: (body) => {
2668 if (body._tag === WS_METHODS.gitListBranches) {
2669 if (body._tag === WS_METHODS.vcsListRefs) {
26692670 return {
26702671 isRepo: true,
2671 hasOriginRemote: true,
2672 hasPrimaryRemote: true,
26722673 nextCursor: null,
26732674 totalCount: 2,
2674 branches: [
2675 refs: [
26752676 {
26762677 name: "main",
26772678 current: true,
......@@ -2761,13 +2762,13 @@ describe("ChatView timeline estimator parity (full app)", () => {
27612762 viewport: DEFAULT_VIEWPORT,
27622763 snapshot: snapshotWithTwoThreads,
27632764 resolveRpc: (body) => {
2764 if (body._tag === WS_METHODS.gitListBranches) {
2765 if (body._tag === WS_METHODS.vcsListRefs) {
27652766 return {
27662767 isRepo: true,
2767 hasOriginRemote: true,
2768 hasPrimaryRemote: true,
27682769 nextCursor: null,
27692770 totalCount: 2,
2770 branches: [
2771 refs: [
27712772 {
27722773 name: "main",
27732774 current: true,
......@@ -3021,13 +3022,13 @@ describe("ChatView timeline estimator parity (full app)", () => {
30213022 snapshot: createDraftOnlySnapshot(),
30223023 initialPath: `/draft/${activeDraftId}`,
30233024 resolveRpc: (body) => {
3024 if (body._tag === WS_METHODS.gitListBranches) {
3025 if (body._tag === WS_METHODS.vcsListRefs) {
30253026 return {
30263027 isRepo: true,
3027 hasOriginRemote: true,
3028 hasPrimaryRemote: true,
30283029 nextCursor: null,
30293030 totalCount: 2,
3030 branches: [
3031 refs: [
30313032 {
30323033 name: "main",
30333034 current: true,
......@@ -3146,13 +3147,13 @@ describe("ChatView timeline estimator parity (full app)", () => {
31463147 snapshot: createDraftOnlySnapshot(),
31473148 initialPath: `/draft/${draftId}`,
31483149 resolveRpc: (body) => {
3149 if (body._tag === WS_METHODS.gitListBranches) {
3150 if (body._tag === WS_METHODS.vcsListRefs) {
31503151 return {
31513152 isRepo: true,
3152 hasOriginRemote: true,
3153 hasPrimaryRemote: true,
31533154 nextCursor: null,
31543155 totalCount: branches.length,
3155 branches,
3156 refs: branches,
31563157 };
31573158 }
31583159 return undefined;
......@@ -3170,8 +3171,8 @@ describe("ChatView timeline estimator parity (full app)", () => {
31703171 branchButton.click();
31713172
31723173 await waitForElement(
3173 () => document.querySelector<HTMLInputElement>('input[placeholder="Search branches..."]'),
3174 "Unable to find branch search input.",
3174 () => document.querySelector<HTMLInputElement>('input[placeholder="Search refs..."]'),
3175 "Unable to find ref search input.",
31753176 );
31763177
31773178 const popup = await waitForElement(
apps/web/src/components/GitActionsControl.browser.tsx+9-1
......@@ -113,7 +113,15 @@ vi.mock("~/lib/gitStatusState", () => ({
113113 resetGitStatusStateForTests: () => undefined,
114114 useGitStatus: vi.fn(() => ({
115115 data: {
116 branch: BRANCH_NAME,
116 isRepo: true,
117 sourceControlProvider: {
118 kind: "github",
119 name: "GitHub",
120 baseUrl: "https://github.com",
121 },
122 hasPrimaryRemote: true,
123 isDefaultRef: false,
124 refName: BRANCH_NAME,
117125 hasWorkingTreeChanges: false,
118126 workingTree: { files: [], insertions: 0, deletions: 0 },
119127 hasUpstream: true,
apps/web/src/components/GitActionsControl.logic.test.ts+101-75
......@@ -1,4 +1,4 @@
1import type { GitStatusResult } from "@t3tools/contracts";
1import type { VcsStatusResult } from "@t3tools/contracts";
22import { assert, describe, it } from "vitest";
33import {
44 buildGitActionProgressStages,
......@@ -11,12 +11,12 @@ import {
1111 resolveThreadBranchUpdate,
1212} from "./GitActionsControl.logic";
1313
14function status(overrides: Partial<GitStatusResult> = {}): GitStatusResult {
14function status(overrides: Partial<VcsStatusResult> = {}): VcsStatusResult {
1515 return {
1616 isRepo: true,
17 hasOriginRemote: true,
18 isDefaultBranch: false,
19 branch: "feature/test",
17 hasPrimaryRemote: true,
18 isDefaultRef: false,
19 refName: "feature/test",
2020 hasWorkingTreeChanges: false,
2121 workingTree: {
2222 files: [],
......@@ -31,7 +31,7 @@ function status(overrides: Partial<GitStatusResult> = {}): GitStatusResult {
3131 };
3232}
3333
34describe("when: branch is clean and has an open PR", () => {
34describe("when: ref is clean and has an open PR", () => {
3535 it("resolveQuickAction opens the existing PR", () => {
3636 const quick = resolveQuickAction(
3737 status({
......@@ -39,8 +39,8 @@ describe("when: branch is clean and has an open PR", () => {
3939 number: 10,
4040 title: "Open PR",
4141 url: "https://example.com/pr/10",
42 baseBranch: "main",
43 headBranch: "feature/test",
42 baseRef: "main",
43 headRef: "feature/test",
4444 state: "open",
4545 },
4646 }),
......@@ -56,8 +56,8 @@ describe("when: branch is clean and has an open PR", () => {
5656 number: 11,
5757 title: "Existing PR",
5858 url: "https://example.com/pr/11",
59 baseBranch: "main",
60 headBranch: "feature/test",
59 baseRef: "main",
60 headRef: "feature/test",
6161 state: "open",
6262 },
6363 }),
......@@ -150,7 +150,7 @@ describe("when: git status is unavailable", () => {
150150 });
151151});
152152
153describe("when: branch is clean, ahead, and has an open PR", () => {
153describe("when: ref is clean, ahead, and has an open PR", () => {
154154 it("resolveQuickAction prefers push", () => {
155155 const quick = resolveQuickAction(
156156 status({
......@@ -159,8 +159,8 @@ describe("when: branch is clean, ahead, and has an open PR", () => {
159159 number: 13,
160160 title: "Open PR",
161161 url: "https://example.com/pr/13",
162 baseBranch: "main",
163 headBranch: "feature/test",
162 baseRef: "main",
163 headRef: "feature/test",
164164 state: "open",
165165 },
166166 }),
......@@ -177,8 +177,8 @@ describe("when: branch is clean, ahead, and has an open PR", () => {
177177 number: 12,
178178 title: "Existing PR",
179179 url: "https://example.com/pr/12",
180 baseBranch: "main",
181 headBranch: "feature/test",
180 baseRef: "main",
181 headRef: "feature/test",
182182 state: "open",
183183 },
184184 }),
......@@ -212,7 +212,7 @@ describe("when: branch is clean, ahead, and has an open PR", () => {
212212 });
213213});
214214
215describe("when: branch is clean, ahead, and has no open PR", () => {
215describe("when: ref is clean, ahead, and has no open PR", () => {
216216 it("resolveQuickAction pushes and creates a PR", () => {
217217 const quick = resolveQuickAction(status({ aheadCount: 2, pr: null }), false);
218218 assert.deepInclude(quick, {
......@@ -253,7 +253,33 @@ describe("when: branch is clean, ahead, and has no open PR", () => {
253253 });
254254});
255255
256describe("when: branch is clean, up to date, and has no open PR", () => {
256describe("when: source control provider uses merge requests", () => {
257 it("uses GitLab MR terminology in quick actions and menu items", () => {
258 const gitlabStatus = status({
259 aheadCount: 2,
260 sourceControlProvider: {
261 kind: "gitlab",
262 name: "GitLab",
263 baseUrl: "https://gitlab.com",
264 },
265 });
266
267 const quick = resolveQuickAction(gitlabStatus, false);
268 const items = buildMenuItems(gitlabStatus, false);
269
270 assert.deepInclude(quick, {
271 kind: "run_action",
272 action: "create_pr",
273 label: "Push & create MR",
274 });
275 assert.deepInclude(items[2], {
276 id: "pr",
277 label: "Create MR",
278 });
279 });
280});
281
282describe("when: ref is clean, up to date, and has no open PR", () => {
257283 it("resolveQuickAction returns disabled no-action state", () => {
258284 const quick = resolveQuickAction(
259285 status({ aheadCount: 0, behindCount: 0, hasWorkingTreeChanges: false, pr: null }),
......@@ -293,7 +319,7 @@ describe("when: branch is clean, up to date, and has no open PR", () => {
293319 });
294320});
295321
296describe("when: branch is behind upstream", () => {
322describe("when: ref is behind upstream", () => {
297323 it("resolveQuickAction returns pull", () => {
298324 const quick = resolveQuickAction(status({ behindCount: 2 }), false);
299325 assert.deepInclude(quick, { kind: "run_pull", label: "Pull", disabled: false });
......@@ -330,11 +356,11 @@ describe("when: branch is behind upstream", () => {
330356 });
331357});
332358
333describe("when: branch has diverged from upstream", () => {
359describe("when: ref has diverged from upstream", () => {
334360 it("resolveQuickAction returns a disabled sync hint", () => {
335361 const quick = resolveQuickAction(status({ aheadCount: 2, behindCount: 1 }), false);
336362 assert.deepEqual(quick, {
337 label: "Sync branch",
363 label: "Sync ref",
338364 disabled: true,
339365 kind: "show_hint",
340366 hint: "Branch has diverged from upstream. Rebase/merge first.",
......@@ -375,8 +401,8 @@ describe("when: working tree has local changes", () => {
375401 number: 16,
376402 title: "Existing PR",
377403 url: "https://example.com/pr/16",
378 baseBranch: "main",
379 headBranch: "feature/test",
404 baseRef: "main",
405 headRef: "feature/test",
380406 state: "open",
381407 },
382408 }),
......@@ -420,10 +446,10 @@ describe("when: working tree has local changes", () => {
420446 });
421447});
422448
423describe("when: on default branch without open PR", () => {
449describe("when: on default ref without open PR", () => {
424450 it("resolveQuickAction returns commit and push when local changes exist", () => {
425451 const quick = resolveQuickAction(
426 status({ branch: "main", hasWorkingTreeChanges: true }),
452 status({ refName: "main", hasWorkingTreeChanges: true }),
427453 false,
428454 true,
429455 );
......@@ -435,9 +461,9 @@ describe("when: on default branch without open PR", () => {
435461 });
436462 });
437463
438 it("resolveQuickAction returns push when branch is ahead", () => {
464 it("resolveQuickAction returns push when ref is ahead", () => {
439465 const quick = resolveQuickAction(
440 status({ branch: "main", aheadCount: 2, pr: null }),
466 status({ refName: "main", aheadCount: 2, pr: null }),
441467 false,
442468 true,
443469 );
......@@ -450,7 +476,7 @@ describe("when: on default branch without open PR", () => {
450476 });
451477});
452478
453describe("when: working tree has local changes and branch is behind upstream", () => {
479describe("when: working tree has local changes and ref is behind upstream", () => {
454480 it("resolveQuickAction still prefers commit, push, and create PR", () => {
455481 const quick = resolveQuickAction(
456482 status({ hasWorkingTreeChanges: true, behindCount: 1 }),
......@@ -497,14 +523,14 @@ describe("when: working tree has local changes and branch is behind upstream", (
497523describe("when: HEAD is detached and there are no local changes", () => {
498524 it("resolveQuickAction shows detached head hint", () => {
499525 const quick = resolveQuickAction(
500 status({ branch: null, hasWorkingTreeChanges: false, hasUpstream: false }),
526 status({ refName: null, hasWorkingTreeChanges: false, hasUpstream: false }),
501527 false,
502528 );
503529 assert.deepInclude(quick, { kind: "show_hint", label: "Commit", disabled: true });
504530 });
505531
506532 it("buildMenuItems keeps commit, push, and PR disabled", () => {
507 const items = buildMenuItems(status({ branch: null, hasWorkingTreeChanges: false }), false);
533 const items = buildMenuItems(status({ refName: null, hasWorkingTreeChanges: false }), false);
508534 assert.deepEqual(items, [
509535 {
510536 id: "commit",
......@@ -534,7 +560,7 @@ describe("when: HEAD is detached and there are no local changes", () => {
534560 });
535561});
536562
537describe("when: branch has no upstream configured", () => {
563describe("when: ref has no upstream configured", () => {
538564 it("resolveQuickAction is disabled when clean, no upstream, and no local commits are ahead", () => {
539565 const quick = resolveQuickAction(
540566 status({ hasUpstream: false, pr: null, aheadCount: 0 }),
......@@ -557,8 +583,8 @@ describe("when: branch has no upstream configured", () => {
557583 number: 14,
558584 title: "Existing PR",
559585 url: "https://example.com/pr/14",
560 baseBranch: "main",
561 headBranch: "feature/test",
586 baseRef: "main",
587 headRef: "feature/test",
562588 state: "open",
563589 },
564590 }),
......@@ -580,8 +606,8 @@ describe("when: branch has no upstream configured", () => {
580606 number: 15,
581607 title: "Existing PR",
582608 url: "https://example.com/pr/15",
583 baseBranch: "main",
584 headBranch: "feature/test",
609 baseRef: "main",
610 headRef: "feature/test",
585611 state: "open",
586612 },
587613 }),
......@@ -656,7 +682,7 @@ describe("when: branch has no upstream configured", () => {
656682 assert.deepEqual(quick, {
657683 kind: "show_hint",
658684 label: "Push",
659 hint: 'Add an "origin" remote before pushing or creating a PR.',
685 hint: 'Add an "origin" remote before pushing or creating a pull request.',
660686 disabled: true,
661687 });
662688 });
......@@ -725,10 +751,10 @@ describe("when: branch has no upstream configured", () => {
725751 ]);
726752 });
727753
728 it("resolveQuickAction is disabled on default branch when no upstream exists and no commits are ahead", () => {
754 it("resolveQuickAction is disabled on default ref when no upstream exists and no commits are ahead", () => {
729755 const quick = resolveQuickAction(
730756 status({
731 branch: "main",
757 refName: "main",
732758 hasUpstream: false,
733759 aheadCount: 0,
734760 pr: null,
......@@ -744,10 +770,10 @@ describe("when: branch has no upstream configured", () => {
744770 });
745771 });
746772
747 it("resolveQuickAction uses push-only on default branch when no upstream exists and commits are ahead", () => {
773 it("resolveQuickAction uses push-only on default ref when no upstream exists and commits are ahead", () => {
748774 const quick = resolveQuickAction(
749775 status({
750 branch: "main",
776 refName: "main",
751777 hasUpstream: false,
752778 aheadCount: 1,
753779 pr: null,
......@@ -763,7 +789,7 @@ describe("when: branch has no upstream configured", () => {
763789 });
764790 });
765791
766 it("buildMenuItems still disables push and create PR when branch is behind", () => {
792 it("buildMenuItems still disables push and create PR when ref is behind", () => {
767793 const items = buildMenuItems(
768794 status({
769795 hasUpstream: false,
......@@ -803,7 +829,7 @@ describe("when: branch has no upstream configured", () => {
803829});
804830
805831describe("requiresDefaultBranchConfirmation", () => {
806 it("requires confirmation for push actions on default branch", () => {
832 it("requires confirmation for push actions on default ref", () => {
807833 assert.isFalse(requiresDefaultBranchConfirmation("commit", true));
808834 assert.isTrue(requiresDefaultBranchConfirmation("push", true));
809835 assert.isTrue(requiresDefaultBranchConfirmation("create_pr", true));
......@@ -823,9 +849,9 @@ describe("resolveDefaultBranchActionDialogCopy", () => {
823849 });
824850
825851 assert.deepEqual(copy, {
826 title: "Push to default branch?",
852 title: "Push to default ref?",
827853 description:
828 'This action will push local commits on "main". You can continue on this branch or create a feature branch and run the same action there.',
854 'This action will push local commits on "main". You can continue on this ref or create a feature ref and run the same action there.',
829855 continueLabel: "Push to main",
830856 });
831857 });
......@@ -838,9 +864,9 @@ describe("resolveDefaultBranchActionDialogCopy", () => {
838864 });
839865
840866 assert.deepEqual(copy, {
841 title: "Push & create PR from default branch?",
867 title: "Push & create PR from default ref?",
842868 description:
843 'This action will push local commits and create a PR on "main". You can continue on this branch or create a feature branch and run the same action there.',
869 'This action will push local commits and create a pull request on "main". You can continue on this ref or create a feature ref and run the same action there.',
844870 continueLabel: "Push & create PR",
845871 });
846872 });
......@@ -853,9 +879,9 @@ describe("resolveDefaultBranchActionDialogCopy", () => {
853879 });
854880
855881 assert.deepEqual(copy, {
856 title: "Commit, push & create PR from default branch?",
882 title: "Commit, push & create PR from default ref?",
857883 description:
858 'This action will commit, push, and create a PR on "main". You can continue on this branch or create a feature branch and run the same action there.',
884 'This action will commit, push, and create a pull request on "main". You can continue on this ref or create a feature ref and run the same action there.',
859885 continueLabel: "Commit, push & create PR",
860886 });
861887 });
......@@ -884,7 +910,7 @@ describe("buildGitActionProgressStages", () => {
884910 "Pushing to origin/feature/test...",
885911 "Preparing PR...",
886912 "Generating PR content...",
887 "Creating GitHub pull request...",
913 "Creating pull request...",
888914 ]);
889915 });
890916
......@@ -898,7 +924,7 @@ describe("buildGitActionProgressStages", () => {
898924 assert.deepEqual(stages, [
899925 "Preparing PR...",
900926 "Generating PR content...",
901 "Creating GitHub pull request...",
927 "Creating pull request...",
902928 ]);
903929 });
904930
......@@ -928,7 +954,7 @@ describe("buildGitActionProgressStages", () => {
928954 "Pushing to origin/feature/test...",
929955 "Preparing PR...",
930956 "Generating PR content...",
931 "Creating GitHub pull request...",
957 "Creating pull request...",
932958 ]);
933959 });
934960});
......@@ -944,7 +970,7 @@ describe("resolveThreadBranchUpdate", () => {
944970 commit: {
945971 status: "created",
946972 commitSha: "89abcdef01234567",
947 subject: "feat: add branch sync",
973 subject: "feat: add ref sync",
948974 },
949975 push: { status: "pushed", branch: "feature/fix-toast-copy" },
950976 pr: { status: "skipped_not_requested" },
......@@ -968,7 +994,7 @@ describe("resolveThreadBranchUpdate", () => {
968994 commit: {
969995 status: "created",
970996 commitSha: "89abcdef01234567",
971 subject: "feat: add branch sync",
997 subject: "feat: add ref sync",
972998 },
973999 push: { status: "pushed", branch: "feature/fix-toast-copy" },
9741000 pr: { status: "skipped_not_requested" },
......@@ -985,8 +1011,8 @@ describe("resolveThreadBranchUpdate", () => {
9851011describe("resolveLiveThreadBranchUpdate", () => {
9861012 it("returns a branch update when live git status differs from stored thread metadata", () => {
9871013 const update = resolveLiveThreadBranchUpdate({
988 threadBranch: "feature/old-branch",
989 gitStatus: status({ branch: "effect-atom" }),
1014 threadBranch: "feature/old-ref",
1015 gitStatus: status({ refName: "effect-atom" }),
9901016 });
9911017
9921018 assert.deepEqual(update, {
......@@ -996,35 +1022,35 @@ describe("resolveLiveThreadBranchUpdate", () => {
9961022
9971023 it("returns null when live git status is unavailable", () => {
9981024 const update = resolveLiveThreadBranchUpdate({
999 threadBranch: "feature/old-branch",
1025 threadBranch: "feature/old-ref",
10001026 gitStatus: null,
10011027 });
10021028
10031029 assert.equal(update, null);
10041030 });
10051031
1006 it("returns null when the stored thread branch already matches git status", () => {
1032 it("returns null when the stored thread ref already matches git status", () => {
10071033 const update = resolveLiveThreadBranchUpdate({
10081034 threadBranch: "effect-atom",
1009 gitStatus: status({ branch: "effect-atom" }),
1035 gitStatus: status({ refName: "effect-atom" }),
10101036 });
10111037
10121038 assert.equal(update, null);
10131039 });
10141040
1015 it("returns null when git status is detached HEAD but the thread already has a branch", () => {
1041 it("returns null when git status is detached HEAD but the thread already has a ref", () => {
10161042 const update = resolveLiveThreadBranchUpdate({
10171043 threadBranch: "effect-atom",
1018 gitStatus: status({ branch: null }),
1044 gitStatus: status({ refName: null }),
10191045 });
10201046
10211047 assert.equal(update, null);
10221048 });
10231049
1024 it("does not regress a semantic thread branch back to a temporary worktree branch", () => {
1050 it("does not regress a semantic thread ref back to a temporary worktree ref", () => {
10251051 const update = resolveLiveThreadBranchUpdate({
10261052 threadBranch: "t3code/github-query-rate-limit",
1027 gitStatus: status({ branch: "t3code/bda76797" }),
1053 gitStatus: status({ refName: "t3code/bda76797" }),
10281054 });
10291055
10301056 assert.equal(update, null);
......@@ -1032,31 +1058,31 @@ describe("resolveLiveThreadBranchUpdate", () => {
10321058});
10331059
10341060describe("resolveAutoFeatureBranchName", () => {
1035 it("uses semantic preferred branch names when available", () => {
1036 const branch = resolveAutoFeatureBranchName(["main", "feature/other"], "fix toast copy");
1037 assert.equal(branch, "feature/fix-toast-copy");
1061 it("uses semantic preferred ref names when available", () => {
1062 const ref = resolveAutoFeatureBranchName(["main", "feature/other"], "fix toast copy");
1063 assert.equal(ref, "feature/fix-toast-copy");
10381064 });
10391065
1040 it("normalizes preferred names that already include a branch namespace", () => {
1041 const branch = resolveAutoFeatureBranchName(["main"], "feature/refine-toolbar-actions");
1042 assert.equal(branch, "feature/refine-toolbar-actions");
1066 it("normalizes preferred names that already include a ref namespace", () => {
1067 const ref = resolveAutoFeatureBranchName(["main"], "feature/refine-toolbar-actions");
1068 assert.equal(ref, "feature/refine-toolbar-actions");
10431069 });
10441070
1045 it("increments suffix when the preferred branch name already exists", () => {
1046 const branch = resolveAutoFeatureBranchName(
1071 it("increments suffix when the preferred ref name already exists", () => {
1072 const ref = resolveAutoFeatureBranchName(
10471073 ["main", "feature/fix-toast-copy", "feature/fix-toast-copy-2"],
10481074 "fix toast copy",
10491075 );
1050 assert.equal(branch, "feature/fix-toast-copy-3");
1076 assert.equal(ref, "feature/fix-toast-copy-3");
10511077 });
10521078
1053 it("treats existing branch names as case-insensitive for collision checks", () => {
1054 const branch = resolveAutoFeatureBranchName(["Feature/Ticket-1"], "feature/ticket-1");
1055 assert.equal(branch, "feature/ticket-1-2");
1079 it("treats existing ref names as case-insensitive for collision checks", () => {
1080 const ref = resolveAutoFeatureBranchName(["Feature/Ticket-1"], "feature/ticket-1");
1081 assert.equal(ref, "feature/ticket-1-2");
10561082 });
10571083
10581084 it("falls back to feature/update when no preferred name is provided", () => {
1059 const branch = resolveAutoFeatureBranchName(["main"]);
1060 assert.equal(branch, "feature/update");
1085 const ref = resolveAutoFeatureBranchName(["main"]);
1086 assert.equal(ref, "feature/update");
10611087 });
10621088});
apps/web/src/components/GitActionsControl.logic.ts+67-48
......@@ -1,9 +1,14 @@
11import type {
22 GitRunStackedActionResult,
33 GitStackedAction,
4 GitStatusResult,
4 VcsStatusResult,
55} from "@t3tools/contracts";
66import { isTemporaryWorktreeBranch } from "@t3tools/shared/git";
7import {
8 DEFAULT_CHANGE_REQUEST_TERMINOLOGY,
9 getChangeRequestTerminology,
10 type ChangeRequestTerminology,
11} from "../sourceControlPresentation";
712
813export type GitActionIconName = "commit" | "push" | "pr";
914
......@@ -38,6 +43,14 @@ export type DefaultBranchConfirmableAction =
3843 | "commit_push"
3944 | "commit_push_pr";
4045
46function resolveChangeRequestTerminology(
47 gitStatus: VcsStatusResult | null,
48): ChangeRequestTerminology {
49 return gitStatus?.sourceControlProvider
50 ? getChangeRequestTerminology(gitStatus.sourceControlProvider)
51 : DEFAULT_CHANGE_REQUEST_TERMINOLOGY;
52}
53
4154export function buildGitActionProgressStages(input: {
4255 action: GitStackedAction;
4356 hasCustomCommitMessage: boolean;
......@@ -45,13 +58,15 @@ export function buildGitActionProgressStages(input: {
4558 pushTarget?: string;
4659 featureBranch?: boolean;
4760 shouldPushBeforePr?: boolean;
61 terminology?: ChangeRequestTerminology;
4862}): string[] {
49 const branchStages = input.featureBranch ? ["Preparing feature branch..."] : [];
63 const terminology = input.terminology ?? DEFAULT_CHANGE_REQUEST_TERMINOLOGY;
64 const branchStages = input.featureBranch ? ["Preparing feature ref..."] : [];
5065 const pushStage = input.pushTarget ? `Pushing to ${input.pushTarget}...` : "Pushing...";
5166 const prStages = [
52 "Preparing PR...",
53 "Generating PR content...",
54 "Creating GitHub pull request...",
67 `Preparing ${terminology.shortLabel}...`,
68 `Generating ${terminology.shortLabel} content...`,
69 `Creating ${terminology.singular}...`,
5570 ];
5671
5772 if (input.action === "push") {
......@@ -77,17 +92,18 @@ export function buildGitActionProgressStages(input: {
7792}
7893
7994export function buildMenuItems(
80 gitStatus: GitStatusResult | null,
95 gitStatus: VcsStatusResult | null,
8196 isBusy: boolean,
82 hasOriginRemote = true,
97 hasPrimaryRemote = true,
8398): GitActionMenuItem[] {
8499 if (!gitStatus) return [];
100 const terminology = resolveChangeRequestTerminology(gitStatus);
85101
86 const hasBranch = gitStatus.branch !== null;
102 const hasBranch = gitStatus.refName !== null;
87103 const hasChanges = gitStatus.hasWorkingTreeChanges;
88104 const hasOpenPr = gitStatus.pr?.state === "open";
89105 const isBehind = gitStatus.behindCount > 0;
90 const canPushWithoutUpstream = hasOriginRemote && !gitStatus.hasUpstream;
106 const canPushWithoutUpstream = hasPrimaryRemote && !gitStatus.hasUpstream;
91107 const canCommit = !isBusy && hasChanges;
92108 const canPush =
93109 !isBusy &&
......@@ -126,14 +142,14 @@ export function buildMenuItems(
126142 hasOpenPr
127143 ? {
128144 id: "pr",
129 label: "View PR",
145 label: `View ${terminology.shortLabel}`,
130146 disabled: !canOpenPr,
131147 icon: "pr",
132148 kind: "open_pr",
133149 }
134150 : {
135151 id: "pr",
136 label: "Create PR",
152 label: `Create ${terminology.shortLabel}`,
137153 disabled: !canCreatePr,
138154 icon: "pr",
139155 kind: "open_dialog",
......@@ -143,10 +159,10 @@ export function buildMenuItems(
143159}
144160
145161export function resolveQuickAction(
146 gitStatus: GitStatusResult | null,
162 gitStatus: VcsStatusResult | null,
147163 isBusy: boolean,
148 isDefaultBranch = false,
149 hasOriginRemote = true,
164 isDefaultRef = false,
165 hasPrimaryRemote = true,
150166): GitQuickAction {
151167 if (isBusy) {
152168 return { label: "Commit", disabled: true, kind: "show_hint", hint: "Git action in progress." };
......@@ -161,31 +177,32 @@ export function resolveQuickAction(
161177 };
162178 }
163179
164 const hasBranch = gitStatus.branch !== null;
180 const hasBranch = gitStatus.refName !== null;
165181 const hasChanges = gitStatus.hasWorkingTreeChanges;
166182 const hasOpenPr = gitStatus.pr?.state === "open";
167183 const isAhead = gitStatus.aheadCount > 0;
168184 const isBehind = gitStatus.behindCount > 0;
169185 const isDiverged = isAhead && isBehind;
186 const terminology = resolveChangeRequestTerminology(gitStatus);
170187
171188 if (!hasBranch) {
172189 return {
173190 label: "Commit",
174191 disabled: true,
175192 kind: "show_hint",
176 hint: "Create and checkout a branch before pushing or opening a PR.",
193 hint: `Create and checkout a ref before pushing or opening a ${terminology.singular}.`,
177194 };
178195 }
179196
180197 if (hasChanges) {
181 if (!gitStatus.hasUpstream && !hasOriginRemote) {
198 if (!gitStatus.hasUpstream && !hasPrimaryRemote) {
182199 return { label: "Commit", disabled: false, kind: "run_action", action: "commit" };
183200 }
184 if (hasOpenPr || isDefaultBranch) {
201 if (hasOpenPr || isDefaultRef) {
185202 return { label: "Commit & push", disabled: false, kind: "run_action", action: "commit_push" };
186203 }
187204 return {
188 label: "Commit, push & PR",
205 label: `Commit, push & ${terminology.shortLabel}`,
189206 disabled: false,
190207 kind: "run_action",
191208 action: "commit_push_pr",
......@@ -193,20 +210,20 @@ export function resolveQuickAction(
193210 }
194211
195212 if (!gitStatus.hasUpstream) {
196 if (!hasOriginRemote) {
213 if (!hasPrimaryRemote) {
197214 if (hasOpenPr && !isAhead) {
198 return { label: "View PR", disabled: false, kind: "open_pr" };
215 return { label: `View ${terminology.shortLabel}`, disabled: false, kind: "open_pr" };
199216 }
200217 return {
201218 label: "Push",
202219 disabled: true,
203220 kind: "show_hint",
204 hint: 'Add an "origin" remote before pushing or creating a PR.',
221 hint: `Add an "origin" remote before pushing or creating a ${terminology.singular}.`,
205222 };
206223 }
207224 if (!isAhead) {
208225 if (hasOpenPr) {
209 return { label: "View PR", disabled: false, kind: "open_pr" };
226 return { label: `View ${terminology.shortLabel}`, disabled: false, kind: "open_pr" };
210227 }
211228 return {
212229 label: "Push",
......@@ -215,16 +232,16 @@ export function resolveQuickAction(
215232 hint: "No local commits to push.",
216233 };
217234 }
218 if (hasOpenPr || isDefaultBranch) {
235 if (hasOpenPr || isDefaultRef) {
219236 return {
220237 label: "Push",
221238 disabled: false,
222239 kind: "run_action",
223 action: isDefaultBranch ? "commit_push" : "push",
240 action: isDefaultRef ? "commit_push" : "push",
224241 };
225242 }
226243 return {
227 label: "Push & create PR",
244 label: `Push & create ${terminology.shortLabel}`,
228245 disabled: false,
229246 kind: "run_action",
230247 action: "create_pr",
......@@ -233,7 +250,7 @@ export function resolveQuickAction(
233250
234251 if (isDiverged) {
235252 return {
236 label: "Sync branch",
253 label: "Sync ref",
237254 disabled: true,
238255 kind: "show_hint",
239256 hint: "Branch has diverged from upstream. Rebase/merge first.",
......@@ -249,16 +266,16 @@ export function resolveQuickAction(
249266 }
250267
251268 if (isAhead) {
252 if (hasOpenPr || isDefaultBranch) {
269 if (hasOpenPr || isDefaultRef) {
253270 return {
254271 label: "Push",
255272 disabled: false,
256273 kind: "run_action",
257 action: isDefaultBranch ? "commit_push" : "push",
274 action: isDefaultRef ? "commit_push" : "push",
258275 };
259276 }
260277 return {
261 label: "Push & create PR",
278 label: `Push & create ${terminology.shortLabel}`,
262279 disabled: false,
263280 kind: "run_action",
264281 action: "create_pr",
......@@ -266,7 +283,7 @@ export function resolveQuickAction(
266283 }
267284
268285 if (hasOpenPr && gitStatus.hasUpstream) {
269 return { label: "View PR", disabled: false, kind: "open_pr" };
286 return { label: `View ${terminology.shortLabel}`, disabled: false, kind: "open_pr" };
270287 }
271288
272289 return {
......@@ -279,9 +296,9 @@ export function resolveQuickAction(
279296
280297export function requiresDefaultBranchConfirmation(
281298 action: GitStackedAction,
282 isDefaultBranch: boolean,
299 isDefaultRef: boolean,
283300): boolean {
284 if (!isDefaultBranch) return false;
301 if (!isDefaultRef) return false;
285302 return (
286303 action === "push" ||
287304 action === "create_pr" ||
......@@ -294,20 +311,22 @@ export function resolveDefaultBranchActionDialogCopy(input: {
294311 action: DefaultBranchConfirmableAction;
295312 branchName: string;
296313 includesCommit: boolean;
314 terminology?: ChangeRequestTerminology;
297315}): DefaultBranchActionDialogCopy {
298316 const branchLabel = input.branchName;
299 const suffix = ` on "${branchLabel}". You can continue on this branch or create a feature branch and run the same action there.`;
317 const suffix = ` on "${branchLabel}". You can continue on this ref or create a feature ref and run the same action there.`;
318 const terminology = input.terminology ?? DEFAULT_CHANGE_REQUEST_TERMINOLOGY;
300319
301320 if (input.action === "push" || input.action === "commit_push") {
302321 if (input.includesCommit) {
303322 return {
304 title: "Commit & push to default branch?",
323 title: "Commit & push to default ref?",
305324 description: `This action will commit and push changes${suffix}`,
306325 continueLabel: `Commit & push to ${branchLabel}`,
307326 };
308327 }
309328 return {
310 title: "Push to default branch?",
329 title: "Push to default ref?",
311330 description: `This action will push local commits${suffix}`,
312331 continueLabel: `Push to ${branchLabel}`,
313332 };
......@@ -315,15 +334,15 @@ export function resolveDefaultBranchActionDialogCopy(input: {
315334
316335 if (input.includesCommit) {
317336 return {
318 title: "Commit, push & create PR from default branch?",
319 description: `This action will commit, push, and create a PR${suffix}`,
320 continueLabel: `Commit, push & create PR`,
337 title: `Commit, push & create ${terminology.shortLabel} from default ref?`,
338 description: `This action will commit, push, and create a ${terminology.singular}${suffix}`,
339 continueLabel: `Commit, push & create ${terminology.shortLabel}`,
321340 };
322341 }
323342 return {
324 title: "Push & create PR from default branch?",
325 description: `This action will push local commits and create a PR${suffix}`,
326 continueLabel: "Push & create PR",
343 title: `Push & create ${terminology.shortLabel} from default ref?`,
344 description: `This action will push local commits and create a ${terminology.singular}${suffix}`,
345 continueLabel: `Push & create ${terminology.shortLabel}`,
327346 };
328347}
329348
......@@ -341,31 +360,31 @@ export function resolveThreadBranchUpdate(
341360
342361export function resolveLiveThreadBranchUpdate(input: {
343362 threadBranch: string | null;
344 gitStatus: GitStatusResult | null;
363 gitStatus: VcsStatusResult | null;
345364}): { branch: string | null } | null {
346365 if (!input.gitStatus) {
347366 return null;
348367 }
349368
350 if (input.gitStatus.branch === null && input.threadBranch !== null) {
369 if (input.gitStatus.refName === null && input.threadBranch !== null) {
351370 return null;
352371 }
353372
354 if (input.threadBranch === input.gitStatus.branch) {
373 if (input.threadBranch === input.gitStatus.refName) {
355374 return null;
356375 }
357376
358377 if (
359378 input.threadBranch !== null &&
360 input.gitStatus.branch !== null &&
379 input.gitStatus.refName !== null &&
361380 !isTemporaryWorktreeBranch(input.threadBranch) &&
362 isTemporaryWorktreeBranch(input.gitStatus.branch)
381 isTemporaryWorktreeBranch(input.gitStatus.refName)
363382 ) {
364383 return null;
365384 }
366385
367386 return {
368 branch: input.gitStatus.branch,
387 branch: input.gitStatus.refName,
369388 };
370389}
371390
apps/web/src/components/GitActionsControl.tsx+79-49
......@@ -3,12 +3,11 @@ import type {
33 GitActionProgressEvent,
44 GitRunStackedActionResult,
55 GitStackedAction,
6 GitStatusResult,
6 VcsStatusResult,
77} from "@t3tools/contracts";
88import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query";
99import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react";
1010import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react";
11import { GitHubIcon } from "./Icons";
1211import {
1312 buildGitActionProgressStages,
1413 buildMenuItems,
......@@ -52,6 +51,7 @@ import { resolvePathLinkTarget } from "~/terminal-links";
5251import { type DraftId, useComposerDraftStore } from "~/composerDraftStore";
5352import { readEnvironmentApi } from "~/environmentApi";
5453import { readLocalApi } from "~/localApi";
54import { getSourceControlPresentation } from "~/sourceControlPresentation";
5555import { useStore } from "~/store";
5656import { createThreadSelectorByRef } from "~/storeSelectors";
5757
......@@ -89,7 +89,7 @@ interface RunGitActionWithToastInput {
8989 commitMessage?: string;
9090 onConfirmed?: () => void;
9191 skipDefaultBranchPrompt?: boolean;
92 statusOverride?: GitStatusResult | null;
92 statusOverride?: VcsStatusResult | null;
9393 featureBranch?: boolean;
9494 progressToastId?: GitActionToastId;
9595 filePaths?: string[];
......@@ -121,22 +121,23 @@ function getMenuActionDisabledReason({
121121 item,
122122 gitStatus,
123123 isBusy,
124 hasOriginRemote,
124 hasPrimaryRemote,
125125}: {
126126 item: GitActionMenuItem;
127 gitStatus: GitStatusResult | null;
127 gitStatus: VcsStatusResult | null;
128128 isBusy: boolean;
129 hasOriginRemote: boolean;
129 hasPrimaryRemote: boolean;
130130}): string | null {
131131 if (!item.disabled) return null;
132132 if (isBusy) return "Git action in progress.";
133133 if (!gitStatus) return "Git status is unavailable.";
134134
135 const hasBranch = gitStatus.branch !== null;
135 const hasBranch = gitStatus.refName !== null;
136136 const hasChanges = gitStatus.hasWorkingTreeChanges;
137137 const hasOpenPr = gitStatus.pr?.state === "open";
138138 const isAhead = gitStatus.aheadCount > 0;
139139 const isBehind = gitStatus.behindCount > 0;
140 const terminology = getSourceControlPresentation(gitStatus.sourceControlProvider).terminology;
140141
141142 if (item.id === "commit") {
142143 if (!hasChanges) {
......@@ -147,7 +148,7 @@ function getMenuActionDisabledReason({
147148
148149 if (item.id === "push") {
149150 if (!hasBranch) {
150 return "Detached HEAD: checkout a branch before pushing.";
151 return "Detached HEAD: checkout a refName before pushing.";
151152 }
152153 if (hasChanges) {
153154 return "Commit or stash local changes before pushing.";
......@@ -155,7 +156,7 @@ function getMenuActionDisabledReason({
155156 if (isBehind) {
156157 return "Branch is behind upstream. Pull/rebase before pushing.";
157158 }
158 if (!gitStatus.hasUpstream && !hasOriginRemote) {
159 if (!gitStatus.hasUpstream && !hasPrimaryRemote) {
159160 return 'Add an "origin" remote before pushing.';
160161 }
161162 if (!isAhead) {
......@@ -165,46 +166,58 @@ function getMenuActionDisabledReason({
165166 }
166167
167168 if (hasOpenPr) {
168 return "View PR is currently unavailable.";
169 return `View ${terminology.singular} is currently unavailable.`;
169170 }
170171 if (!hasBranch) {
171 return "Detached HEAD: checkout a branch before creating a PR.";
172 return `Detached HEAD: checkout a refName before creating a ${terminology.singular}.`;
172173 }
173174 if (hasChanges) {
174 return "Commit local changes before creating a PR.";
175 return `Commit local changes before creating a ${terminology.singular}.`;
175176 }
176 if (!gitStatus.hasUpstream && !hasOriginRemote) {
177 return 'Add an "origin" remote before creating a PR.';
177 if (!gitStatus.hasUpstream && !hasPrimaryRemote) {
178 return `Add an "origin" remote before creating a ${terminology.singular}.`;
178179 }
179180 if (!isAhead) {
180 return "No local commits to include in a PR.";
181 return `No local commits to include in a ${terminology.singular}.`;
181182 }
182183 if (isBehind) {
183 return "Branch is behind upstream. Pull/rebase before creating a PR.";
184 return `Branch is behind upstream. Pull/rebase before creating a ${terminology.singular}.`;
184185 }
185 return "Create PR is currently unavailable.";
186 return `Create ${terminology.singular} is currently unavailable.`;
186187}
187188
188189const COMMIT_DIALOG_TITLE = "Commit changes";
189190const COMMIT_DIALOG_DESCRIPTION =
190191 "Review and confirm your commit. Leave the message blank to auto-generate one.";
191192
192function GitActionItemIcon({ icon }: { icon: GitActionIconName }) {
193function GitActionItemIcon({
194 icon,
195 SourceControlIcon,
196}: {
197 icon: GitActionIconName;
198 SourceControlIcon: ReturnType<typeof getSourceControlPresentation>["Icon"];
199}) {
193200 if (icon === "commit") return <GitCommitIcon />;
194201 if (icon === "push") return <CloudUploadIcon />;
195 return <GitHubIcon />;
202 return <SourceControlIcon />;
196203}
197204
198function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) {
205function GitQuickActionIcon({
206 quickAction,
207 SourceControlIcon,
208}: {
209 quickAction: GitQuickAction;
210 SourceControlIcon: ReturnType<typeof getSourceControlPresentation>["Icon"];
211}) {
199212 const iconClassName = "size-3.5";
200 if (quickAction.kind === "open_pr") return <GitHubIcon className={iconClassName} />;
213 if (quickAction.kind === "open_pr") return <SourceControlIcon className={iconClassName} />;
201214 if (quickAction.kind === "run_pull") return <InfoIcon className={iconClassName} />;
202215 if (quickAction.kind === "run_action") {
203216 if (quickAction.action === "commit") return <GitCommitIcon className={iconClassName} />;
204217 if (quickAction.action === "push" || quickAction.action === "commit_push") {
205218 return <CloudUploadIcon className={iconClassName} />;
206219 }
207 return <GitHubIcon className={iconClassName} />;
220 return <SourceControlIcon className={iconClassName} />;
208221 }
209222 if (quickAction.label === "Commit") return <GitCommitIcon className={iconClassName} />;
210223 return <InfoIcon className={iconClassName} />;
......@@ -322,9 +335,15 @@ export default function GitActionsControl({
322335 environmentId: activeEnvironmentId,
323336 cwd: gitCwd,
324337 });
338 const sourceControlPresentation = useMemo(
339 () => getSourceControlPresentation(gitStatus?.sourceControlProvider),
340 [gitStatus?.sourceControlProvider],
341 );
342 const changeRequestTerminology = sourceControlPresentation.terminology;
343 const SourceControlIcon = sourceControlPresentation.Icon;
325344 // Default to true while loading so we don't flash init controls.
326345 const isRepo = gitStatus?.isRepo ?? true;
327 const hasOriginRemote = gitStatus?.hasOriginRemote ?? false;
346 const hasPrimaryRemote = gitStatus?.hasPrimaryRemote ?? false;
328347 const gitStatusForActions = gitStatus;
329348
330349 const allFiles = gitStatusForActions?.workingTree.files ?? [];
......@@ -382,18 +401,18 @@ export default function GitActionsControl({
382401 persistThreadBranchSync,
383402 ]);
384403
385 const isDefaultBranch = useMemo(() => {
386 return gitStatusForActions?.isDefaultBranch ?? false;
387 }, [gitStatusForActions?.isDefaultBranch]);
404 const isDefaultRef = useMemo(() => {
405 return gitStatusForActions?.isDefaultRef ?? false;
406 }, [gitStatusForActions?.isDefaultRef]);
388407
389408 const gitActionMenuItems = useMemo(
390 () => buildMenuItems(gitStatusForActions, isGitActionRunning, hasOriginRemote),
391 [gitStatusForActions, hasOriginRemote, isGitActionRunning],
409 () => buildMenuItems(gitStatusForActions, isGitActionRunning, hasPrimaryRemote),
410 [gitStatusForActions, hasPrimaryRemote, isGitActionRunning],
392411 );
393412 const quickAction = useMemo(
394413 () =>
395 resolveQuickAction(gitStatusForActions, isGitActionRunning, isDefaultBranch, hasOriginRemote),
396 [gitStatusForActions, hasOriginRemote, isDefaultBranch, isGitActionRunning],
414 resolveQuickAction(gitStatusForActions, isGitActionRunning, isDefaultRef, hasPrimaryRemote),
415 [gitStatusForActions, hasPrimaryRemote, isDefaultRef, isGitActionRunning],
397416 );
398417 const quickActionDisabledReason = quickAction.disabled
399418 ? (quickAction.hint ?? "This action is currently unavailable.")
......@@ -403,6 +422,7 @@ export default function GitActionsControl({
403422 action: pendingDefaultBranchAction.action,
404423 branchName: pendingDefaultBranchAction.branchName,
405424 includesCommit: pendingDefaultBranchAction.includesCommit,
425 terminology: changeRequestTerminology,
406426 })
407427 : null;
408428
......@@ -468,7 +488,7 @@ export default function GitActionsControl({
468488 if (!prUrl) {
469489 toastManager.add({
470490 type: "error",
471 title: "No open PR found.",
491 title: "No open pull request found.",
472492 data: threadToastData,
473493 });
474494 return;
......@@ -477,7 +497,7 @@ export default function GitActionsControl({
477497 toastManager.add(
478498 stackedThreadToast({
479499 type: "error",
480 title: "Unable to open PR link",
500 title: "Unable to open pull request link",
481501 description: err instanceof Error ? err.message : "An error occurred.",
482502 ...(threadToastData !== undefined ? { data: threadToastData } : {}),
483503 }),
......@@ -497,8 +517,8 @@ export default function GitActionsControl({
497517 filePaths,
498518 }: RunGitActionWithToastInput) => {
499519 const actionStatus = statusOverride ?? gitStatusForActions;
500 const actionBranch = actionStatus?.branch ?? null;
501 const actionIsDefaultBranch = featureBranch ? false : isDefaultBranch;
520 const actionBranch = actionStatus?.refName ?? null;
521 const actionIsDefaultBranch = featureBranch ? false : isDefaultRef;
502522 const actionCanCommit =
503523 action === "commit" || action === "commit_push" || action === "commit_push_pr";
504524 const includesCommit =
......@@ -534,6 +554,7 @@ export default function GitActionsControl({
534554 hasCustomCommitMessage: !!commitMessage?.trim(),
535555 hasWorkingTreeChanges: !!actionStatus?.hasWorkingTreeChanges,
536556 featureBranch,
557 terminology: changeRequestTerminology,
537558 shouldPushBeforePr:
538559 action === "create_pr" &&
539560 (!actionStatus?.hasUpstream || (actionStatus?.aheadCount ?? 0) > 0),
......@@ -775,8 +796,8 @@ export default function GitActionsControl({
775796 title: result.status === "pulled" ? "Pulled" : "Already up to date",
776797 description:
777798 result.status === "pulled"
778 ? `Updated ${result.branch} from ${result.upstreamBranch ?? "upstream"}`
779 : `${result.branch} is already synchronized.`,
799 ? `Updated ${result.refName} from ${result.upstreamRef ?? "upstream"}`
800 : `${result.refName} is already synchronized.`,
780801 data: threadToastData,
781802 }),
782803 error: (err) => ({
......@@ -889,7 +910,10 @@ export default function GitActionsControl({
889910 />
890911 }
891912 >
892 <GitQuickActionIcon quickAction={quickAction} />
913 <GitQuickActionIcon
914 quickAction={quickAction}
915 SourceControlIcon={SourceControlIcon}
916 />
893917 <span className="sr-only @3xl/header-actions:not-sr-only @3xl/header-actions:ml-0.5">
894918 {quickAction.label}
895919 </span>
......@@ -905,7 +929,7 @@ export default function GitActionsControl({
905929 disabled={isGitActionRunning || quickAction.disabled}
906930 onClick={runQuickAction}
907931 >
908 <GitQuickActionIcon quickAction={quickAction} />
932 <GitQuickActionIcon quickAction={quickAction} SourceControlIcon={SourceControlIcon} />
909933 <span className="sr-only @3xl/header-actions:not-sr-only @3xl/header-actions:ml-0.5">
910934 {quickAction.label}
911935 </span>
......@@ -934,7 +958,7 @@ export default function GitActionsControl({
934958 item,
935959 gitStatus: gitStatusForActions,
936960 isBusy: isGitActionRunning,
937 hasOriginRemote,
961 hasPrimaryRemote,
938962 });
939963 if (item.disabled && disabledReason) {
940964 return (
......@@ -945,7 +969,10 @@ export default function GitActionsControl({
945969 render={<span className="block w-max cursor-not-allowed" />}
946970 >
947971 <MenuItem className="w-full" disabled>
948 <GitActionItemIcon icon={item.icon} />
972 <GitActionItemIcon
973 icon={item.icon}
974 SourceControlIcon={SourceControlIcon}
975 />
949976 {item.label}
950977 </MenuItem>
951978 </PopoverTrigger>
......@@ -964,18 +991,19 @@ export default function GitActionsControl({
964991 openDialogForMenuItem(item);
965992 }}
966993 >
967 <GitActionItemIcon icon={item.icon} />
994 <GitActionItemIcon icon={item.icon} SourceControlIcon={SourceControlIcon} />
968995 {item.label}
969996 </MenuItem>
970997 );
971998 })}
972 {gitStatusForActions?.branch === null && (
999 {gitStatusForActions?.refName === null && (
9731000 <p className="px-2 py-1.5 text-xs text-warning">
974 Detached HEAD: create and checkout a branch to enable push and PR actions.
1001 Detached HEAD: create and checkout a refName to enable push and pull request
1002 actions.
9751003 </p>
9761004 )}
9771005 {gitStatusForActions &&
978 gitStatusForActions.branch !== null &&
1006 gitStatusForActions.refName !== null &&
9791007 !gitStatusForActions.hasWorkingTreeChanges &&
9801008 gitStatusForActions.behindCount > 0 &&
9811009 gitStatusForActions.aheadCount === 0 && (
......@@ -1013,10 +1041,12 @@ export default function GitActionsControl({
10131041 <span className="text-muted-foreground">Branch</span>
10141042 <span className="flex items-center justify-between gap-2">
10151043 <span className="font-medium">
1016 {gitStatusForActions?.branch ?? "(detached HEAD)"}
1044 {gitStatusForActions?.refName ?? "(detached HEAD)"}
10171045 </span>
1018 {isDefaultBranch && (
1019 <span className="text-right text-warning text-xs">Warning: default branch</span>
1046 {isDefaultRef && (
1047 <span className="text-right text-warning text-xs">
1048 Warning: default refName
1049 </span>
10201050 )}
10211051 </span>
10221052 </div>
......@@ -1149,7 +1179,7 @@ export default function GitActionsControl({
11491179 disabled={noneSelected}
11501180 onClick={runDialogActionOnNewBranch}
11511181 >
1152 Commit on new branch
1182 Commit on new refName
11531183 </Button>
11541184 <Button size="sm" disabled={noneSelected} onClick={runDialogAction}>
11551185 Commit
......@@ -1169,7 +1199,7 @@ export default function GitActionsControl({
11691199 <DialogPopup className="max-w-xl">
11701200 <DialogHeader>
11711201 <DialogTitle>
1172 {pendingDefaultBranchActionCopy?.title ?? "Run action on default branch?"}
1202 {pendingDefaultBranchActionCopy?.title ?? "Run action on default refName?"}
11731203 </DialogTitle>
11741204 <DialogDescription>{pendingDefaultBranchActionCopy?.description}</DialogDescription>
11751205 </DialogHeader>
apps/web/src/components/Icons.tsx+25
......@@ -15,6 +15,31 @@ export const GitHubIcon: Icon = (props) => (
1515 </svg>
1616);
1717
18export const GitLabIcon: Icon = (props) => (
19 <svg {...props} viewBox="0 0 24 24" fill="currentColor">
20 <path d="M22.6 9.3 20.9 4c-.2-.7-1.2-.7-1.5-.1l-2 5.4H6.6l-2-5.4c-.2-.6-1.2-.6-1.5.1L1.4 9.3c-.2.6 0 1.2.5 1.6L12 18.7l10.1-7.8c.5-.4.7-1 .5-1.6Z" />
21 <path d="m12 18.7 4.1-9.4H7.9L12 18.7Z" opacity=".72" />
22 <path d="M7.9 9.3H1.5L12 18.7 7.9 9.3ZM16.1 9.3 12 18.7l10.5-9.4h-6.4Z" opacity=".88" />
23 </svg>
24);
25
26export const AzureDevOpsIcon: Icon = (props) => (
27 <svg {...props} viewBox="0 0 24 24" fill="currentColor">
28 <path d="M21 5.2v12.7L16 22l-8-2.8V22l-4.9-6.4 4.9.4V4.8L16 2l5 3.2ZM16 6.4l-5 1.4v8.4l5 1.4V6.4Z" />
29 <path d="m8 8.4-5 .8v5.6l5 .8V8.4Z" opacity=".72" />
30 </svg>
31);
32
33export const BitbucketIcon: Icon = (props) => (
34 <svg {...props} viewBox="0 0 24 24" fill="currentColor">
35 <path d="M2.4 3.5c-.4 0-.7.3-.6.8L4.3 20c.1.4.4.7.8.7h13.7c.4 0 .7-.3.8-.7l2.6-15.7c.1-.5-.2-.8-.7-.8H2.4Zm12.5 11.1H9.4L8.5 8.7h7.4l-1 5.9Z" />
36 <path
37 d="M21 8.7h-5.1l-1 5.9H9.4l-4.2 5c.2.6.5 1.1 1.2 1.1h12.4c.4 0 .7-.3.8-.7L21 8.7Z"
38 opacity=".72"
39 />
40 </svg>
41);
42
1843export const CursorIcon: Icon = ({ className, ...props }) => (
1944 <svg
2045 {...props}
apps/web/src/components/KeybindingsToast.browser.tsx+3-3
......@@ -256,13 +256,13 @@ function resolveWsRpc(tag: string): unknown {
256256 if (tag === WS_METHODS.serverGetConfig) {
257257 return fixture.serverConfig;
258258 }
259 if (tag === WS_METHODS.gitListBranches) {
259 if (tag === WS_METHODS.vcsListRefs) {
260260 return {
261261 isRepo: true,
262 hasOriginRemote: true,
262 hasPrimaryRemote: true,
263263 nextCursor: null,
264264 totalCount: 1,
265 branches: [{ name: "main", current: true, isDefault: true, worktreePath: null }],
265 refs: [{ name: "main", current: true, isDefault: true, worktreePath: null }],
266266 };
267267 }
268268 if (tag === WS_METHODS.projectsSearchEntries) {
apps/web/src/components/PullRequestThreadDialog.tsx+24-10
......@@ -7,8 +7,10 @@ import {
77 gitPreparePullRequestThreadMutationOptions,
88 gitResolvePullRequestQueryOptions,
99} from "~/lib/gitReactQuery";
10import { useGitStatus } from "~/lib/gitStatusState";
1011import { cn } from "~/lib/utils";
1112import { parsePullRequestReference } from "~/pullRequestReference";
13import { getSourceControlPresentation } from "~/sourceControlPresentation";
1214import { Button } from "./ui/button";
1315import {
1416 Dialog,
......@@ -51,6 +53,13 @@ export function PullRequestThreadDialog({
5153 { wait: 450 },
5254 (debouncerState) => ({ isPending: debouncerState.isPending }),
5355 );
56 const { data: gitStatus = null } = useGitStatus({ environmentId, cwd });
57 const sourceControlPresentation = useMemo(
58 () => getSourceControlPresentation(gitStatus?.sourceControlProvider),
59 [gitStatus?.sourceControlProvider],
60 );
61 const terminology = sourceControlPresentation.terminology;
62 const SourceControlIcon = sourceControlPresentation.Icon;
5463
5564 useEffect(() => {
5665 if (!open) return;
......@@ -161,20 +170,20 @@ export function PullRequestThreadDialog({
161170 const validationMessage = !referenceDirty
162171 ? null
163172 : reference.trim().length === 0
164 ? "Paste a GitHub pull request URL, `gh pr checkout 123`, or enter 123 / #123."
173 ? `Paste a ${terminology.singular} URL, checkout command, or enter 123 / #123.`
165174 : parsedReference === null
166 ? "Use a GitHub pull request URL, `gh pr checkout 123`, 123, or #123."
175 ? `Use a ${terminology.singular} URL, checkout command, 123, or #123.`
167176 : null;
168177 const errorMessage =
169178 validationMessage ??
170179 (resolvedPullRequest === null && resolvePullRequestQuery.isError
171180 ? resolvePullRequestQuery.error instanceof Error
172181 ? resolvePullRequestQuery.error.message
173 : "Failed to resolve pull request."
182 : `Failed to resolve ${terminology.singular}.`
174183 : preparePullRequestThreadMutation.error instanceof Error
175184 ? preparePullRequestThreadMutation.error.message
176185 : preparePullRequestThreadMutation.error
177 ? "Failed to prepare pull request thread."
186 ? `Failed to prepare ${terminology.singular} thread.`
178187 : null);
179188
180189 return (
......@@ -188,18 +197,23 @@ export function PullRequestThreadDialog({
188197 >
189198 <DialogPopup className="max-w-xl">
190199 <DialogHeader>
191 <DialogTitle>Checkout Pull Request</DialogTitle>
200 <DialogTitle className="flex items-center gap-2">
201 <SourceControlIcon className="size-4" />
202 Checkout {terminology.singular}
203 </DialogTitle>
192204 <DialogDescription>
193 Resolve a GitHub pull request, then create the draft thread in the main repo or in a
194 dedicated worktree.
205 Resolve a {sourceControlPresentation.providerName} {terminology.singular}, then create
206 the draft thread in the main repo or in a dedicated worktree.
195207 </DialogDescription>
196208 </DialogHeader>
197209 <DialogPanel className="space-y-4">
198210 <label className="grid gap-1.5">
199 <span className="text-xs font-medium text-foreground">Pull request</span>
211 <span className="text-xs font-medium text-foreground capitalize">
212 {terminology.singular}
213 </span>
200214 <Input
201215 ref={referenceInputRef}
202 placeholder="https://github.com/owner/repo/pull/42, gh pr checkout 42, or #42"
216 placeholder={`${terminology.shortLabel} URL, checkout command, or #42`}
203217 value={reference}
204218 onChange={(event) => {
205219 setReferenceDirty(true);
......@@ -237,7 +251,7 @@ export function PullRequestThreadDialog({
237251 {isResolving ? (
238252 <div className="flex items-center gap-2 text-muted-foreground text-xs">
239253 <Spinner className="size-3.5" />
240 Resolving pull request...
254 Resolving {terminology.singular}...
241255 </div>
242256 ) : null}
243257
apps/web/src/components/Sidebar.tsx+4-4
......@@ -3,7 +3,6 @@ import {
33 ArrowUpDownIcon,
44 ChevronRightIcon,
55 CloudIcon,
6 GitPullRequestIcon,
76 FolderPlusIcon,
87 SearchIcon,
98 SettingsIcon,
......@@ -12,6 +11,7 @@ import {
1211 TriangleAlertIcon,
1312} from "lucide-react";
1413import {
14 ChangeRequestStatusIcon,
1515 prStatusIndicator,
1616 resolveThreadPr,
1717 terminalStatusFromRunningIds,
......@@ -367,7 +367,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
367367 },
368368 });
369369 const pr = resolveThreadPr(thread.branch, gitStatus.data);
370 const prStatus = prStatusIndicator(pr);
370 const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider);
371371 const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds);
372372 const isConfirmingArchive = confirmingArchiveThreadKey === threadKey && !isThreadRunning;
373373 const threadMetaClassName = isConfirmingArchive
......@@ -558,7 +558,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP
558558 className={`inline-flex items-center justify-center ${prStatus.colorClass} cursor-pointer rounded-sm outline-hidden focus-visible:ring-1 focus-visible:ring-ring`}
559559 onClick={handlePrClick}
560560 >
561 <GitPullRequestIcon className="size-3" />
561 <ChangeRequestStatusIcon className="size-3" />
562562 </button>
563563 }
564564 />
......@@ -1002,7 +1002,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
10021002 toastManager.add(
10031003 stackedThreadToast({
10041004 type: "error",
1005 title: "Unable to open PR link",
1005 title: "Unable to open pull request link",
10061006 description: error instanceof Error ? error.message : "An error occurred.",
10071007 }),
10081008 );
apps/web/src/components/ThreadStatusIndicators.tsx+24-15
......@@ -1,5 +1,5 @@
11import { scopeProjectRef, scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime";
2import type { GitStatusResult } from "@t3tools/contracts";
2import type { VcsStatusResult } from "@t3tools/contracts";
33import { CloudIcon, GitPullRequestIcon, TerminalIcon } from "lucide-react";
44import { useMemo } from "react";
55import { usePrimaryEnvironmentId } from "../environments/primary";
......@@ -11,12 +11,13 @@ import { useGitStatus } from "../lib/gitStatusState";
1111import { type AppState, selectProjectByRef, useStore } from "../store";
1212import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore";
1313import { useUiStateStore } from "../uiStateStore";
14import { resolveChangeRequestPresentation } from "../sourceControlPresentation";
1415import { resolveThreadStatusPill, type ThreadStatusPill } from "./Sidebar.logic";
1516import type { SidebarThreadSummary } from "../types";
1617import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
1718
1819export interface PrStatusIndicator {
19 label: "PR open" | "PR closed" | "PR merged";
20 label: string;
2021 colorClass: string;
2122 tooltip: string;
2223 url: string;
......@@ -28,43 +29,51 @@ export interface TerminalStatusIndicator {
2829 pulse: boolean;
2930}
3031
31export type ThreadPr = GitStatusResult["pr"];
32export type ThreadPr = VcsStatusResult["pr"];
3233
33export function prStatusIndicator(pr: ThreadPr): PrStatusIndicator | null {
34export function prStatusIndicator(
35 pr: ThreadPr,
36 provider: VcsStatusResult["sourceControlProvider"] | null | undefined,
37): PrStatusIndicator | null {
3438 if (!pr) return null;
39 const presentation = resolveChangeRequestPresentation(provider);
3540
3641 if (pr.state === "open") {
3742 return {
38 label: "PR open",
43 label: `${presentation.shortName} open`,
3944 colorClass: "text-emerald-600 dark:text-emerald-300/90",
40 tooltip: `#${pr.number} PR open: ${pr.title}`,
45 tooltip: `#${pr.number} ${presentation.shortName} open: ${pr.title}`,
4146 url: pr.url,
4247 };
4348 }
4449 if (pr.state === "closed") {
4550 return {
46 label: "PR closed",
51 label: `${presentation.shortName} closed`,
4752 colorClass: "text-zinc-500 dark:text-zinc-400/80",
48 tooltip: `#${pr.number} PR closed: ${pr.title}`,
53 tooltip: `#${pr.number} ${presentation.shortName} closed: ${pr.title}`,
4954 url: pr.url,
5055 };
5156 }
5257 if (pr.state === "merged") {
5358 return {
54 label: "PR merged",
59 label: `${presentation.shortName} merged`,
5560 colorClass: "text-violet-600 dark:text-violet-300/90",
56 tooltip: `#${pr.number} PR merged: ${pr.title}`,
61 tooltip: `#${pr.number} ${presentation.shortName} merged: ${pr.title}`,
5762 url: pr.url,
5863 };
5964 }
6065 return null;
6166}
6267
68export function ChangeRequestStatusIcon({ className }: { className?: string }) {
69 return <GitPullRequestIcon className={className} />;
70}
71
6372export function resolveThreadPr(
6473 threadBranch: string | null,
65 gitStatus: GitStatusResult | null,
74 gitStatus: VcsStatusResult | null,
6675): ThreadPr | null {
67 if (threadBranch === null || gitStatus === null || gitStatus.branch !== threadBranch) {
76 if (threadBranch === null || gitStatus === null || gitStatus.refName !== threadBranch) {
6877 return null;
6978 }
7079
......@@ -124,7 +133,7 @@ export function ThreadStatusLabel({
124133
125134/**
126135 * Non-interactive leading status icons for a thread row in compact contexts
127 * like the command palette. Shows the PR state icon (if present) and the
136 * like the command palette. Shows the change request state icon (if present) and the
128137 * thread status dot, matching the sidebar's leading indicators.
129138 */
130139export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummary }) {
......@@ -146,7 +155,7 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar
146155 cwd: thread.branch != null ? gitCwd : null,
147156 });
148157 const pr = resolveThreadPr(thread.branch, gitStatus.data);
149 const prStatus = prStatusIndicator(pr);
158 const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider);
150159 const threadStatus = resolveThreadStatusPill({
151160 thread: {
152161 ...thread,
......@@ -170,7 +179,7 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar
170179 />
171180 }
172181 >
173 <GitPullRequestIcon className="size-3" />
182 <ChangeRequestStatusIcon className="size-3" />
174183 </TooltipTrigger>
175184 <TooltipPopup side="top">{prStatus.tooltip}</TooltipPopup>
176185 </Tooltip>
apps/web/src/components/settings/ProviderInstanceCard.tsx+8-44
......@@ -1,7 +1,7 @@
11"use client";
22
33import { ChevronDownIcon, PlusIcon, Trash2Icon, XIcon } from "lucide-react";
4import { useEffect, useMemo, useState, type ReactNode } from "react";
4import { useEffect, useState, type ReactNode } from "react";
55import {
66 isProviderDriverKind,
77 type ProviderInstanceConfig,
......@@ -24,6 +24,7 @@ import type { DriverOption } from "./providerDriverMeta";
2424import { ProviderSettingsForm } from "./ProviderSettingsForm";
2525import { ProviderModelsSection } from "./ProviderModelsSection";
2626import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon";
27import { RedactedSensitiveText } from "./RedactedSensitiveText";
2728import {
2829 PROVIDER_STATUS_STYLES,
2930 getProviderSummary,
......@@ -42,8 +43,6 @@ const PROVIDER_ACCENT_SWATCHES = [
4243
4344const ENVIRONMENT_VARIABLE_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
4445
45const REDACTED_EMAIL_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
46
4746let environmentVariableDraftId = 0;
4847const nextEnvironmentVariableDraftId = () => `provider-env-${environmentVariableDraftId++}`;
4948
......@@ -68,25 +67,6 @@ function makeEnvironmentDraftRow(
6867 };
6968}
7069
71function redactedEmailPlaceholder(email: string): string {
72 let state = 0x811c9dc5;
73 for (let index = 0; index < email.length; index += 1) {
74 state ^= email.charCodeAt(index);
75 state = Math.imul(state, 0x01000193);
76 }
77
78 const nextChar = () => {
79 state = Math.imul(state ^ (state >>> 13), 0x85ebca6b);
80 state = Math.imul(state ^ (state >>> 16), 0xc2b2ae35);
81 return REDACTED_EMAIL_ALPHABET[Math.abs(state) % REDACTED_EMAIL_ALPHABET.length] ?? "x";
82 };
83
84 return Array.from(email, (char) => {
85 if (char === "@" || char === "." || char === "-" || char === "_") return char;
86 return nextChar();
87 }).join("");
88}
89
9070/**
9171 * Read a string[] at `key` from the opaque config blob, filtering out
9272 * non-string entries. Used for `customModels`, which is always typed as
......@@ -146,35 +126,19 @@ function ProviderAuthEmail(props: {
146126 readonly prefix?: string;
147127 readonly separator?: boolean;
148128}) {
149 const [revealed, setRevealed] = useState(false);
150129 const trimmed = props.email?.trim();
151 const redacted = useMemo(() => (trimmed ? redactedEmailPlaceholder(trimmed) : ""), [trimmed]);
152130 if (!trimmed) return null;
153131
154132 return (
155133 <span className="inline-flex min-w-0 items-center gap-1.5">
156134 {props.separator ? <span aria-hidden>·</span> : null}
157135 {props.prefix ? <span className="text-muted-foreground/80">{props.prefix}</span> : null}
158 <Tooltip>
159 <TooltipTrigger
160 render={
161 <button
162 type="button"
163 className={cn(
164 "min-w-0 cursor-pointer rounded-sm font-mono text-[11px] leading-none transition hover:text-foreground",
165 revealed ? "text-muted-foreground" : "select-none text-muted-foreground blur-[2px]",
166 )}
167 onClick={() => setRevealed((value) => !value)}
168 aria-label={revealed ? "Hide account email" : "Reveal account email"}
169 >
170 {revealed ? trimmed : redacted}
171 </button>
172 }
173 />
174 <TooltipPopup side="top">
175 {revealed ? "Click to hide email" : "Click to reveal email"}
176 </TooltipPopup>
177 </Tooltip>
136 <RedactedSensitiveText
137 value={trimmed}
138 ariaLabel="Toggle account email visibility"
139 revealTooltip="Click to reveal email"
140 hideTooltip="Click to hide email"
141 />
178142 </span>
179143 );
180144}
apps/web/src/components/settings/RedactedSensitiveText.tsx created+61
......@@ -0,0 +1,61 @@
1import { useMemo, useState } from "react";
2
3import { cn } from "../../lib/utils";
4import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
5
6const REDACTED_TEXT_ALPHABET = "abcdefghjkmnpqrstuvwxyz23456789";
7
8function redactedPlaceholder(value: string): string {
9 let state = 0x811c9dc5;
10 for (let index = 0; index < value.length; index += 1) {
11 state ^= value.charCodeAt(index);
12 state = Math.imul(state, 0x01000193);
13 }
14
15 const nextChar = () => {
16 state = Math.imul(state ^ (state >>> 13), 0x85ebca6b);
17 state = Math.imul(state ^ (state >>> 16), 0xc2b2ae35);
18 return REDACTED_TEXT_ALPHABET[Math.abs(state) % REDACTED_TEXT_ALPHABET.length] ?? "x";
19 };
20
21 return Array.from(value, (char) => {
22 if (char === "@" || char === "." || char === "-" || char === "_") return char;
23 return nextChar();
24 }).join("");
25}
26
27export function RedactedSensitiveText(props: {
28 readonly value: string | null | undefined;
29 readonly ariaLabel: string;
30 readonly revealTooltip: string;
31 readonly hideTooltip: string;
32 readonly className?: string;
33}) {
34 const [revealed, setRevealed] = useState(false);
35 const value = props.value?.trim();
36 const redacted = useMemo(() => (value ? redactedPlaceholder(value) : ""), [value]);
37
38 if (!value) return null;
39
40 return (
41 <Tooltip>
42 <TooltipTrigger
43 render={
44 <button
45 type="button"
46 className={cn(
47 "min-w-0 cursor-pointer rounded-sm font-mono text-[11px] leading-none transition hover:text-foreground",
48 revealed ? "text-muted-foreground" : "select-none text-muted-foreground blur-[2px]",
49 props.className,
50 )}
51 onClick={() => setRevealed((current) => !current)}
52 aria-label={props.ariaLabel}
53 >
54 {revealed ? value : redacted}
55 </button>
56 }
57 />
58 <TooltipPopup side="top">{revealed ? props.hideTooltip : props.revealTooltip}</TooltipPopup>
59 </Tooltip>
60 );
61}
apps/web/src/components/settings/SettingsPanels.browser.tsx+149-2
......@@ -11,17 +11,19 @@ import {
1111 type DesktopUpdateState,
1212 type LocalApi,
1313 type ServerConfig,
14 type SourceControlDiscoveryResult,
1415} from "@t3tools/contracts";
15import { DateTime } from "effect";
16import { DateTime, Option } from "effect";
1617import { page } from "vitest/browser";
1718import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
1819import { render } from "vitest-browser-react";
1920
2021import { __resetLocalApiForTests } from "../../localApi";
21import { AppAtomRegistryProvider } from "../../rpc/atomRegistry";
22import { AppAtomRegistryProvider, resetAppAtomRegistryForTests } from "../../rpc/atomRegistry";
2223import { resetServerStateForTests, setServerConfigSnapshot } from "../../rpc/serverState";
2324import { ConnectionsSettings } from "./ConnectionsSettings";
2425import { GeneralSettingsPanel } from "./SettingsPanels";
26import { SourceControlSettingsPanel } from "./SourceControlSettings";
2527
2628const authAccessHarness = vi.hoisted(() => {
2729 type Snapshot = AuthAccessSnapshot;
......@@ -723,3 +725,148 @@ describe("GeneralSettingsPanel observability", () => {
723725 await expect.element(page.getByPlaceholder("Optional")).toBeInTheDocument();
724726 });
725727});
728
729describe("SourceControlSettingsPanel discovery states", () => {
730 let mounted:
731 | (Awaited<ReturnType<typeof render>> & {
732 cleanup?: () => Promise<void>;
733 unmount?: () => Promise<void>;
734 })
735 | null = null;
736
737 beforeEach(async () => {
738 resetAppAtomRegistryForTests();
739 await __resetLocalApiForTests();
740 document.body.innerHTML = "";
741 });
742
743 afterEach(async () => {
744 if (mounted) {
745 const teardown = mounted.cleanup ?? mounted.unmount;
746 await teardown?.call(mounted).catch(() => {});
747 }
748 mounted = null;
749 Reflect.deleteProperty(window, "nativeApi");
750 document.body.innerHTML = "";
751 await __resetLocalApiForTests();
752 resetAppAtomRegistryForTests();
753 });
754
755 function setSourceControlDiscoveryStub(
756 discoverSourceControl: () => Promise<SourceControlDiscoveryResult>,
757 ) {
758 window.nativeApi = {
759 server: {
760 discoverSourceControl,
761 },
762 } as LocalApi;
763 }
764
765 it("shows skeleton sections while the first source control scan is pending", async () => {
766 setSourceControlDiscoveryStub(() => new Promise(() => {}));
767
768 mounted = await render(
769 <AppAtomRegistryProvider>
770 <SourceControlSettingsPanel />
771 </AppAtomRegistryProvider>,
772 );
773
774 await expect.element(page.getByText("Version Control")).toBeInTheDocument();
775 await expect.element(page.getByText("Source Control Providers")).toBeInTheDocument();
776 await expect
777 .element(page.getByRole("button", { name: "Scan source control tools" }))
778 .toBeDisabled();
779 await expect.element(page.getByText("No source control tools found")).not.toBeInTheDocument();
780 });
781
782 it("uses the shared empty state when discovery completes without tools", async () => {
783 setSourceControlDiscoveryStub(async () => ({
784 versionControlSystems: [],
785 sourceControlProviders: [],
786 }));
787
788 mounted = await render(
789 <AppAtomRegistryProvider>
790 <SourceControlSettingsPanel />
791 </AppAtomRegistryProvider>,
792 );
793
794 await expect.element(page.getByText("No source control tools found")).toBeInTheDocument();
795 await expect
796 .element(page.getByText("Install a supported Git or pull request CLI, then scan again."))
797 .toBeInTheDocument();
798 await expect.element(page.getByRole("button", { name: "Scan" })).toBeInTheDocument();
799 });
800
801 it("keeps discovered rows instead of showing the empty state", async () => {
802 setSourceControlDiscoveryStub(async () => ({
803 versionControlSystems: [
804 {
805 kind: "git",
806 label: "Git",
807 executable: "git",
808 implemented: true,
809 status: "available",
810 version: Option.some("git version 2.50.0"),
811 installHint: "Install Git.",
812 detail: Option.none(),
813 },
814 ],
815 sourceControlProviders: [],
816 }));
817
818 mounted = await render(
819 <AppAtomRegistryProvider>
820 <SourceControlSettingsPanel />
821 </AppAtomRegistryProvider>,
822 );
823
824 await expect.element(page.getByRole("heading", { name: "Git" })).toBeInTheDocument();
825 await expect.element(page.getByText("No source control tools found")).not.toBeInTheDocument();
826 });
827
828 it("does not rescan on remount while the discovery atom is fresh", async () => {
829 let calls = 0;
830 setSourceControlDiscoveryStub(async () => {
831 calls += 1;
832 return {
833 versionControlSystems: [
834 {
835 kind: "git",
836 label: "Git",
837 executable: "git",
838 implemented: true,
839 status: "available",
840 version: Option.some("git version 2.50.0"),
841 installHint: "Install Git.",
842 detail: Option.none(),
843 },
844 ],
845 sourceControlProviders: [],
846 };
847 });
848
849 mounted = await render(
850 <AppAtomRegistryProvider>
851 <SourceControlSettingsPanel />
852 </AppAtomRegistryProvider>,
853 );
854
855 await expect.element(page.getByRole("heading", { name: "Git" })).toBeInTheDocument();
856 expect(calls).toBe(1);
857
858 const teardown = mounted.cleanup ?? mounted.unmount;
859 await teardown?.call(mounted).catch(() => {});
860 mounted = null;
861 document.body.innerHTML = "";
862
863 mounted = await render(
864 <AppAtomRegistryProvider>
865 <SourceControlSettingsPanel />
866 </AppAtomRegistryProvider>,
867 );
868
869 await expect.element(page.getByRole("heading", { name: "Git" })).toBeInTheDocument();
870 expect(calls).toBe(1);
871 });
872});
apps/web/src/components/settings/SettingsSidebarNav.tsx+3-1
......@@ -1,5 +1,5 @@
11import { useCallback, type ComponentType } from "react";
2import { ArchiveIcon, ArrowLeftIcon, Link2Icon, Settings2Icon } from "lucide-react";
2import { ArchiveIcon, ArrowLeftIcon, GitBranchIcon, Link2Icon, Settings2Icon } from "lucide-react";
33import { useCanGoBack, useNavigate } from "@tanstack/react-router";
44
55import {
......@@ -15,6 +15,7 @@ import {
1515
1616export type SettingsSectionPath =
1717 | "/settings/general"
18 | "/settings/source-control"
1819 | "/settings/connections"
1920 | "/settings/archived";
2021
......@@ -24,6 +25,7 @@ export const SETTINGS_NAV_ITEMS: ReadonlyArray<{
2425 icon: ComponentType<{ className?: string }>;
2526}> = [
2627 { label: "General", to: "/settings/general", icon: Settings2Icon },
28 { label: "Source Control", to: "/settings/source-control", icon: GitBranchIcon },
2729 { label: "Connections", to: "/settings/connections", icon: Link2Icon },
2830 { label: "Archive", to: "/settings/archived", icon: ArchiveIcon },
2931];
apps/web/src/components/settings/SourceControlSettings.tsx created+471
......@@ -0,0 +1,471 @@
1import { GitPullRequestIcon, RefreshCwIcon } from "lucide-react";
2import { Option } from "effect";
3import { type ReactNode, useId } from "react";
4import type {
5 SourceControlProviderKind,
6 SourceControlDiscoveryResult,
7 SourceControlProviderAuth,
8 SourceControlProviderDiscoveryItem,
9 VcsDiscoveryItem,
10} from "@t3tools/contracts";
11
12import { cn } from "../../lib/utils";
13import {
14 refreshSourceControlDiscovery,
15 useSourceControlDiscovery,
16} from "../../lib/sourceControlDiscoveryState";
17import { Badge } from "../ui/badge";
18import { Button } from "../ui/button";
19import {
20 Empty,
21 EmptyContent,
22 EmptyDescription,
23 EmptyHeader,
24 EmptyMedia,
25 EmptyTitle,
26} from "../ui/empty";
27import { Skeleton } from "../ui/skeleton";
28import { Switch } from "../ui/switch";
29import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
30import { GitHubIcon, type Icon } from "../Icons";
31import { RedactedSensitiveText } from "./RedactedSensitiveText";
32import { SettingsPageContainer, SettingsSection } from "./settingsLayout";
33
34const EMPTY_DISCOVERY_RESULT: SourceControlDiscoveryResult = {
35 versionControlSystems: [],
36 sourceControlProviders: [],
37};
38
39const GitLabIcon: Icon = (props) => (
40 <svg {...props} viewBox="0 0 32 32" fill="none">
41 <path
42 d="m31.46 12.78-.04-.12-4.35-11.35A1.14 1.14 0 0 0 25.94.6c-.24 0-.47.1-.66.24-.19.15-.33.36-.39.6l-2.94 9h-11.9l-2.94-9A1.14 1.14 0 0 0 6.07.58a1.15 1.15 0 0 0-1.14.72L.58 12.68l-.05.11a8.1 8.1 0 0 0 2.68 9.34l.02.01.04.03 6.63 4.97 3.28 2.48 2 1.52a1.35 1.35 0 0 0 1.62 0l2-1.52 3.28-2.48 6.67-5h.02a8.09 8.09 0 0 0 2.7-9.36Z"
43 fill="#E24329"
44 />
45 <path
46 d="m31.46 12.78-.04-.12a14.75 14.75 0 0 0-5.86 2.64l-9.55 7.24 6.09 4.6 6.67-5h.02a8.09 8.09 0 0 0 2.67-9.36Z"
47 fill="#FC6D26"
48 />
49 <path
50 d="m9.9 27.14 3.28 2.48 2 1.52a1.35 1.35 0 0 0 1.62 0l2-1.52 3.28-2.48-6.1-4.6-6.07 4.6Z"
51 fill="#FCA326"
52 />
53 <path
54 d="M6.44 15.3a14.71 14.71 0 0 0-5.86-2.63l-.05.12a8.1 8.1 0 0 0 2.68 9.34l.02.01.04.03 6.63 4.97 6.1-4.6-9.56-7.24Z"
55 fill="#FC6D26"
56 />
57 </svg>
58);
59
60const AzureDevOpsIcon: Icon = (props) => {
61 const id = useId().replaceAll(":", "");
62 const gradientA = `${id}-azure-a`;
63 const gradientB = `${id}-azure-b`;
64 const gradientC = `${id}-azure-c`;
65
66 return (
67 <svg {...props} viewBox="0 0 96 96">
68 <defs>
69 <linearGradient
70 id={gradientA}
71 x1="-1032.17"
72 x2="-1059.21"
73 y1="145.31"
74 y2="65.43"
75 gradientTransform="matrix(1 0 0 -1 1075 158)"
76 gradientUnits="userSpaceOnUse"
77 >
78 <stop offset="0" stopColor="#114a8b" />
79 <stop offset="1" stopColor="#0669bc" />
80 </linearGradient>
81 <linearGradient
82 id={gradientB}
83 x1="-1023.73"
84 x2="-1029.98"
85 y1="108.08"
86 y2="105.97"
87 gradientTransform="matrix(1 0 0 -1 1075 158)"
88 gradientUnits="userSpaceOnUse"
89 >
90 <stop offset="0" stopOpacity=".3" />
91 <stop offset=".07" stopOpacity=".2" />
92 <stop offset=".32" stopOpacity=".1" />
93 <stop offset=".62" stopOpacity=".05" />
94 <stop offset="1" stopOpacity="0" />
95 </linearGradient>
96 <linearGradient
97 id={gradientC}
98 x1="-1027.16"
99 x2="-997.48"
100 y1="147.64"
101 y2="68.56"
102 gradientTransform="matrix(1 0 0 -1 1075 158)"
103 gradientUnits="userSpaceOnUse"
104 >
105 <stop offset="0" stopColor="#3ccbf4" />
106 <stop offset="1" stopColor="#2892df" />
107 </linearGradient>
108 </defs>
109 <path
110 fill={`url(#${gradientA})`}
111 d="M33.34 6.54h26.04l-27.03 80.1a4.15 4.15 0 0 1-3.94 2.81H8.15a4.14 4.14 0 0 1-3.93-5.47L29.4 9.38a4.15 4.15 0 0 1 3.94-2.83z"
112 />
113 <path
114 fill="#0078d4"
115 d="M71.17 60.26H29.88a1.91 1.91 0 0 0-1.3 3.31l26.53 24.76a4.17 4.17 0 0 0 2.85 1.13h23.38z"
116 />
117 <path
118 fill={`url(#${gradientB})`}
119 d="M33.34 6.54a4.12 4.12 0 0 0-3.95 2.88L4.25 83.92a4.14 4.14 0 0 0 3.91 5.54h20.79a4.44 4.44 0 0 0 3.4-2.9l5.02-14.78 17.91 16.7a4.24 4.24 0 0 0 2.67.97h23.29L71.02 60.26H41.24L59.47 6.55z"
120 />
121 <path
122 fill={`url(#${gradientC})`}
123 d="M66.6 9.36a4.14 4.14 0 0 0-3.93-2.82H33.65a4.15 4.15 0 0 1 3.93 2.82l25.18 74.62a4.15 4.15 0 0 1-3.93 5.48h29.02a4.15 4.15 0 0 0 3.93-5.48z"
124 />
125 </svg>
126 );
127};
128
129const BitbucketIcon: Icon = (props) => {
130 const id = useId().replaceAll(":", "");
131 const gradientId = `${id}-bitbucket-a`;
132
133 return (
134 <svg {...props} viewBox="8.4 14.39 2481.29 2231.21">
135 <path fill="none" d="M989.97,1493.09h518.05l125.04-730.04H852.22L989.97,1493.09z" />
136 <path
137 fill="#2684FF"
138 d="M88.92,14.4C45.02,13.83,8.97,48.96,8.41,92.86c-0.06,4.61,0.28,9.22,1.02,13.77l337.48,2048.72 c8.68,51.75,53.26,89.8,105.74,90.24h1619.03c39.38,0.5,73.19-27.9,79.49-66.78l337.49-2071.78c7.03-43.34-22.41-84.17-65.75-91.2 c-4.55-0.74-9.15-1.08-13.76-1.02L88.92,14.4z M1509.99,1495.09H993.24l-139.92-731h781.89L1509.99,1495.09z"
139 />
140 <linearGradient
141 id={gradientId}
142 gradientUnits="userSpaceOnUse"
143 x1="945.1094"
144 y1="1524.8389"
145 x2="944.4923"
146 y2="1524.1893"
147 gradientTransform="matrix(1996.6343 0 0 -1480.3047 -1884485.625 2258195)"
148 >
149 <stop offset="0.18" stopColor="#0052CC" />
150 <stop offset="1" stopColor="#2684FF" />
151 </linearGradient>
152 <path
153 fill={`url(#${gradientId})`}
154 d="M2379.27,763.06h-745.5l-125.12,730.42H992.31l-609.67,723.67c19.32,16.71,43.96,26,69.5,26.21h1618.13 c39.35,0.51,73.14-27.88,79.44-66.72L2379.27,763.06z"
155 />
156 </svg>
157 );
158};
159
160const SOURCE_CONTROL_PROVIDER_ICONS: Partial<Record<SourceControlProviderKind, Icon>> = {
161 github: GitHubIcon,
162 gitlab: GitLabIcon,
163 "azure-devops": AzureDevOpsIcon,
164 bitbucket: BitbucketIcon,
165};
166
167function optionLabel(value: Option.Option<string>): string | null {
168 return Option.getOrNull(value);
169}
170
171function isProviderDiscoveryItem(
172 item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem,
173): item is SourceControlProviderDiscoveryItem {
174 return "auth" in item;
175}
176
177function authPresentation(auth: SourceControlProviderAuth): {
178 readonly label: string;
179 readonly badge: "warning" | null;
180} {
181 if (auth.status === "authenticated") {
182 return { label: "Signed in", badge: null };
183 }
184 if (auth.status === "unauthenticated") {
185 return { label: "Sign in", badge: "warning" };
186 }
187 return { label: "Sign in", badge: null };
188}
189
190function RedactedAccount(props: { readonly account: string | null }) {
191 return (
192 <RedactedSensitiveText
193 value={props.account}
194 ariaLabel="Toggle source control account visibility"
195 revealTooltip="Click to reveal account"
196 hideTooltip="Click to hide account"
197 />
198 );
199}
200
201function itemStatusDot(item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem): string {
202 if (!item.implemented) return "bg-muted-foreground/35";
203 if (item.status !== "available") return "bg-warning";
204 if (isProviderDiscoveryItem(item) && item.auth.status !== "authenticated") return "bg-warning";
205 return "bg-success";
206}
207
208function SourceControlItemMark({
209 item,
210}: {
211 readonly item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem;
212}) {
213 const dotClassName = itemStatusDot(item);
214 const Icon = isProviderDiscoveryItem(item) ? SOURCE_CONTROL_PROVIDER_ICONS[item.kind] : null;
215
216 if (!Icon) {
217 return <span className={cn("size-2 shrink-0 rounded-full", dotClassName)} aria-hidden />;
218 }
219
220 return (
221 <span className="relative inline-flex size-5 shrink-0 items-center justify-center">
222 <Icon className="size-4.5 text-foreground/80" aria-hidden />
223 <span
224 className={cn(
225 "pointer-events-none absolute -left-0.5 -top-0.5 size-2 rounded-full ring-2 ring-background",
226 dotClassName,
227 )}
228 aria-hidden
229 />
230 </span>
231 );
232}
233
234function itemSummary({
235 item,
236 auth,
237 authAccount,
238}: {
239 readonly item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem;
240 readonly auth: SourceControlProviderAuth | null;
241 readonly authAccount: string | null;
242}) {
243 if (!item.implemented) {
244 return <span>Support for {item.label} is coming soon.</span>;
245 }
246
247 if (item.status !== "available") {
248 return <span>Not found - {item.installHint}</span>;
249 }
250
251 if (auth) {
252 if (auth.status === "authenticated") {
253 return (
254 <>
255 <span>Authenticated</span>
256 {authAccount ? (
257 <>
258 <span aria-hidden>as</span>
259 <RedactedAccount account={authAccount} />
260 </>
261 ) : null}
262 </>
263 );
264 }
265 if (auth.status === "unauthenticated") {
266 return <span>Sign in with the {item.executable} CLI to enable pull request actions.</span>;
267 }
268 return (
269 <span>
270 Install and sign in with the {item.executable} CLI to enable pull request actions.
271 </span>
272 );
273 }
274
275 return <span>Available</span>;
276}
277
278function DiscoveryItemRow({
279 item,
280}: {
281 readonly item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem;
282}) {
283 const version = optionLabel(item.version);
284 const enabled = item.implemented && item.status === "available";
285 const auth = isProviderDiscoveryItem(item) ? item.auth : null;
286 const authStatus = auth ? authPresentation(auth) : null;
287 const authAccount = auth ? optionLabel(auth.account) : null;
288
289 return (
290 <div
291 className={cn(
292 "border-t border-border/60 first:border-t-0",
293 !item.implemented && "opacity-80",
294 )}
295 >
296 <div className="px-4 py-3.5 sm:px-5">
297 <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
298 <div className="min-w-0 flex-1 space-y-1">
299 <div className="flex min-w-0 flex-wrap items-center gap-2">
300 <SourceControlItemMark item={item} />
301 <h3 className="truncate text-[13px] font-semibold tracking-[-0.01em] text-foreground">
302 {item.label}
303 </h3>
304 {version ? <code className="text-xs text-muted-foreground">{version}</code> : null}
305 {!item.implemented ? (
306 <Badge variant="warning" size="sm">
307 Coming Soon
308 </Badge>
309 ) : null}
310 {authStatus?.badge ? (
311 <Badge variant={authStatus.badge} size="sm">
312 {authStatus.label}
313 </Badge>
314 ) : null}
315 </div>
316 <p className="flex min-w-0 flex-wrap items-center gap-x-1 text-xs text-muted-foreground">
317 {itemSummary({ item, auth, authAccount })}
318 </p>
319 </div>
320 <div className="flex w-full shrink-0 items-center gap-2 sm:w-auto sm:justify-end">
321 {item.implemented ? (
322 <Switch checked={enabled} disabled aria-label={`${item.label} availability`} />
323 ) : null}
324 </div>
325 </div>
326 </div>
327 </div>
328 );
329}
330
331function SourceControlSectionSkeleton({
332 title,
333 headerAction,
334}: {
335 readonly title: string;
336 readonly headerAction?: ReactNode;
337}) {
338 return (
339 <SettingsSection title={title} headerAction={headerAction}>
340 {Array.from({ length: 2 }, (_, index) => (
341 <div key={index} className="border-t border-border/60 px-4 py-3.5 first:border-t-0 sm:px-5">
342 <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
343 <div className="min-w-0 flex-1 space-y-2">
344 <div className="flex items-center gap-2">
345 <Skeleton className="size-2 rounded-full" />
346 <Skeleton className="h-4 w-28 rounded-full" />
347 <Skeleton className="h-5 w-14 rounded-full" />
348 </div>
349 <Skeleton className="h-3 w-full max-w-xs rounded-full" />
350 </div>
351 <div className="flex shrink-0 items-center gap-2">
352 <Skeleton className="size-7 rounded-md" />
353 <Skeleton className="h-5 w-9 rounded-full" />
354 </div>
355 </div>
356 </div>
357 ))}
358 </SettingsSection>
359 );
360}
361
362function EmptySourceControlDiscovery({
363 error,
364 isPending,
365 onScan,
366}: {
367 readonly error: string | null;
368 readonly isPending: boolean;
369 readonly onScan: () => void;
370}) {
371 const hasError = error !== null;
372
373 return (
374 <SettingsSection title="Detected tools">
375 <Empty className="min-h-88">
376 <EmptyMedia variant="icon">
377 <GitPullRequestIcon />
378 </EmptyMedia>
379 <EmptyHeader>
380 <EmptyTitle>
381 {hasError ? "Could not scan source control" : "No source control tools found"}
382 </EmptyTitle>
383 <EmptyDescription>
384 {hasError ? error : "Install a supported Git or pull request CLI, then scan again."}
385 </EmptyDescription>
386 </EmptyHeader>
387 <EmptyContent>
388 <Button
389 size="sm"
390 variant="outline"
391 className="h-8 gap-1.5 px-3 text-xs"
392 onClick={onScan}
393 disabled={isPending}
394 >
395 <RefreshCwIcon className={cn("size-3.5", isPending && "animate-spin")} />
396 Scan
397 </Button>
398 </EmptyContent>
399 </Empty>
400 </SettingsSection>
401 );
402}
403
404export function SourceControlSettingsPanel() {
405 const discovery = useSourceControlDiscovery();
406
407 const result = discovery.data ?? EMPTY_DISCOVERY_RESULT;
408 const hasDiscoveryItems =
409 result.versionControlSystems.length > 0 || result.sourceControlProviders.length > 0;
410 const isInitialScanPending = discovery.isPending && discovery.data === null;
411 const handleScan = () => {
412 void refreshSourceControlDiscovery();
413 };
414 const scanButton = (
415 <Tooltip>
416 <TooltipTrigger
417 render={
418 <Button
419 size="icon-xs"
420 variant="ghost"
421 className="size-5 rounded-sm p-0 text-muted-foreground hover:text-foreground"
422 onClick={handleScan}
423 disabled={discovery.isPending}
424 aria-label="Scan source control tools"
425 >
426 <RefreshCwIcon className={cn("size-3", discovery.isPending && "animate-spin")} />
427 </Button>
428 }
429 />
430 <TooltipPopup side="top">Scan source control tools</TooltipPopup>
431 </Tooltip>
432 );
433
434 return (
435 <SettingsPageContainer>
436 {isInitialScanPending ? (
437 <>
438 <SourceControlSectionSkeleton title="Version Control" headerAction={scanButton} />
439 <SourceControlSectionSkeleton title="Source Control Providers" />
440 </>
441 ) : hasDiscoveryItems ? (
442 <>
443 {result.versionControlSystems.length > 0 ? (
444 <SettingsSection title="Version Control" headerAction={scanButton}>
445 {result.versionControlSystems.map((item) => (
446 <DiscoveryItemRow key={`vcs:${item.kind}`} item={item} />
447 ))}
448 </SettingsSection>
449 ) : null}
450
451 {result.sourceControlProviders.length > 0 ? (
452 <SettingsSection
453 title="Source Control Providers"
454 headerAction={result.versionControlSystems.length === 0 ? scanButton : null}
455 >
456 {result.sourceControlProviders.map((item) => (
457 <DiscoveryItemRow key={`provider:${item.kind}`} item={item} />
458 ))}
459 </SettingsSection>
460 ) : null}
461 </>
462 ) : (
463 <EmptySourceControlDiscovery
464 error={discovery.error}
465 isPending={discovery.isPending}
466 onScan={handleScan}
467 />
468 )}
469 </SettingsPageContainer>
470 );
471}
apps/web/src/components/settings/settingsLayout.tsx+2-2
......@@ -28,13 +28,13 @@ export function SettingsSection({
2828}) {
2929 return (
3030 <section className="space-y-2.5">
31 <div className="flex items-center justify-between px-1">
31 <div className="flex h-5 items-center justify-between px-1">
3232 <h2 className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.08em] text-foreground/50">
3333 <span className="inline-block h-px w-3 bg-border" aria-hidden />
3434 {icon}
3535 {title}
3636 </h2>
37 {headerAction}
37 <div className="flex h-5 min-w-5 items-center justify-end">{headerAction}</div>
3838 </div>
3939 <div className="relative overflow-hidden rounded-2xl border bg-card text-card-foreground shadow-sm/4 not-dark:bg-clip-padding before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-2xl)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] dark:shadow-none dark:before:shadow-[0_-1px_--theme(--color-white/6%)]">
4040 {children}
apps/web/src/environmentApi.ts+11-9
......@@ -23,16 +23,18 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi {
2323 filesystem: {
2424 browse: rpcClient.filesystem.browse,
2525 },
26 vcs: {
27 pull: rpcClient.vcs.pull,
28 refreshStatus: rpcClient.vcs.refreshStatus,
29 onStatus: (input, callback, options) => rpcClient.vcs.onStatus(input, callback, options),
30 listRefs: rpcClient.vcs.listRefs,
31 createWorktree: rpcClient.vcs.createWorktree,
32 removeWorktree: rpcClient.vcs.removeWorktree,
33 createRef: rpcClient.vcs.createRef,
34 switchRef: rpcClient.vcs.switchRef,
35 init: rpcClient.vcs.init,
36 },
2637 git: {
27 pull: rpcClient.git.pull,
28 refreshStatus: rpcClient.git.refreshStatus,
29 onStatus: (input, callback, options) => rpcClient.git.onStatus(input, callback, options),
30 listBranches: rpcClient.git.listBranches,
31 createWorktree: rpcClient.git.createWorktree,
32 removeWorktree: rpcClient.git.removeWorktree,
33 createBranch: rpcClient.git.createBranch,
34 checkout: rpcClient.git.checkout,
35 init: rpcClient.git.init,
3638 resolvePullRequest: rpcClient.git.resolvePullRequest,
3739 preparePullRequestThread: rpcClient.git.preparePullRequestThread,
3840 },
apps/web/src/environments/runtime/connection.test.ts-9
......@@ -85,16 +85,7 @@ function createTestClient() {
8585 openInEditor: vi.fn(async () => undefined),
8686 },
8787 git: {
88 pull: vi.fn(async () => undefined),
89 refreshStatus: vi.fn(async () => undefined),
90 onStatus: vi.fn(() => () => undefined),
9188 runStackedAction: vi.fn(async () => ({}) as any),
92 listBranches: vi.fn(async () => []),
93 createWorktree: vi.fn(async () => undefined),
94 removeWorktree: vi.fn(async () => undefined),
95 createBranch: vi.fn(async () => undefined),
96 checkout: vi.fn(async () => undefined),
97 init: vi.fn(async () => undefined),
9889 resolvePullRequest: vi.fn(async () => undefined),
9990 preparePullRequestThread: vi.fn(async () => undefined),
10091 },
apps/web/src/hooks/useThreadActions.ts+1-1
......@@ -209,7 +209,7 @@ export function useThreadActions() {
209209 }
210210
211211 try {
212 await ensureEnvironmentApi(threadRef.environmentId).git.removeWorktree({
212 await ensureEnvironmentApi(threadRef.environmentId).vcs.removeWorktree({
213213 cwd: threadProject.cwd,
214214 path: orphanedWorktreePath,
215215 force: true,
apps/web/src/lib/gitReactQuery.test.ts+5-5
......@@ -11,7 +11,7 @@ vi.mock("../wsRpcClient", () => ({
1111}));
1212
1313import type { InfiniteData } from "@tanstack/react-query";
14import { EnvironmentId, type GitListBranchesResult } from "@t3tools/contracts";
14import { EnvironmentId, type VcsListRefsResult } from "@t3tools/contracts";
1515
1616import {
1717 gitBranchSearchInfiniteQueryOptions,
......@@ -22,15 +22,15 @@ import {
2222 invalidateGitQueries,
2323} from "./gitReactQuery";
2424
25const BRANCH_QUERY_RESULT: GitListBranchesResult = {
26 branches: [],
25const BRANCH_QUERY_RESULT: VcsListRefsResult = {
26 refs: [],
2727 isRepo: true,
28 hasOriginRemote: true,
28 hasPrimaryRemote: true,
2929 nextCursor: null,
3030 totalCount: 0,
3131};
3232
33const BRANCH_SEARCH_RESULT: InfiniteData<GitListBranchesResult, number> = {
33const BRANCH_SEARCH_RESULT: InfiniteData<VcsListRefsResult, number> = {
3434 pages: [BRANCH_QUERY_RESULT],
3535 pageParams: [0],
3636};
apps/web/src/lib/gitReactQuery.ts+38-20
......@@ -19,17 +19,17 @@ const GIT_BRANCHES_PAGE_SIZE = 100;
1919
2020export const gitQueryKeys = {
2121 all: ["git"] as const,
22 branches: (environmentId: EnvironmentId | null, cwd: string | null) =>
23 ["git", "branches", environmentId ?? null, cwd] as const,
22 refs: (environmentId: EnvironmentId | null, cwd: string | null) =>
23 ["git", "refs", environmentId ?? null, cwd] as const,
2424 branchSearch: (environmentId: EnvironmentId | null, cwd: string | null, query: string) =>
25 ["git", "branches", environmentId ?? null, cwd, "search", query] as const,
25 ["git", "refs", environmentId ?? null, cwd, "search", query] as const,
2626};
2727
2828export const gitMutationKeys = {
2929 init: (environmentId: EnvironmentId | null, cwd: string | null) =>
3030 ["git", "mutation", "init", environmentId ?? null, cwd] as const,
31 checkout: (environmentId: EnvironmentId | null, cwd: string | null) =>
32 ["git", "mutation", "checkout", environmentId ?? null, cwd] as const,
31 switchRef: (environmentId: EnvironmentId | null, cwd: string | null) =>
32 ["git", "mutation", "switchRef", environmentId ?? null, cwd] as const,
3333 runStackedAction: (environmentId: EnvironmentId | null, cwd: string | null) =>
3434 ["git", "mutation", "run-stacked-action", environmentId ?? null, cwd] as const,
3535 pull: (environmentId: EnvironmentId | null, cwd: string | null) =>
......@@ -45,7 +45,7 @@ export function invalidateGitQueries(
4545 const environmentId = input?.environmentId ?? null;
4646 const cwd = input?.cwd ?? null;
4747 if (cwd !== null) {
48 return queryClient.invalidateQueries({ queryKey: gitQueryKeys.branches(environmentId, cwd) });
48 return queryClient.invalidateQueries({ queryKey: gitQueryKeys.refs(environmentId, cwd) });
4949 }
5050
5151 return queryClient.invalidateQueries({ queryKey: gitQueryKeys.all });
......@@ -60,9 +60,12 @@ function invalidateGitBranchQueries(
6060 return Promise.resolve();
6161 }
6262
63 return queryClient.invalidateQueries({ queryKey: gitQueryKeys.branches(environmentId, cwd) });
63 return queryClient.invalidateQueries({ queryKey: gitQueryKeys.refs(environmentId, cwd) });
6464}
6565
66/**
67 * @deprecated Use a VCS-named query helper once the UI naming migration lands.
68 */
6669export function gitBranchSearchInfiniteQueryOptions(input: {
6770 environmentId: EnvironmentId | null;
6871 cwd: string | null;
......@@ -75,10 +78,10 @@ export function gitBranchSearchInfiniteQueryOptions(input: {
7578 queryKey: gitQueryKeys.branchSearch(input.environmentId, input.cwd, normalizedQuery),
7679 initialPageParam: 0,
7780 queryFn: async ({ pageParam }) => {
78 if (!input.cwd) throw new Error("Git branches are unavailable.");
79 if (!input.environmentId) throw new Error("Git branches are unavailable.");
81 if (!input.cwd) throw new Error("Git refs are unavailable.");
82 if (!input.environmentId) throw new Error("Git refs are unavailable.");
8083 const api = ensureEnvironmentApi(input.environmentId);
81 return api.git.listBranches({
84 return api.vcs.listRefs({
8285 cwd: input.cwd,
8386 ...(normalizedQuery.length > 0 ? { query: normalizedQuery } : {}),
8487 cursor: pageParam,
......@@ -121,6 +124,9 @@ export function gitResolvePullRequestQueryOptions(input: {
121124 });
122125}
123126
127/**
128 * @deprecated Use a VCS-named mutation helper once the UI naming migration lands.
129 */
124130export function gitInitMutationOptions(input: {
125131 environmentId: EnvironmentId | null;
126132 cwd: string | null;
......@@ -131,7 +137,7 @@ export function gitInitMutationOptions(input: {
131137 mutationFn: async () => {
132138 if (!input.cwd || !input.environmentId) throw new Error("Git init is unavailable.");
133139 const api = ensureEnvironmentApi(input.environmentId);
134 return api.git.init({ cwd: input.cwd });
140 return api.vcs.init({ cwd: input.cwd });
135141 },
136142 onSettled: async () => {
137143 await invalidateGitBranchQueries(input.queryClient, input.environmentId, input.cwd);
......@@ -139,17 +145,20 @@ export function gitInitMutationOptions(input: {
139145 });
140146}
141147
148/**
149 * @deprecated Use a VCS-named mutation helper once the UI naming migration lands.
150 */
142151export function gitCheckoutMutationOptions(input: {
143152 environmentId: EnvironmentId | null;
144153 cwd: string | null;
145154 queryClient: QueryClient;
146155}) {
147156 return mutationOptions({
148 mutationKey: gitMutationKeys.checkout(input.environmentId, input.cwd),
149 mutationFn: async (branch: string) => {
150 if (!input.cwd || !input.environmentId) throw new Error("Git checkout is unavailable.");
157 mutationKey: gitMutationKeys.switchRef(input.environmentId, input.cwd),
158 mutationFn: async (refName: string) => {
159 if (!input.cwd || !input.environmentId) throw new Error("Git switchRef is unavailable.");
151160 const api = ensureEnvironmentApi(input.environmentId);
152 return api.git.checkout({ cwd: input.cwd, branch });
161 return api.vcs.switchRef({ cwd: input.cwd, refName });
153162 },
154163 onSettled: async () => {
155164 await invalidateGitBranchQueries(input.queryClient, input.environmentId, input.cwd);
......@@ -198,6 +207,9 @@ export function gitRunStackedActionMutationOptions(input: {
198207 });
199208}
200209
210/**
211 * @deprecated Use a VCS-named mutation helper once the UI naming migration lands.
212 */
201213export function gitPullMutationOptions(input: {
202214 environmentId: EnvironmentId | null;
203215 cwd: string | null;
......@@ -208,7 +220,7 @@ export function gitPullMutationOptions(input: {
208220 mutationFn: async () => {
209221 if (!input.cwd || !input.environmentId) throw new Error("Git pull is unavailable.");
210222 const api = ensureEnvironmentApi(input.environmentId);
211 return api.git.pull({ cwd: input.cwd });
223 return api.vcs.pull({ cwd: input.cwd });
212224 },
213225 onSuccess: async () => {
214226 await invalidateGitBranchQueries(input.queryClient, input.environmentId, input.cwd);
......@@ -216,6 +228,9 @@ export function gitPullMutationOptions(input: {
216228 });
217229}
218230
231/**
232 * @deprecated Use a VCS-named mutation helper once the UI naming migration lands.
233 */
219234export function gitCreateWorktreeMutationOptions(input: {
220235 environmentId: EnvironmentId | null;
221236 queryClient: QueryClient;
......@@ -223,12 +238,12 @@ export function gitCreateWorktreeMutationOptions(input: {
223238 return mutationOptions({
224239 mutationKey: ["git", "mutation", "create-worktree", input.environmentId ?? null] as const,
225240 mutationFn: (
226 args: Parameters<ReturnType<typeof ensureEnvironmentApi>["git"]["createWorktree"]>[0],
241 args: Parameters<ReturnType<typeof ensureEnvironmentApi>["vcs"]["createWorktree"]>[0],
227242 ) => {
228243 if (!input.environmentId) {
229244 throw new Error("Worktree creation is unavailable.");
230245 }
231 return ensureEnvironmentApi(input.environmentId).git.createWorktree(args);
246 return ensureEnvironmentApi(input.environmentId).vcs.createWorktree(args);
232247 },
233248 onSuccess: async () => {
234249 await invalidateGitQueries(input.queryClient, { environmentId: input.environmentId });
......@@ -236,6 +251,9 @@ export function gitCreateWorktreeMutationOptions(input: {
236251 });
237252}
238253
254/**
255 * @deprecated Use a VCS-named mutation helper once the UI naming migration lands.
256 */
239257export function gitRemoveWorktreeMutationOptions(input: {
240258 environmentId: EnvironmentId | null;
241259 queryClient: QueryClient;
......@@ -243,12 +261,12 @@ export function gitRemoveWorktreeMutationOptions(input: {
243261 return mutationOptions({
244262 mutationKey: ["git", "mutation", "remove-worktree", input.environmentId ?? null] as const,
245263 mutationFn: (
246 args: Parameters<ReturnType<typeof ensureEnvironmentApi>["git"]["removeWorktree"]>[0],
264 args: Parameters<ReturnType<typeof ensureEnvironmentApi>["vcs"]["removeWorktree"]>[0],
247265 ) => {
248266 if (!input.environmentId) {
249267 throw new Error("Worktree removal is unavailable.");
250268 }
251 return ensureEnvironmentApi(input.environmentId).git.removeWorktree(args);
269 return ensureEnvironmentApi(input.environmentId).vcs.removeWorktree(args);
252270 },
253271 onSuccess: async () => {
254272 await invalidateGitQueries(input.queryClient, { environmentId: input.environmentId });
apps/web/src/lib/gitStatusState.test.ts+31-29
......@@ -1,4 +1,4 @@
1import { EnvironmentId, type GitStatusResult } from "@t3tools/contracts";
1import { EnvironmentId, type VcsStatusResult } from "@t3tools/contracts";
22import { afterEach, describe, expect, it, vi } from "vitest";
33
44import type { WsRpcClient } from "../rpc/wsRpcClient";
......@@ -33,17 +33,17 @@ function registerListener<T>(listeners: Set<(event: T) => void>, listener: (even
3333 };
3434}
3535
36const gitStatusListeners = new Set<(event: GitStatusResult) => void>();
36const gitStatusListeners = new Set<(event: VcsStatusResult) => void>();
3737const ENVIRONMENT_ID = EnvironmentId.make("environment-local");
3838const OTHER_ENVIRONMENT_ID = EnvironmentId.make("environment-remote");
3939const TARGET = { environmentId: ENVIRONMENT_ID, cwd: "/repo" } as const;
4040const FRESH_TARGET = { environmentId: ENVIRONMENT_ID, cwd: "/fresh" } as const;
4141
42const BASE_STATUS: GitStatusResult = {
42const BASE_STATUS: VcsStatusResult = {
4343 isRepo: true,
44 hasOriginRemote: true,
45 isDefaultBranch: false,
46 branch: "feature/push-status",
44 hasPrimaryRemote: true,
45 isDefaultRef: false,
46 refName: "feature/push-status",
4747 hasWorkingTreeChanges: false,
4848 workingTree: { files: [], insertions: 0, deletions: 0 },
4949 hasUpstream: true,
......@@ -55,21 +55,21 @@ const BASE_STATUS: GitStatusResult = {
5555const gitClient = {
5656 refreshStatus: vi.fn(async (input: { cwd: string }) => ({
5757 ...BASE_STATUS,
58 branch: `${input.cwd}-refreshed`,
58 refName: `${input.cwd}-refreshed`,
5959 })),
60 onStatus: vi.fn((input: { cwd: string }, listener: (event: GitStatusResult) => void) =>
60 onStatus: vi.fn((input: { cwd: string }, listener: (event: VcsStatusResult) => void) =>
6161 registerListener(gitStatusListeners, listener),
6262 ),
6363};
6464
65function emitGitStatus(event: GitStatusResult) {
65function emitGitStatus(event: VcsStatusResult) {
6666 for (const listener of gitStatusListeners) {
6767 listener(event);
6868 }
6969}
7070
7171function createRegisteredGitStatusClient(environmentId: EnvironmentId) {
72 const listeners = new Set<(event: GitStatusResult) => void>();
72 const listeners = new Set<(event: VcsStatusResult) => void>();
7373 const client = {
7474 dispose: vi.fn(async () => undefined),
7575 reconnect: vi.fn(async () => undefined),
......@@ -89,22 +89,24 @@ function createRegisteredGitStatusClient(environmentId: EnvironmentId) {
8989 shell: {
9090 openInEditor: vi.fn(async () => undefined),
9191 },
92 git: {
92 vcs: {
9393 pull: vi.fn(async () => undefined),
9494 refreshStatus: vi.fn(async (input: { cwd: string }) => ({
9595 ...BASE_STATUS,
96 branch: `${input.cwd}-refreshed`,
96 refName: `${input.cwd}-refreshed`,
9797 })),
98 onStatus: vi.fn((_: { cwd: string }, listener: (event: GitStatusResult) => void) =>
98 onStatus: vi.fn((_: { cwd: string }, listener: (event: VcsStatusResult) => void) =>
9999 registerListener(listeners, listener),
100100 ),
101 runStackedAction: vi.fn(async () => ({}) as any),
102 listBranches: vi.fn(async () => []),
101 listRefs: vi.fn(async () => []),
103102 createWorktree: vi.fn(async () => undefined),
104103 removeWorktree: vi.fn(async () => undefined),
105 createBranch: vi.fn(async () => undefined),
106 checkout: vi.fn(async () => undefined),
104 createRef: vi.fn(async () => undefined),
105 switchRef: vi.fn(async () => undefined),
107106 init: vi.fn(async () => undefined),
107 },
108 git: {
109 runStackedAction: vi.fn(async () => ({}) as any),
108110 resolvePullRequest: vi.fn(async () => undefined),
109111 preparePullRequestThread: vi.fn(async () => undefined),
110112 },
......@@ -155,7 +157,7 @@ function createRegisteredGitStatusClient(environmentId: EnvironmentId) {
155157
156158 return {
157159 client,
158 emit: (event: GitStatusResult) => {
160 emit: (event: VcsStatusResult) => {
159161 for (const listener of listeners) {
160162 listener(event);
161163 }
......@@ -219,7 +221,7 @@ describe("gitStatusState", () => {
219221
220222 expect(gitClient.onStatus).toHaveBeenCalledOnce();
221223 expect(gitClient.refreshStatus).toHaveBeenCalledWith({ cwd: "/repo" });
222 expect(refreshed).toEqual({ ...BASE_STATUS, branch: "/repo-refreshed" });
224 expect(refreshed).toEqual({ ...BASE_STATUS, refName: "/repo-refreshed" });
223225 expect(getGitStatusSnapshot(TARGET)).toEqual({
224226 data: BASE_STATUS,
225227 error: null,
......@@ -231,17 +233,17 @@ describe("gitStatusState", () => {
231233 });
232234
233235 it("keeps git status subscriptions isolated by environment when cwds match", () => {
234 const localListeners = new Set<(event: GitStatusResult) => void>();
235 const remoteListeners = new Set<(event: GitStatusResult) => void>();
236 const localListeners = new Set<(event: VcsStatusResult) => void>();
237 const remoteListeners = new Set<(event: VcsStatusResult) => void>();
236238 const localClient = {
237239 refreshStatus: vi.fn(),
238 onStatus: vi.fn((_: { cwd: string }, listener: (event: GitStatusResult) => void) =>
240 onStatus: vi.fn((_: { cwd: string }, listener: (event: VcsStatusResult) => void) =>
239241 registerListener(localListeners, listener),
240242 ),
241243 };
242244 const remoteClient = {
243245 refreshStatus: vi.fn(),
244 onStatus: vi.fn((_: { cwd: string }, listener: (event: GitStatusResult) => void) =>
246 onStatus: vi.fn((_: { cwd: string }, listener: (event: VcsStatusResult) => void) =>
245247 registerListener(remoteListeners, listener),
246248 ),
247249 };
......@@ -254,11 +256,11 @@ describe("gitStatusState", () => {
254256 listener(BASE_STATUS);
255257 }
256258 for (const listener of remoteListeners) {
257 listener({ ...BASE_STATUS, branch: "remote-branch" });
259 listener({ ...BASE_STATUS, refName: "remote-refName" });
258260 }
259261
260 expect(getGitStatusSnapshot(TARGET).data?.branch).toBe("feature/push-status");
261 expect(getGitStatusSnapshot(remoteTarget).data?.branch).toBe("remote-branch");
262 expect(getGitStatusSnapshot(TARGET).data?.refName).toBe("feature/push-status");
263 expect(getGitStatusSnapshot(remoteTarget).data?.refName).toBe("remote-refName");
262264
263265 releaseLocal();
264266 releaseRemote();
......@@ -292,7 +294,7 @@ describe("gitStatusState", () => {
292294 const release = watchGitStatus(TARGET);
293295
294296 firstClient.emit(BASE_STATUS);
295 expect(getGitStatusSnapshot(TARGET).data?.branch).toBe("feature/push-status");
297 expect(getGitStatusSnapshot(TARGET).data?.refName).toBe("feature/push-status");
296298
297299 serviceHarness.connections.delete(ENVIRONMENT_ID);
298300 for (const listener of serviceHarness.listeners) {
......@@ -307,10 +309,10 @@ describe("gitStatusState", () => {
307309 });
308310
309311 const secondClient = createRegisteredGitStatusClient(ENVIRONMENT_ID);
310 secondClient.emit({ ...BASE_STATUS, branch: "reconnected-branch" });
312 secondClient.emit({ ...BASE_STATUS, refName: "reconnected-refName" });
311313
312314 expect(getGitStatusSnapshot(TARGET)).toEqual({
313 data: { ...BASE_STATUS, branch: "reconnected-branch" },
315 data: { ...BASE_STATUS, refName: "reconnected-refName" },
314316 error: null,
315317 cause: null,
316318 isPending: false,
apps/web/src/lib/gitStatusState.ts+7-7
......@@ -2,7 +2,7 @@ import { useAtomValue } from "@effect/atom-react";
22import {
33 type EnvironmentId,
44 type GitManagerServiceError,
5 type GitStatusResult,
5 type VcsStatusResult,
66} from "@t3tools/contracts";
77import { Cause } from "effect";
88import { Atom } from "effect/unstable/reactivity";
......@@ -16,13 +16,13 @@ import {
1616import type { WsRpcClient } from "~/rpc/wsRpcClient";
1717
1818interface GitStatusState {
19 readonly data: GitStatusResult | null;
19 readonly data: VcsStatusResult | null;
2020 readonly error: GitManagerServiceError | null;
2121 readonly cause: Cause.Cause<GitManagerServiceError> | null;
2222 readonly isPending: boolean;
2323}
2424
25type GitStatusClient = Pick<WsRpcClient["git"], "onStatus" | "refreshStatus">;
25type GitStatusClient = Pick<WsRpcClient["vcs"], "onStatus" | "refreshStatus">;
2626interface ResolvedGitStatusClient {
2727 readonly clientIdentity: string;
2828 readonly client: GitStatusClient;
......@@ -56,7 +56,7 @@ const EMPTY_GIT_STATUS_ATOM = Atom.make(EMPTY_GIT_STATUS_STATE).pipe(
5656const NOOP: () => void = () => undefined;
5757const watchedGitStatuses = new Map<string, WatchedGitStatus>();
5858const knownGitStatusKeys = new Set<string>();
59const gitStatusRefreshInFlight = new Map<string, Promise<GitStatusResult>>();
59const gitStatusRefreshInFlight = new Map<string, Promise<VcsStatusResult>>();
6060const gitStatusLastRefreshAtByKey = new Map<string, number>();
6161
6262const GIT_STATUS_REFRESH_DEBOUNCE_MS = 1_000;
......@@ -83,7 +83,7 @@ function readResolvedGitStatusClient(target: GitStatusTarget): ResolvedGitStatus
8383 }
8484 const connection = readEnvironmentConnection(target.environmentId);
8585 return connection
86 ? { clientIdentity: connection.environmentId, client: connection.client.git }
86 ? { clientIdentity: connection.environmentId, client: connection.client.vcs }
8787 : null;
8888}
8989
......@@ -119,7 +119,7 @@ export function watchGitStatus(target: GitStatusTarget, client?: GitStatusClient
119119export function refreshGitStatus(
120120 target: GitStatusTarget,
121121 client?: GitStatusClient,
122): Promise<GitStatusResult | null> {
122): Promise<VcsStatusResult | null> {
123123 const targetKey = getGitStatusTargetKey(target);
124124 if (targetKey === null || target.cwd === null) {
125125 return Promise.resolve(null);
......@@ -245,7 +245,7 @@ function subscribeToGitStatus(targetKey: string, cwd: string, client: GitStatusC
245245 markGitStatusPending(targetKey);
246246 return client.onStatus(
247247 { cwd },
248 (status: GitStatusResult) => {
248 (status: VcsStatusResult) => {
249249 appAtomRegistry.set(gitStatusStateAtom(targetKey), {
250250 data: status,
251251 error: null,
apps/web/src/lib/sourceControlDiscoveryState.ts created+56
......@@ -0,0 +1,56 @@
1import { useAtomValue } from "@effect/atom-react";
2import {
3 EMPTY_SOURCE_CONTROL_DISCOVERY_ATOM,
4 EMPTY_SOURCE_CONTROL_DISCOVERY_STATE,
5 type SourceControlDiscoveryState,
6 createSourceControlDiscoveryManager,
7 getSourceControlDiscoveryTargetKey,
8 sourceControlDiscoveryStateAtom,
9} from "@t3tools/client-runtime";
10import type { SourceControlDiscoveryResult } from "@t3tools/contracts";
11import { Effect } from "effect";
12import { Atom } from "effect/unstable/reactivity";
13
14import { readLocalApi } from "../localApi";
15import { appAtomRegistry } from "../rpc/atomRegistry";
16
17const SOURCE_CONTROL_DISCOVERY_TARGET = { key: "primary" } as const;
18const SOURCE_CONTROL_DISCOVERY_STALE_TIME_MS = 30_000;
19const SOURCE_CONTROL_DISCOVERY_IDLE_TTL_MS = 5 * 60_000;
20
21export const sourceControlDiscoveryManager = createSourceControlDiscoveryManager({
22 getRegistry: () => appAtomRegistry,
23 getClient: () => readLocalApi()?.server ?? null,
24});
25
26const sourceControlDiscoveryAutoRefreshAtom = Atom.make(() =>
27 Effect.promise(() => refreshSourceControlDiscovery()),
28).pipe(
29 Atom.swr({
30 staleTime: SOURCE_CONTROL_DISCOVERY_STALE_TIME_MS,
31 revalidateOnMount: true,
32 }),
33 Atom.setIdleTTL(SOURCE_CONTROL_DISCOVERY_IDLE_TTL_MS),
34 Atom.withLabel("source-control-discovery:auto-refresh"),
35);
36
37export function refreshSourceControlDiscovery(): Promise<SourceControlDiscoveryResult | null> {
38 return sourceControlDiscoveryManager.refresh(SOURCE_CONTROL_DISCOVERY_TARGET);
39}
40
41export function resetSourceControlDiscoveryStateForTests(): void {
42 sourceControlDiscoveryManager.reset();
43}
44
45export function useSourceControlDiscovery(): SourceControlDiscoveryState {
46 const targetKey = getSourceControlDiscoveryTargetKey(SOURCE_CONTROL_DISCOVERY_TARGET);
47
48 useAtomValue(sourceControlDiscoveryAutoRefreshAtom);
49
50 const state = useAtomValue(
51 targetKey !== null
52 ? sourceControlDiscoveryStateAtom(targetKey)
53 : EMPTY_SOURCE_CONTROL_DISCOVERY_ATOM,
54 );
55 return targetKey === null ? EMPTY_SOURCE_CONTROL_DISCOVERY_STATE : state;
56}
apps/web/src/localApi.test.ts+19-17
......@@ -3,7 +3,7 @@ import {
33 DEFAULT_SERVER_SETTINGS,
44 type DesktopBridge,
55 EnvironmentId,
6 type GitStatusResult,
6 type VcsStatusResult,
77 ProjectId,
88 type OrchestrationShellStreamItem,
99 ProviderDriverKind,
......@@ -34,7 +34,7 @@ function registerListener<T>(listeners: Set<(event: T) => void>, listener: (even
3434
3535const terminalEventListeners = new Set<(event: TerminalEvent) => void>();
3636const shellStreamListeners = new Set<(event: OrchestrationShellStreamItem) => void>();
37const gitStatusListeners = new Set<(event: GitStatusResult) => void>();
37const gitStatusListeners = new Set<(event: VcsStatusResult) => void>();
3838
3939const rpcClientMock = {
4040 dispose: vi.fn(),
......@@ -59,19 +59,21 @@ const rpcClientMock = {
5959 shell: {
6060 openInEditor: vi.fn(),
6161 },
62 git: {
62 vcs: {
6363 pull: vi.fn(),
6464 refreshStatus: vi.fn(),
65 onStatus: vi.fn((input: { cwd: string }, listener: (event: GitStatusResult) => void) =>
65 onStatus: vi.fn((input: { cwd: string }, listener: (event: VcsStatusResult) => void) =>
6666 registerListener(gitStatusListeners, listener),
6767 ),
68 runStackedAction: vi.fn(),
69 listBranches: vi.fn(),
68 listRefs: vi.fn(),
7069 createWorktree: vi.fn(),
7170 removeWorktree: vi.fn(),
72 createBranch: vi.fn(),
73 checkout: vi.fn(),
71 createRef: vi.fn(),
72 switchRef: vi.fn(),
7473 init: vi.fn(),
74 },
75 git: {
76 runStackedAction: vi.fn(),
7577 resolvePullRequest: vi.fn(),
7678 preparePullRequestThread: vi.fn(),
7779 },
......@@ -259,11 +261,11 @@ const baseServerConfig: ServerConfig = {
259261 settings: DEFAULT_SERVER_SETTINGS,
260262};
261263
262const baseGitStatus: GitStatusResult = {
264const baseGitStatus: VcsStatusResult = {
263265 isRepo: true,
264 hasOriginRemote: true,
265 isDefaultBranch: false,
266 branch: "feature/streamed",
266 hasPrimaryRemote: true,
267 isDefaultRef: false,
268 refName: "feature/streamed",
267269 hasWorkingTreeChanges: false,
268270 workingTree: { files: [], insertions: 0, deletions: 0 },
269271 hasUpstream: true,
......@@ -351,24 +353,24 @@ describe("wsApi", () => {
351353 const api = createEnvironmentApi(rpcClientMock as never);
352354 const onStatus = vi.fn();
353355
354 api.git.onStatus({ cwd: "/repo" }, onStatus);
356 api.vcs.onStatus({ cwd: "/repo" }, onStatus);
355357
356358 const gitStatus = baseGitStatus;
357359 emitEvent(gitStatusListeners, gitStatus);
358360
359 expect(rpcClientMock.git.onStatus).toHaveBeenCalledWith({ cwd: "/repo" }, onStatus, undefined);
361 expect(rpcClientMock.vcs.onStatus).toHaveBeenCalledWith({ cwd: "/repo" }, onStatus, undefined);
360362 expect(onStatus).toHaveBeenCalledWith(gitStatus);
361363 });
362364
363365 it("forwards git status refreshes directly to the RPC client", async () => {
364 rpcClientMock.git.refreshStatus.mockResolvedValue(baseGitStatus);
366 rpcClientMock.vcs.refreshStatus.mockResolvedValue(baseGitStatus);
365367 const { createEnvironmentApi } = await import("./environmentApi");
366368
367369 const api = createEnvironmentApi(rpcClientMock as never);
368370
369 await api.git.refreshStatus({ cwd: "/repo" });
371 await api.vcs.refreshStatus({ cwd: "/repo" });
370372
371 expect(rpcClientMock.git.refreshStatus).toHaveBeenCalledWith({ cwd: "/repo" });
373 expect(rpcClientMock.vcs.refreshStatus).toHaveBeenCalledWith({ cwd: "/repo" });
372374 });
373375
374376 it("forwards shell stream subscription options to the RPC client", async () => {
apps/web/src/localApi.ts+3
......@@ -1,6 +1,7 @@
11import type { ContextMenuItem, LocalApi } from "@t3tools/contracts";
22
33import { resetGitStatusStateForTests } from "./lib/gitStatusState";
4import { resetSourceControlDiscoveryStateForTests } from "./lib/sourceControlDiscoveryState";
45import { resetRequestLatencyStateForTests } from "./rpc/requestLatencyState";
56import { resetServerStateForTests } from "./rpc/serverState";
67import { resetWsConnectionStateForTests } from "./rpc/wsConnectionState";
......@@ -115,6 +116,7 @@ export function createLocalApi(rpcClient: WsRpcClient): LocalApi {
115116 upsertKeybinding: rpcClient.server.upsertKeybinding,
116117 getSettings: rpcClient.server.getSettings,
117118 updateSettings: rpcClient.server.updateSettings,
119 discoverSourceControl: rpcClient.server.discoverSourceControl,
118120 },
119121 };
120122}
......@@ -146,6 +148,7 @@ export async function __resetLocalApiForTests() {
146148 __resetClientSettingsPersistenceForTests();
147149 await resetEnvironmentServiceForTests();
148150 resetGitStatusStateForTests();
151 resetSourceControlDiscoveryStateForTests();
149152 resetRequestLatencyStateForTests();
150153 resetSavedEnvironmentRegistryStoreForTests();
151154 resetSavedEnvironmentRuntimeStoreForTests();
apps/web/src/pullRequestReference.test.ts+32
......@@ -9,6 +9,24 @@ describe("parsePullRequestReference", () => {
99 );
1010 });
1111
12 it("accepts Azure DevOps pull request URLs", () => {
13 expect(
14 parsePullRequestReference("https://dev.azure.com/acme/project/_git/t3code/pullrequest/42"),
15 ).toBe("https://dev.azure.com/acme/project/_git/t3code/pullrequest/42");
16 });
17
18 it("accepts GitLab merge request URLs", () => {
19 expect(parsePullRequestReference("https://gitlab.com/group/project/-/merge_requests/42")).toBe(
20 "https://gitlab.com/group/project/-/merge_requests/42",
21 );
22 });
23
24 it("accepts legacy Azure DevOps pull request URLs", () => {
25 expect(
26 parsePullRequestReference("https://acme.visualstudio.com/project/_git/t3code/pullrequest/42"),
27 ).toBe("https://acme.visualstudio.com/project/_git/t3code/pullrequest/42");
28 });
29
1230 it("accepts raw numbers", () => {
1331 expect(parsePullRequestReference("42")).toBe("42");
1432 });
......@@ -31,6 +49,20 @@ describe("parsePullRequestReference", () => {
3149 ).toBe("https://github.com/pingdotgg/t3code/pull/42");
3250 });
3351
52 it("accepts glab mr checkout commands with raw numbers", () => {
53 expect(parsePullRequestReference("glab mr checkout 42")).toBe("42");
54 });
55
56 it("accepts az repos pr checkout commands with raw numbers", () => {
57 expect(parsePullRequestReference("az repos pr checkout --id 42")).toBe("42");
58 });
59
60 it("accepts az repos pr checkout commands with extra flags", () => {
61 expect(parsePullRequestReference("az repos pr checkout --id 42 --remote-name origin")).toBe(
62 "42",
63 );
64 });
65
3466 it("rejects non-pull-request input", () => {
3567 expect(parsePullRequestReference("feature/my-branch")).toBeNull();
3668 });
apps/web/src/pullRequestReference.ts+28-2
......@@ -1,7 +1,22 @@
11const GITHUB_PULL_REQUEST_URL_PATTERN =
22 /^https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/(\d+)(?:[/?#].*)?$/i;
3const GITLAB_MERGE_REQUEST_URL_PATTERN =
4 /^https:\/\/[^/\s]*gitlab[^/\s]*\/.+\/-\/merge_requests\/(\d+)(?:[/?#].*)?$/i;
5const AZURE_DEVOPS_PULL_REQUEST_URL_PATTERN =
6 /^https:\/\/(?:dev\.azure\.com\/[^/\s]+\/[^/\s]+|[^/\s]+\.visualstudio\.com\/[^/\s]+)\/_git\/[^/\s]+\/pullrequest\/(\d+)(?:[/?#].*)?$/i;
37const PULL_REQUEST_NUMBER_PATTERN = /^#?(\d+)$/;
48const GITHUB_CLI_PR_CHECKOUT_PATTERN = /^gh\s+pr\s+checkout\s+(.+)$/i;
9const GITLAB_CLI_MR_CHECKOUT_PATTERN = /^glab\s+mr\s+checkout\s+(.+)$/i;
10const AZURE_DEVOPS_CLI_PR_CHECKOUT_PATTERN = /^az\s+repos\s+pr\s+checkout\s+(.+)$/i;
11
12function parseAzureDevOpsCheckoutReference(args: string): string | null {
13 const parts = args.trim().split(/\s+/).filter(Boolean);
14 const idFlagIndex = parts.findIndex((part) => part === "--id" || part === "-i");
15 if (idFlagIndex >= 0) {
16 return parts[idFlagIndex + 1] ?? null;
17 }
18 return parts.find((part) => !part.startsWith("-")) ?? null;
19}
520
621export function parsePullRequestReference(input: string): string | null {
722 const trimmed = input.trim();
......@@ -10,12 +25,23 @@ export function parsePullRequestReference(input: string): string | null {
1025 }
1126
1227 const ghCliCheckoutMatch = GITHUB_CLI_PR_CHECKOUT_PATTERN.exec(trimmed);
13 const normalizedInput = ghCliCheckoutMatch?.[1]?.trim() ?? trimmed;
28 const glabCliCheckoutMatch = GITLAB_CLI_MR_CHECKOUT_PATTERN.exec(trimmed);
29 const azureDevOpsCliCheckoutMatch = AZURE_DEVOPS_CLI_PR_CHECKOUT_PATTERN.exec(trimmed);
30 const normalizedInput =
31 ghCliCheckoutMatch?.[1]?.trim() ??
32 glabCliCheckoutMatch?.[1]?.trim() ??
33 (azureDevOpsCliCheckoutMatch?.[1]
34 ? parseAzureDevOpsCheckoutReference(azureDevOpsCliCheckoutMatch[1])
35 : null) ??
36 trimmed;
1437 if (normalizedInput.length === 0) {
1538 return null;
1639 }
1740
18 const urlMatch = GITHUB_PULL_REQUEST_URL_PATTERN.exec(normalizedInput);
41 const urlMatch =
42 GITHUB_PULL_REQUEST_URL_PATTERN.exec(normalizedInput) ??
43 GITLAB_MERGE_REQUEST_URL_PATTERN.exec(normalizedInput) ??
44 AZURE_DEVOPS_PULL_REQUEST_URL_PATTERN.exec(normalizedInput);
1945 if (urlMatch?.[1]) {
2046 return normalizedInput;
2147 }
apps/web/src/routeTree.gen.ts+21
......@@ -13,6 +13,7 @@ import { Route as SettingsRouteImport } from './routes/settings'
1313import { Route as PairRouteImport } from './routes/pair'
1414import { Route as ChatRouteImport } from './routes/_chat'
1515import { Route as ChatIndexRouteImport } from './routes/_chat.index'
16import { Route as SettingsSourceControlRouteImport } from './routes/settings.source-control'
1617import { Route as SettingsGeneralRouteImport } from './routes/settings.general'
1718import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections'
1819import { Route as SettingsArchivedRouteImport } from './routes/settings.archived'
......@@ -38,6 +39,11 @@ const ChatIndexRoute = ChatIndexRouteImport.update({
3839 path: '/',
3940 getParentRoute: () => ChatRoute,
4041} as any)
42const SettingsSourceControlRoute = SettingsSourceControlRouteImport.update({
43 id: '/source-control',
44 path: '/source-control',
45 getParentRoute: () => SettingsRoute,
46} as any)
4147const SettingsGeneralRoute = SettingsGeneralRouteImport.update({
4248 id: '/general',
4349 path: '/general',
......@@ -72,6 +78,7 @@ export interface FileRoutesByFullPath {
7278 '/settings/archived': typeof SettingsArchivedRoute
7379 '/settings/connections': typeof SettingsConnectionsRoute
7480 '/settings/general': typeof SettingsGeneralRoute
81 '/settings/source-control': typeof SettingsSourceControlRoute
7582 '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute
7683 '/draft/$draftId': typeof ChatDraftDraftIdRoute
7784}
......@@ -81,6 +88,7 @@ export interface FileRoutesByTo {
8188 '/settings/archived': typeof SettingsArchivedRoute
8289 '/settings/connections': typeof SettingsConnectionsRoute
8390 '/settings/general': typeof SettingsGeneralRoute
91 '/settings/source-control': typeof SettingsSourceControlRoute
8492 '/': typeof ChatIndexRoute
8593 '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute
8694 '/draft/$draftId': typeof ChatDraftDraftIdRoute
......@@ -93,6 +101,7 @@ export interface FileRoutesById {
93101 '/settings/archived': typeof SettingsArchivedRoute
94102 '/settings/connections': typeof SettingsConnectionsRoute
95103 '/settings/general': typeof SettingsGeneralRoute
104 '/settings/source-control': typeof SettingsSourceControlRoute
96105 '/_chat/': typeof ChatIndexRoute
97106 '/_chat/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute
98107 '/_chat/draft/$draftId': typeof ChatDraftDraftIdRoute
......@@ -106,6 +115,7 @@ export interface FileRouteTypes {
106115 | '/settings/archived'
107116 | '/settings/connections'
108117 | '/settings/general'
118 | '/settings/source-control'
109119 | '/$environmentId/$threadId'
110120 | '/draft/$draftId'
111121 fileRoutesByTo: FileRoutesByTo
......@@ -115,6 +125,7 @@ export interface FileRouteTypes {
115125 | '/settings/archived'
116126 | '/settings/connections'
117127 | '/settings/general'
128 | '/settings/source-control'
118129 | '/'
119130 | '/$environmentId/$threadId'
120131 | '/draft/$draftId'
......@@ -126,6 +137,7 @@ export interface FileRouteTypes {
126137 | '/settings/archived'
127138 | '/settings/connections'
128139 | '/settings/general'
140 | '/settings/source-control'
129141 | '/_chat/'
130142 | '/_chat/$environmentId/$threadId'
131143 | '/_chat/draft/$draftId'
......@@ -167,6 +179,13 @@ declare module '@tanstack/react-router' {
167179 preLoaderRoute: typeof ChatIndexRouteImport
168180 parentRoute: typeof ChatRoute
169181 }
182 '/settings/source-control': {
183 id: '/settings/source-control'
184 path: '/source-control'
185 fullPath: '/settings/source-control'
186 preLoaderRoute: typeof SettingsSourceControlRouteImport
187 parentRoute: typeof SettingsRoute
188 }
170189 '/settings/general': {
171190 id: '/settings/general'
172191 path: '/general'
......@@ -223,12 +242,14 @@ interface SettingsRouteChildren {
223242 SettingsArchivedRoute: typeof SettingsArchivedRoute
224243 SettingsConnectionsRoute: typeof SettingsConnectionsRoute
225244 SettingsGeneralRoute: typeof SettingsGeneralRoute
245 SettingsSourceControlRoute: typeof SettingsSourceControlRoute
226246}
227247
228248const SettingsRouteChildren: SettingsRouteChildren = {
229249 SettingsArchivedRoute: SettingsArchivedRoute,
230250 SettingsConnectionsRoute: SettingsConnectionsRoute,
231251 SettingsGeneralRoute: SettingsGeneralRoute,
252 SettingsSourceControlRoute: SettingsSourceControlRoute,
232253}
233254
234255const SettingsRouteWithChildren = SettingsRoute._addFileChildren(
apps/web/src/routes/settings.source-control.tsx created+7
......@@ -0,0 +1,7 @@
1import { createFileRoute } from "@tanstack/react-router";
2
3import { SourceControlSettingsPanel } from "../components/settings/SourceControlSettings";
4
5export const Route = createFileRoute("/settings/source-control")({
6 component: SourceControlSettingsPanel,
7});
apps/web/src/rpc/wsRpcClient.test.ts+11-11
......@@ -1,7 +1,7 @@
11import type {
2 GitStatusLocalResult,
3 GitStatusRemoteResult,
4 GitStatusStreamEvent,
2 VcsStatusLocalResult,
3 VcsStatusRemoteResult,
4 VcsStatusStreamEvent,
55} from "@t3tools/contracts";
66import { describe, expect, it, vi } from "vitest";
77
......@@ -18,16 +18,16 @@ vi.mock("./wsTransport", () => ({
1818import { createWsRpcClient } from "./wsRpcClient";
1919import { type WsTransport } from "./wsTransport";
2020
21const baseLocalStatus: GitStatusLocalResult = {
21const baseLocalStatus: VcsStatusLocalResult = {
2222 isRepo: true,
23 hasOriginRemote: true,
24 isDefaultBranch: false,
25 branch: "feature/demo",
23 hasPrimaryRemote: true,
24 isDefaultRef: false,
25 refName: "feature/demo",
2626 hasWorkingTreeChanges: false,
2727 workingTree: { files: [], insertions: 0, deletions: 0 },
2828};
2929
30const baseRemoteStatus: GitStatusRemoteResult = {
30const baseRemoteStatus: VcsStatusRemoteResult = {
3131 hasUpstream: true,
3232 aheadCount: 0,
3333 behindCount: 0,
......@@ -35,7 +35,7 @@ const baseRemoteStatus: GitStatusRemoteResult = {
3535};
3636
3737describe("wsRpcClient", () => {
38 it("reduces git status stream events into flat status snapshots", () => {
38 it("reduces vcs status stream events into flat status snapshots", () => {
3939 const subscribe = vi.fn(<TValue>(_connect: unknown, listener: (value: TValue) => void) => {
4040 for (const event of [
4141 {
......@@ -54,7 +54,7 @@ describe("wsRpcClient", () => {
5454 hasWorkingTreeChanges: true,
5555 },
5656 },
57 ] satisfies GitStatusStreamEvent[]) {
57 ] satisfies VcsStatusStreamEvent[]) {
5858 listener(event as TValue);
5959 }
6060 return () => undefined;
......@@ -74,7 +74,7 @@ describe("wsRpcClient", () => {
7474 const client = createWsRpcClient(transport as unknown as WsTransport);
7575 const listener = vi.fn();
7676
77 client.git.onStatus({ cwd: "/repo" }, listener);
77 client.vcs.onStatus({ cwd: "/repo" }, listener);
7878
7979 expect(listener.mock.calls).toEqual([
8080 [
apps/web/src/rpc/wsRpcClient.ts+39-29
......@@ -2,8 +2,8 @@ import {
22 type GitActionProgressEvent,
33 type GitRunStackedActionInput,
44 type GitRunStackedActionResult,
5 type GitStatusResult,
6 type GitStatusStreamEvent,
5 type VcsStatusResult,
6 type VcsStatusStreamEvent,
77 type LocalApi,
88 ORCHESTRATION_WS_METHODS,
99 type ServerSettingsPatch,
......@@ -77,24 +77,29 @@ export interface WsRpcClient {
7777 readonly editor: Parameters<LocalApi["shell"]["openInEditor"]>[1];
7878 }) => ReturnType<LocalApi["shell"]["openInEditor"]>;
7979 };
80 readonly git: {
81 readonly pull: RpcUnaryMethod<typeof WS_METHODS.gitPull>;
82 readonly refreshStatus: RpcUnaryMethod<typeof WS_METHODS.gitRefreshStatus>;
80 readonly vcs: {
81 readonly pull: RpcUnaryMethod<typeof WS_METHODS.vcsPull>;
82 readonly refreshStatus: RpcUnaryMethod<typeof WS_METHODS.vcsRefreshStatus>;
8383 readonly onStatus: (
84 input: RpcInput<typeof WS_METHODS.subscribeGitStatus>,
85 listener: (status: GitStatusResult) => void,
84 input: RpcInput<typeof WS_METHODS.subscribeVcsStatus>,
85 listener: (status: VcsStatusResult) => void,
8686 options?: StreamSubscriptionOptions,
8787 ) => () => void;
88 readonly listRefs: RpcUnaryMethod<typeof WS_METHODS.vcsListRefs>;
89 readonly createWorktree: RpcUnaryMethod<typeof WS_METHODS.vcsCreateWorktree>;
90 readonly removeWorktree: RpcUnaryMethod<typeof WS_METHODS.vcsRemoveWorktree>;
91 readonly createRef: RpcUnaryMethod<typeof WS_METHODS.vcsCreateRef>;
92 readonly switchRef: RpcUnaryMethod<typeof WS_METHODS.vcsSwitchRef>;
93 readonly init: RpcUnaryMethod<typeof WS_METHODS.vcsInit>;
94 };
95 /**
96 * Git-specific workflows. Local repository mechanics live under `vcs`.
97 */
98 readonly git: {
8899 readonly runStackedAction: (
89100 input: GitRunStackedActionInput,
90101 options?: GitRunStackedActionOptions,
91102 ) => Promise<GitRunStackedActionResult>;
92 readonly listBranches: RpcUnaryMethod<typeof WS_METHODS.gitListBranches>;
93 readonly createWorktree: RpcUnaryMethod<typeof WS_METHODS.gitCreateWorktree>;
94 readonly removeWorktree: RpcUnaryMethod<typeof WS_METHODS.gitRemoveWorktree>;
95 readonly createBranch: RpcUnaryMethod<typeof WS_METHODS.gitCreateBranch>;
96 readonly checkout: RpcUnaryMethod<typeof WS_METHODS.gitCheckout>;
97 readonly init: RpcUnaryMethod<typeof WS_METHODS.gitInit>;
98103 readonly resolvePullRequest: RpcUnaryMethod<typeof WS_METHODS.gitResolvePullRequest>;
99104 readonly preparePullRequestThread: RpcUnaryMethod<
100105 typeof WS_METHODS.gitPreparePullRequestThread
......@@ -114,6 +119,9 @@ export interface WsRpcClient {
114119 readonly updateSettings: (
115120 patch: ServerSettingsPatch,
116121 ) => ReturnType<RpcUnaryMethod<typeof WS_METHODS.serverUpdateSettings>>;
122 readonly discoverSourceControl: RpcUnaryNoArgMethod<
123 typeof WS_METHODS.serverDiscoverSourceControl
124 >;
117125 readonly subscribeConfig: RpcStreamMethod<typeof WS_METHODS.subscribeServerConfig>;
118126 readonly subscribeLifecycle: RpcStreamMethod<typeof WS_METHODS.subscribeServerLifecycle>;
119127 readonly subscribeAuthAccess: RpcStreamMethod<typeof WS_METHODS.subscribeAuthAccess>;
......@@ -161,21 +169,31 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient {
161169 openInEditor: (input) =>
162170 transport.request((client) => client[WS_METHODS.shellOpenInEditor](input)),
163171 },
164 git: {
165 pull: (input) => transport.request((client) => client[WS_METHODS.gitPull](input)),
172 vcs: {
173 pull: (input) => transport.request((client) => client[WS_METHODS.vcsPull](input)),
166174 refreshStatus: (input) =>
167 transport.request((client) => client[WS_METHODS.gitRefreshStatus](input)),
175 transport.request((client) => client[WS_METHODS.vcsRefreshStatus](input)),
168176 onStatus: (input, listener, options) => {
169 let current: GitStatusResult | null = null;
177 let current: VcsStatusResult | null = null;
170178 return transport.subscribe(
171 (client) => client[WS_METHODS.subscribeGitStatus](input),
172 (event: GitStatusStreamEvent) => {
179 (client) => client[WS_METHODS.subscribeVcsStatus](input),
180 (event: VcsStatusStreamEvent) => {
173181 current = applyGitStatusStreamEvent(current, event);
174182 listener(current);
175183 },
176184 options,
177185 );
178186 },
187 listRefs: (input) => transport.request((client) => client[WS_METHODS.vcsListRefs](input)),
188 createWorktree: (input) =>
189 transport.request((client) => client[WS_METHODS.vcsCreateWorktree](input)),
190 removeWorktree: (input) =>
191 transport.request((client) => client[WS_METHODS.vcsRemoveWorktree](input)),
192 createRef: (input) => transport.request((client) => client[WS_METHODS.vcsCreateRef](input)),
193 switchRef: (input) => transport.request((client) => client[WS_METHODS.vcsSwitchRef](input)),
194 init: (input) => transport.request((client) => client[WS_METHODS.vcsInit](input)),
195 },
196 git: {
179197 runStackedAction: async (input, options) => {
180198 let result: GitRunStackedActionResult | null = null;
181199
......@@ -195,16 +213,6 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient {
195213
196214 throw new Error("Git action stream completed without a final result.");
197215 },
198 listBranches: (input) =>
199 transport.request((client) => client[WS_METHODS.gitListBranches](input)),
200 createWorktree: (input) =>
201 transport.request((client) => client[WS_METHODS.gitCreateWorktree](input)),
202 removeWorktree: (input) =>
203 transport.request((client) => client[WS_METHODS.gitRemoveWorktree](input)),
204 createBranch: (input) =>
205 transport.request((client) => client[WS_METHODS.gitCreateBranch](input)),
206 checkout: (input) => transport.request((client) => client[WS_METHODS.gitCheckout](input)),
207 init: (input) => transport.request((client) => client[WS_METHODS.gitInit](input)),
208216 resolvePullRequest: (input) =>
209217 transport.request((client) => client[WS_METHODS.gitResolvePullRequest](input)),
210218 preparePullRequestThread: (input) =>
......@@ -219,6 +227,8 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient {
219227 getSettings: () => transport.request((client) => client[WS_METHODS.serverGetSettings]({})),
220228 updateSettings: (patch) =>
221229 transport.request((client) => client[WS_METHODS.serverUpdateSettings]({ patch })),
230 discoverSourceControl: () =>
231 transport.request((client) => client[WS_METHODS.serverDiscoverSourceControl]({})),
222232 subscribeConfig: (listener, options) =>
223233 transport.subscribe(
224234 (client) => client[WS_METHODS.subscribeServerConfig]({}),
apps/web/src/sourceControlPresentation.ts created+62
......@@ -0,0 +1,62 @@
1import { GitPullRequestIcon } from "lucide-react";
2import type { ElementType } from "react";
3import type { SourceControlProviderInfo } from "@t3tools/contracts";
4export {
5 DEFAULT_CHANGE_REQUEST_TERMINOLOGY,
6 formatChangeRequestAction,
7 formatCreateChangeRequestPhrase,
8 getChangeRequestTerminology,
9 resolveChangeRequestPresentation,
10 type ChangeRequestPresentation,
11 type ChangeRequestTerminology,
12} from "@t3tools/shared/sourceControl";
13import {
14 getChangeRequestTerminology,
15 resolveChangeRequestPresentation,
16 type ChangeRequestTerminology,
17} from "@t3tools/shared/sourceControl";
18import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./components/Icons";
19
20export interface SourceControlPresentation {
21 readonly providerName: string;
22 readonly terminology: ChangeRequestTerminology;
23 readonly Icon: ElementType<{ className?: string }>;
24}
25
26export function getSourceControlPresentation(
27 provider: SourceControlProviderInfo | null | undefined,
28): SourceControlPresentation {
29 const presentation = resolveChangeRequestPresentation(provider);
30 switch (presentation.icon) {
31 case "github":
32 return {
33 providerName: provider?.name || presentation.providerName,
34 terminology: getChangeRequestTerminology(provider),
35 Icon: GitHubIcon,
36 };
37 case "gitlab":
38 return {
39 providerName: provider?.name || presentation.providerName,
40 terminology: getChangeRequestTerminology(provider),
41 Icon: GitLabIcon,
42 };
43 case "azure-devops":
44 return {
45 providerName: provider?.name || presentation.providerName,
46 terminology: getChangeRequestTerminology(provider),
47 Icon: AzureDevOpsIcon,
48 };
49 case "bitbucket":
50 return {
51 providerName: provider?.name || presentation.providerName,
52 terminology: getChangeRequestTerminology(provider),
53 Icon: BitbucketIcon,
54 };
55 case "change-request":
56 return {
57 providerName: provider?.name || presentation.providerName,
58 terminology: getChangeRequestTerminology(provider),
59 Icon: GitPullRequestIcon,
60 };
61 }
62}
apps/web/test/wsRpcHarness.ts+1-1
......@@ -26,7 +26,7 @@ const STREAM_METHODS = new Set<string>([
2626 ORCHESTRATION_WS_METHODS.subscribeShell,
2727 ORCHESTRATION_WS_METHODS.subscribeThread,
2828 WS_METHODS.gitRunStackedAction,
29 WS_METHODS.subscribeGitStatus,
29 WS_METHODS.subscribeVcsStatus,
3030 WS_METHODS.subscribeTerminalEvents,
3131 WS_METHODS.subscribeServerConfig,
3232 WS_METHODS.subscribeServerLifecycle,
bun.lock+1
......@@ -131,6 +131,7 @@
131131 "version": "0.0.0-alpha.1",
132132 "dependencies": {
133133 "@t3tools/contracts": "workspace:*",
134 "effect": "catalog:",
134135 },
135136 "devDependencies": {
136137 "@effect/language-service": "catalog:",
packages/client-runtime/package.json+2-1
......@@ -15,7 +15,8 @@
1515 "test": "vitest run"
1616 },
1717 "dependencies": {
18 "@t3tools/contracts": "workspace:*"
18 "@t3tools/contracts": "workspace:*",
19 "effect": "catalog:"
1920 },
2021 "devDependencies": {
2122 "@effect/language-service": "catalog:",
packages/client-runtime/src/index.ts+1
......@@ -1,2 +1,3 @@
11export * from "./knownEnvironment.ts";
22export * from "./scoped.ts";
3export * from "./sourceControlDiscoveryState.ts";
packages/client-runtime/src/sourceControlDiscoveryState.test.ts created+107
......@@ -0,0 +1,107 @@
1import { assert, beforeEach, it } from "vitest";
2import type { SourceControlDiscoveryResult } from "@t3tools/contracts";
3import { AtomRegistry } from "effect/unstable/reactivity";
4
5import {
6 EMPTY_SOURCE_CONTROL_DISCOVERY_STATE,
7 createSourceControlDiscoveryManager,
8} from "./sourceControlDiscoveryState.ts";
9
10const EMPTY_RESULT: SourceControlDiscoveryResult = {
11 versionControlSystems: [],
12 sourceControlProviders: [],
13};
14
15function unresolvedDiscovery() {
16 throw new Error("Discovery resolver was not initialized.");
17}
18
19let registry = AtomRegistry.make();
20
21beforeEach(() => {
22 registry.dispose();
23 registry = AtomRegistry.make();
24});
25
26it("stores refreshed discovery data in an atom snapshot", async () => {
27 const manager = createSourceControlDiscoveryManager({
28 getRegistry: () => registry,
29 getClient: () => ({
30 discoverSourceControl: async () => EMPTY_RESULT,
31 }),
32 });
33
34 assert.deepStrictEqual(manager.getSnapshot({ key: null }), EMPTY_SOURCE_CONTROL_DISCOVERY_STATE);
35
36 const result = await manager.refresh({ key: "primary" });
37
38 assert.strictEqual(result, EMPTY_RESULT);
39 assert.deepStrictEqual(manager.getSnapshot({ key: "primary" }), {
40 data: EMPTY_RESULT,
41 error: null,
42 isPending: false,
43 });
44});
45
46it("deduplicates in-flight discovery refreshes by target key", async () => {
47 let resolveDiscovery: (result: SourceControlDiscoveryResult) => void = unresolvedDiscovery;
48 let calls = 0;
49 const manager = createSourceControlDiscoveryManager({
50 getRegistry: () => registry,
51 getClient: () => ({
52 discoverSourceControl: () => {
53 calls += 1;
54 return new Promise<SourceControlDiscoveryResult>((resolve) => {
55 resolveDiscovery = resolve;
56 });
57 },
58 }),
59 });
60
61 const first = manager.refresh({ key: "primary" });
62 const second = manager.refresh({ key: "primary" });
63
64 assert.strictEqual(first, second);
65 assert.strictEqual(calls, 1);
66 assert.deepStrictEqual(manager.getSnapshot({ key: "primary" }), {
67 data: null,
68 error: null,
69 isPending: true,
70 });
71
72 resolveDiscovery(EMPTY_RESULT);
73 await first;
74
75 assert.deepStrictEqual(manager.getSnapshot({ key: "primary" }), {
76 data: EMPTY_RESULT,
77 error: null,
78 isPending: false,
79 });
80});
81
82it("keeps the previous snapshot when refresh fails", async () => {
83 let shouldFail = false;
84 const manager = createSourceControlDiscoveryManager({
85 getRegistry: () => registry,
86 getClient: () => ({
87 discoverSourceControl: async () => {
88 if (shouldFail) {
89 throw new Error("probe failed");
90 }
91 return EMPTY_RESULT;
92 },
93 }),
94 });
95
96 await manager.refresh({ key: "primary" });
97 shouldFail = true;
98
99 const result = await manager.refresh({ key: "primary" });
100
101 assert.strictEqual(result, EMPTY_RESULT);
102 assert.deepStrictEqual(manager.getSnapshot({ key: "primary" }), {
103 data: EMPTY_RESULT,
104 error: "probe failed",
105 isPending: false,
106 });
107});
packages/client-runtime/src/sourceControlDiscoveryState.ts created+206
......@@ -0,0 +1,206 @@
1import type { SourceControlDiscoveryResult } from "@t3tools/contracts";
2import { Atom, type AtomRegistry } from "effect/unstable/reactivity";
3
4/* --- Types ---------------------------------------------------------- */
5
6export interface SourceControlDiscoveryState {
7 readonly data: SourceControlDiscoveryResult | null;
8 readonly error: string | null;
9 readonly isPending: boolean;
10}
11
12export interface SourceControlDiscoveryTarget {
13 readonly key: string | null;
14}
15
16export interface SourceControlDiscoveryClient {
17 readonly discoverSourceControl: () => Promise<SourceControlDiscoveryResult>;
18}
19
20/* --- Constants ------------------------------------------------------ */
21
22export const EMPTY_SOURCE_CONTROL_DISCOVERY_STATE = Object.freeze<SourceControlDiscoveryState>({
23 data: null,
24 error: null,
25 isPending: false,
26});
27
28const INITIAL_SOURCE_CONTROL_DISCOVERY_STATE = Object.freeze<SourceControlDiscoveryState>({
29 data: null,
30 error: null,
31 isPending: true,
32});
33
34/* --- Atoms ---------------------------------------------------------- */
35
36const knownSourceControlDiscoveryKeys = new Set<string>();
37
38export const sourceControlDiscoveryStateAtom = Atom.family((key: string) => {
39 knownSourceControlDiscoveryKeys.add(key);
40 return Atom.make(INITIAL_SOURCE_CONTROL_DISCOVERY_STATE).pipe(
41 Atom.keepAlive,
42 Atom.withLabel(`source-control-discovery:${key}`),
43 );
44});
45
46export const EMPTY_SOURCE_CONTROL_DISCOVERY_ATOM = Atom.make(
47 EMPTY_SOURCE_CONTROL_DISCOVERY_STATE,
48).pipe(Atom.keepAlive, Atom.withLabel("source-control-discovery:null"));
49
50/* --- Helpers -------------------------------------------------------- */
51
52export function getSourceControlDiscoveryTargetKey(
53 target: SourceControlDiscoveryTarget,
54): string | null {
55 const key = target.key?.trim();
56 return key && key.length > 0 ? key : null;
57}
58
59/* --- Refresh manager ------------------------------------------------ */
60
61export interface SourceControlDiscoveryManagerConfig {
62 /**
63 * Get the atom registry used to read/write source-control discovery snapshots.
64 */
65 readonly getRegistry: () => AtomRegistry.AtomRegistry;
66 /**
67 * Resolve the runtime client for a discovery target key.
68 *
69 * Web currently uses a single `"primary"` target, but keeping this keyed
70 * lets mobile or future multi-environment clients provide separate discovery
71 * clients without changing the state primitive.
72 */
73 readonly getClient: (key: string) => SourceControlDiscoveryClient | null;
74}
75
76export function createSourceControlDiscoveryManager(config: SourceControlDiscoveryManagerConfig) {
77 const refreshInFlight = new Map<string, Promise<SourceControlDiscoveryResult | null>>();
78
79 /* -- Atom helpers -------------------------------------------------- */
80
81 function setState(targetKey: string, nextState: SourceControlDiscoveryState): void {
82 config.getRegistry().set(sourceControlDiscoveryStateAtom(targetKey), nextState);
83 }
84
85 function markPending(targetKey: string): void {
86 const current = config.getRegistry().get(sourceControlDiscoveryStateAtom(targetKey));
87 const next: SourceControlDiscoveryState =
88 current.data === null
89 ? INITIAL_SOURCE_CONTROL_DISCOVERY_STATE
90 : {
91 data: current.data,
92 error: null,
93 isPending: true,
94 };
95
96 if (
97 current.data === next.data &&
98 current.error === next.error &&
99 current.isPending === next.isPending
100 ) {
101 return;
102 }
103
104 setState(targetKey, next);
105 }
106
107 function setData(targetKey: string, data: SourceControlDiscoveryResult): void {
108 setState(targetKey, {
109 data,
110 error: null,
111 isPending: false,
112 });
113 }
114
115 function setError(targetKey: string, error: unknown): void {
116 const current = config.getRegistry().get(sourceControlDiscoveryStateAtom(targetKey));
117 setState(targetKey, {
118 data: current.data,
119 error: error instanceof Error ? error.message : "Failed to discover source control tools.",
120 isPending: false,
121 });
122 }
123
124 /* -- Public API ---------------------------------------------------- */
125
126 /**
127 * Trigger a one-shot source-control discovery RPC for a target.
128 *
129 * Calls are deduplicated while a refresh for the same target key is in
130 * flight. On failure, the previous successful snapshot is kept in `data`
131 * and the error message is stored separately so UI can keep rendering stale
132 * discovery results while showing the failure.
133 *
134 * @param target The logical runtime target to refresh.
135 * @param client Optional pre-resolved client, useful in tests.
136 */
137 function refresh(
138 target: SourceControlDiscoveryTarget,
139 client?: SourceControlDiscoveryClient,
140 ): Promise<SourceControlDiscoveryResult | null> {
141 const targetKey = getSourceControlDiscoveryTargetKey(target);
142 if (targetKey === null) {
143 return Promise.resolve(null);
144 }
145
146 const existing = refreshInFlight.get(targetKey);
147 if (existing) {
148 return existing;
149 }
150
151 const resolvedClient = client ?? config.getClient(targetKey);
152 if (!resolvedClient) {
153 const error = new Error("Source control discovery client is unavailable.");
154 setError(targetKey, error);
155 return Promise.resolve(getSnapshot(target).data);
156 }
157
158 markPending(targetKey);
159 const promise = resolvedClient.discoverSourceControl().then(
160 (result) => {
161 setData(targetKey, result);
162 return result;
163 },
164 (error: unknown) => {
165 setError(targetKey, error);
166 return getSnapshot(target).data;
167 },
168 );
169 const tracked = promise.finally(() => refreshInFlight.delete(targetKey));
170 refreshInFlight.set(targetKey, tracked);
171 return tracked;
172 }
173
174 /**
175 * Read the current atom snapshot for `target`.
176 *
177 * Invalid targets return the inert empty state rather than creating a new
178 * family atom entry.
179 */
180 function getSnapshot(target: SourceControlDiscoveryTarget): SourceControlDiscoveryState {
181 const targetKey = getSourceControlDiscoveryTargetKey(target);
182 if (targetKey === null) {
183 return EMPTY_SOURCE_CONTROL_DISCOVERY_STATE;
184 }
185
186 return config.getRegistry().get(sourceControlDiscoveryStateAtom(targetKey));
187 }
188
189 /**
190 * Clear in-flight refresh tracking and reset every known discovery atom.
191 * Primarily used by tests and runtime teardown.
192 */
193 function reset(): void {
194 refreshInFlight.clear();
195 for (const key of knownSourceControlDiscoveryKeys) {
196 setState(key, INITIAL_SOURCE_CONTROL_DISCOVERY_STATE);
197 }
198 knownSourceControlDiscoveryKeys.clear();
199 }
200
201 return {
202 refresh,
203 getSnapshot,
204 reset,
205 };
206}
packages/contracts/src/git.test.ts+7-7
......@@ -2,14 +2,14 @@ import { describe, expect, it } from "vitest";
22import { Schema } from "effect";
33
44import {
5 GitCreateWorktreeInput,
5 VcsCreateWorktreeInput,
66 GitPreparePullRequestThreadInput,
77 GitRunStackedActionResult,
88 GitRunStackedActionInput,
99 GitResolvePullRequestResult,
1010} from "./git.ts";
1111
12const decodeCreateWorktreeInput = Schema.decodeUnknownSync(GitCreateWorktreeInput);
12const decodeCreateWorktreeInput = Schema.decodeUnknownSync(VcsCreateWorktreeInput);
1313const decodePreparePullRequestThreadInput = Schema.decodeUnknownSync(
1414 GitPreparePullRequestThreadInput,
1515);
......@@ -17,16 +17,16 @@ const decodeRunStackedActionInput = Schema.decodeUnknownSync(GitRunStackedAction
1717const decodeRunStackedActionResult = Schema.decodeUnknownSync(GitRunStackedActionResult);
1818const decodeResolvePullRequestResult = Schema.decodeUnknownSync(GitResolvePullRequestResult);
1919
20describe("GitCreateWorktreeInput", () => {
21 it("accepts omitted newBranch for existing-branch worktrees", () => {
20describe("VcsCreateWorktreeInput", () => {
21 it("accepts omitted newRefName for existing-refName worktrees", () => {
2222 const parsed = decodeCreateWorktreeInput({
2323 cwd: "/repo",
24 branch: "feature/existing",
24 refName: "feature/existing",
2525 path: "/tmp/worktree",
2626 });
2727
28 expect(parsed.newBranch).toBeUndefined();
29 expect(parsed.branch).toBe("feature/existing");
28 expect(parsed.newRefName).toBeUndefined();
29 expect(parsed.refName).toBe("feature/existing");
3030 });
3131});
3232
packages/contracts/src/git.ts+72-76
......@@ -1,5 +1,7 @@
11import { Schema } from "effect";
22import { NonNegativeInt, PositiveInt, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts";
3import { SourceControlProviderError, SourceControlProviderInfo } from "./sourceControl.ts";
4import { VcsDriverKind } from "./vcs.ts";
35
46const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString;
57const GIT_LIST_BRANCHES_MAX_LIMIT = 200;
......@@ -40,18 +42,10 @@ const GitPushStepStatus = Schema.Literals([
4042]);
4143const GitBranchStepStatus = Schema.Literals(["created", "skipped_not_requested"]);
4244const GitPrStepStatus = Schema.Literals(["created", "opened_existing", "skipped_not_requested"]);
43const GitStatusPrState = Schema.Literals(["open", "closed", "merged"]);
45const VcsStatusChangeRequestState = Schema.Literals(["open", "closed", "merged"]);
4446const GitPullRequestReference = TrimmedNonEmptyStringSchema;
4547const GitPullRequestState = Schema.Literals(["open", "closed", "merged"]);
4648const GitPreparePullRequestThreadMode = Schema.Literals(["local", "worktree"]);
47export const GitHostingProviderKind = Schema.Literals(["github", "gitlab", "unknown"]);
48export type GitHostingProviderKind = typeof GitHostingProviderKind.Type;
49export const GitHostingProvider = Schema.Struct({
50 kind: GitHostingProviderKind,
51 name: TrimmedNonEmptyStringSchema,
52 baseUrl: Schema.String,
53});
54export type GitHostingProvider = typeof GitHostingProvider.Type;
5549export const GitRunStackedActionToastRunAction = Schema.Struct({
5650 kind: GitStackedAction,
5751});
......@@ -79,7 +73,7 @@ const GitRunStackedActionToast = Schema.Struct({
7973});
8074export type GitRunStackedActionToast = typeof GitRunStackedActionToast.Type;
8175
82export const GitBranch = Schema.Struct({
76export const VcsRef = Schema.Struct({
8377 name: TrimmedNonEmptyStringSchema,
8478 isRemote: Schema.optional(Schema.Boolean),
8579 remoteName: Schema.optional(TrimmedNonEmptyStringSchema),
......@@ -87,11 +81,11 @@ export const GitBranch = Schema.Struct({
8781 isDefault: Schema.Boolean,
8882 worktreePath: TrimmedNonEmptyStringSchema.pipe(Schema.NullOr),
8983});
90export type GitBranch = typeof GitBranch.Type;
84export type VcsRef = typeof VcsRef.Type;
9185
92const GitWorktree = Schema.Struct({
86const VcsWorktree = Schema.Struct({
9387 path: TrimmedNonEmptyStringSchema,
94 branch: TrimmedNonEmptyStringSchema,
88 refName: TrimmedNonEmptyStringSchema,
9589});
9690const GitResolvedPullRequest = Schema.Struct({
9791 number: PositiveInt,
......@@ -105,15 +99,15 @@ export type GitResolvedPullRequest = typeof GitResolvedPullRequest.Type;
10599
106100// RPC Inputs
107101
108export const GitStatusInput = Schema.Struct({
102export const VcsStatusInput = Schema.Struct({
109103 cwd: TrimmedNonEmptyStringSchema,
110104});
111export type GitStatusInput = typeof GitStatusInput.Type;
105export type VcsStatusInput = typeof VcsStatusInput.Type;
112106
113export const GitPullInput = Schema.Struct({
107export const VcsPullInput = Schema.Struct({
114108 cwd: TrimmedNonEmptyStringSchema,
115109});
116export type GitPullInput = typeof GitPullInput.Type;
110export type VcsPullInput = typeof VcsPullInput.Type;
117111
118112export const GitRunStackedActionInput = Schema.Struct({
119113 actionId: TrimmedNonEmptyStringSchema,
......@@ -127,7 +121,7 @@ export const GitRunStackedActionInput = Schema.Struct({
127121});
128122export type GitRunStackedActionInput = typeof GitRunStackedActionInput.Type;
129123
130export const GitListBranchesInput = Schema.Struct({
124export const VcsListRefsInput = Schema.Struct({
131125 cwd: TrimmedNonEmptyStringSchema,
132126 query: Schema.optional(TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(256))),
133127 cursor: Schema.optional(NonNegativeInt),
......@@ -135,15 +129,15 @@ export const GitListBranchesInput = Schema.Struct({
135129 PositiveInt.check(Schema.isLessThanOrEqualTo(GIT_LIST_BRANCHES_MAX_LIMIT)),
136130 ),
137131});
138export type GitListBranchesInput = typeof GitListBranchesInput.Type;
132export type VcsListRefsInput = typeof VcsListRefsInput.Type;
139133
140export const GitCreateWorktreeInput = Schema.Struct({
134export const VcsCreateWorktreeInput = Schema.Struct({
141135 cwd: TrimmedNonEmptyStringSchema,
142 branch: TrimmedNonEmptyStringSchema,
143 newBranch: Schema.optional(TrimmedNonEmptyStringSchema),
136 refName: TrimmedNonEmptyStringSchema,
137 newRefName: Schema.optional(TrimmedNonEmptyStringSchema),
144138 path: Schema.NullOr(TrimmedNonEmptyStringSchema),
145139});
146export type GitCreateWorktreeInput = typeof GitCreateWorktreeInput.Type;
140export type VcsCreateWorktreeInput = typeof VcsCreateWorktreeInput.Type;
147141
148142export const GitPullRequestRefInput = Schema.Struct({
149143 cwd: TrimmedNonEmptyStringSchema,
......@@ -159,53 +153,54 @@ export const GitPreparePullRequestThreadInput = Schema.Struct({
159153});
160154export type GitPreparePullRequestThreadInput = typeof GitPreparePullRequestThreadInput.Type;
161155
162export const GitRemoveWorktreeInput = Schema.Struct({
156export const VcsRemoveWorktreeInput = Schema.Struct({
163157 cwd: TrimmedNonEmptyStringSchema,
164158 path: TrimmedNonEmptyStringSchema,
165159 force: Schema.optional(Schema.Boolean),
166160});
167export type GitRemoveWorktreeInput = typeof GitRemoveWorktreeInput.Type;
161export type VcsRemoveWorktreeInput = typeof VcsRemoveWorktreeInput.Type;
168162
169export const GitCreateBranchInput = Schema.Struct({
163export const VcsCreateRefInput = Schema.Struct({
170164 cwd: TrimmedNonEmptyStringSchema,
171 branch: TrimmedNonEmptyStringSchema,
172 checkout: Schema.optional(Schema.Boolean),
165 refName: TrimmedNonEmptyStringSchema,
166 switchRef: Schema.optional(Schema.Boolean),
173167});
174export type GitCreateBranchInput = typeof GitCreateBranchInput.Type;
168export type VcsCreateRefInput = typeof VcsCreateRefInput.Type;
175169
176export const GitCreateBranchResult = Schema.Struct({
177 branch: TrimmedNonEmptyStringSchema,
170export const VcsCreateRefResult = Schema.Struct({
171 refName: TrimmedNonEmptyStringSchema,
178172});
179export type GitCreateBranchResult = typeof GitCreateBranchResult.Type;
173export type VcsCreateRefResult = typeof VcsCreateRefResult.Type;
180174
181export const GitCheckoutInput = Schema.Struct({
175export const VcsSwitchRefInput = Schema.Struct({
182176 cwd: TrimmedNonEmptyStringSchema,
183 branch: TrimmedNonEmptyStringSchema,
177 refName: TrimmedNonEmptyStringSchema,
184178});
185export type GitCheckoutInput = typeof GitCheckoutInput.Type;
179export type VcsSwitchRefInput = typeof VcsSwitchRefInput.Type;
186180
187export const GitInitInput = Schema.Struct({
181export const VcsInitInput = Schema.Struct({
188182 cwd: TrimmedNonEmptyStringSchema,
183 kind: Schema.optional(VcsDriverKind),
189184});
190export type GitInitInput = typeof GitInitInput.Type;
185export type VcsInitInput = typeof VcsInitInput.Type;
191186
192187// RPC Results
193188
194const GitStatusPr = Schema.Struct({
189const VcsStatusChangeRequest = Schema.Struct({
195190 number: PositiveInt,
196191 title: TrimmedNonEmptyStringSchema,
197192 url: Schema.String,
198 baseBranch: TrimmedNonEmptyStringSchema,
199 headBranch: TrimmedNonEmptyStringSchema,
200 state: GitStatusPrState,
193 baseRef: TrimmedNonEmptyStringSchema,
194 headRef: TrimmedNonEmptyStringSchema,
195 state: VcsStatusChangeRequestState,
201196});
202197
203const GitStatusLocalShape = {
198const VcsStatusLocalShape = {
204199 isRepo: Schema.Boolean,
205 hostingProvider: Schema.optional(GitHostingProvider),
206 hasOriginRemote: Schema.Boolean,
207 isDefaultBranch: Schema.Boolean,
208 branch: Schema.NullOr(TrimmedNonEmptyStringSchema),
200 sourceControlProvider: Schema.optional(SourceControlProviderInfo),
201 hasPrimaryRemote: Schema.Boolean,
202 isDefaultRef: Schema.Boolean,
203 refName: Schema.NullOr(TrimmedNonEmptyStringSchema),
209204 hasWorkingTreeChanges: Schema.Boolean,
210205 workingTree: Schema.Struct({
211206 files: Schema.Array(
......@@ -220,52 +215,52 @@ const GitStatusLocalShape = {
220215 }),
221216};
222217
223const GitStatusRemoteShape = {
218const VcsStatusRemoteShape = {
224219 hasUpstream: Schema.Boolean,
225220 aheadCount: NonNegativeInt,
226221 behindCount: NonNegativeInt,
227 pr: Schema.NullOr(GitStatusPr),
222 pr: Schema.NullOr(VcsStatusChangeRequest),
228223};
229224
230export const GitStatusLocalResult = Schema.Struct(GitStatusLocalShape);
231export type GitStatusLocalResult = typeof GitStatusLocalResult.Type;
225export const VcsStatusLocalResult = Schema.Struct(VcsStatusLocalShape);
226export type VcsStatusLocalResult = typeof VcsStatusLocalResult.Type;
232227
233export const GitStatusRemoteResult = Schema.Struct(GitStatusRemoteShape);
234export type GitStatusRemoteResult = typeof GitStatusRemoteResult.Type;
228export const VcsStatusRemoteResult = Schema.Struct(VcsStatusRemoteShape);
229export type VcsStatusRemoteResult = typeof VcsStatusRemoteResult.Type;
235230
236export const GitStatusResult = Schema.Struct({
237 ...GitStatusLocalShape,
238 ...GitStatusRemoteShape,
231export const VcsStatusResult = Schema.Struct({
232 ...VcsStatusLocalShape,
233 ...VcsStatusRemoteShape,
239234});
240export type GitStatusResult = typeof GitStatusResult.Type;
235export type VcsStatusResult = typeof VcsStatusResult.Type;
241236
242export const GitStatusStreamEvent = Schema.Union([
237export const VcsStatusStreamEvent = Schema.Union([
243238 Schema.TaggedStruct("snapshot", {
244 local: GitStatusLocalResult,
245 remote: Schema.NullOr(GitStatusRemoteResult),
239 local: VcsStatusLocalResult,
240 remote: Schema.NullOr(VcsStatusRemoteResult),
246241 }),
247242 Schema.TaggedStruct("localUpdated", {
248 local: GitStatusLocalResult,
243 local: VcsStatusLocalResult,
249244 }),
250245 Schema.TaggedStruct("remoteUpdated", {
251 remote: Schema.NullOr(GitStatusRemoteResult),
246 remote: Schema.NullOr(VcsStatusRemoteResult),
252247 }),
253248]);
254export type GitStatusStreamEvent = typeof GitStatusStreamEvent.Type;
249export type VcsStatusStreamEvent = typeof VcsStatusStreamEvent.Type;
255250
256export const GitListBranchesResult = Schema.Struct({
257 branches: Schema.Array(GitBranch),
251export const VcsListRefsResult = Schema.Struct({
252 refs: Schema.Array(VcsRef),
258253 isRepo: Schema.Boolean,
259 hasOriginRemote: Schema.Boolean,
254 hasPrimaryRemote: Schema.Boolean,
260255 nextCursor: NonNegativeInt.pipe(Schema.NullOr),
261256 totalCount: NonNegativeInt,
262257});
263export type GitListBranchesResult = typeof GitListBranchesResult.Type;
258export type VcsListRefsResult = typeof VcsListRefsResult.Type;
264259
265export const GitCreateWorktreeResult = Schema.Struct({
266 worktree: GitWorktree,
260export const VcsCreateWorktreeResult = Schema.Struct({
261 worktree: VcsWorktree,
267262});
268export type GitCreateWorktreeResult = typeof GitCreateWorktreeResult.Type;
263export type VcsCreateWorktreeResult = typeof VcsCreateWorktreeResult.Type;
269264
270265export const GitResolvePullRequestResult = Schema.Struct({
271266 pullRequest: GitResolvedPullRequest,
......@@ -279,10 +274,10 @@ export const GitPreparePullRequestThreadResult = Schema.Struct({
279274});
280275export type GitPreparePullRequestThreadResult = typeof GitPreparePullRequestThreadResult.Type;
281276
282export const GitCheckoutResult = Schema.Struct({
283 branch: Schema.NullOr(TrimmedNonEmptyStringSchema),
277export const VcsSwitchRefResult = Schema.Struct({
278 refName: Schema.NullOr(TrimmedNonEmptyStringSchema),
284279});
285export type GitCheckoutResult = typeof GitCheckoutResult.Type;
280export type VcsSwitchRefResult = typeof VcsSwitchRefResult.Type;
286281
287282export const GitRunStackedActionResult = Schema.Struct({
288283 action: GitStackedAction,
......@@ -313,12 +308,12 @@ export const GitRunStackedActionResult = Schema.Struct({
313308});
314309export type GitRunStackedActionResult = typeof GitRunStackedActionResult.Type;
315310
316export const GitPullResult = Schema.Struct({
311export const VcsPullResult = Schema.Struct({
317312 status: Schema.Literals(["pulled", "skipped_up_to_date"]),
318 branch: TrimmedNonEmptyStringSchema,
319 upstreamBranch: TrimmedNonEmptyStringSchema.pipe(Schema.NullOr),
313 refName: TrimmedNonEmptyStringSchema,
314 upstreamRef: TrimmedNonEmptyStringSchema.pipe(Schema.NullOr),
320315});
321export type GitPullResult = typeof GitPullResult.Type;
316export type VcsPullResult = typeof VcsPullResult.Type;
322317
323318// RPC / domain errors
324319export class GitCommandError extends Schema.TaggedErrorClass<GitCommandError>()("GitCommandError", {
......@@ -370,6 +365,7 @@ export const GitManagerServiceError = Schema.Union([
370365 GitManagerError,
371366 GitCommandError,
372367 GitHubCliError,
368 SourceControlProviderError,
373369 TextGenerationError,
374370]);
375371export type GitManagerServiceError = typeof GitManagerServiceError.Type;
packages/contracts/src/index.ts+2
......@@ -11,6 +11,8 @@ export * from "./keybindings.ts";
1111export * from "./server.ts";
1212export * from "./settings.ts";
1313export * from "./git.ts";
14export * from "./vcs.ts";
15export * from "./sourceControl.ts";
1416export * from "./orchestration.ts";
1517export * from "./editor.ts";
1618export * from "./project.ts";
packages/contracts/src/ipc.ts+34-30
......@@ -1,22 +1,22 @@
11import type {
2 GitCheckoutInput,
3 GitCheckoutResult,
4 GitCreateBranchInput,
2 VcsSwitchRefInput,
3 VcsSwitchRefResult,
4 VcsCreateRefInput,
55 GitPreparePullRequestThreadInput,
66 GitPreparePullRequestThreadResult,
77 GitPullRequestRefInput,
8 GitCreateWorktreeInput,
9 GitCreateWorktreeResult,
10 GitInitInput,
11 GitListBranchesInput,
12 GitListBranchesResult,
13 GitPullInput,
14 GitPullResult,
15 GitRemoveWorktreeInput,
8 VcsCreateWorktreeInput,
9 VcsCreateWorktreeResult,
10 VcsInitInput,
11 VcsListRefsInput,
12 VcsListRefsResult,
13 VcsPullInput,
14 VcsPullResult,
15 VcsRemoveWorktreeInput,
1616 GitResolvePullRequestResult,
17 GitStatusInput,
18 GitStatusResult,
19 GitCreateBranchResult,
17 VcsStatusInput,
18 VcsStatusResult,
19 VcsCreateRefResult,
2020} from "./git.ts";
2121import type { FilesystemBrowseInput, FilesystemBrowseResult } from "./filesystem.ts";
2222import type {
......@@ -55,6 +55,7 @@ import type {
5555import type { EnvironmentId } from "./baseSchemas.ts";
5656import { EditorId } from "./editor.ts";
5757import { ServerSettings, type ClientSettings, type ServerSettingsPatch } from "./settings.ts";
58import type { SourceControlDiscoveryResult } from "./sourceControl.ts";
5859
5960export interface ContextMenuItem<T extends string = string> {
6061 id: T;
......@@ -227,6 +228,7 @@ export interface LocalApi {
227228 upsertKeybinding: (input: ServerUpsertKeybindingInput) => Promise<ServerUpsertKeybindingResult>;
228229 getSettings: () => Promise<ServerSettings>;
229230 updateSettings: (patch: ServerSettingsPatch) => Promise<ServerSettings>;
231 discoverSourceControl: () => Promise<SourceControlDiscoveryResult>;
230232 };
231233}
232234
......@@ -235,7 +237,7 @@ export interface LocalApi {
235237 *
236238 * These operations must always be routed with explicit environment context.
237239 * They represent remote stateful capabilities such as orchestration, terminal,
238 * project, and git operations. In multi-environment mode, each environment gets
240 * project, VCS, and provider operations. In multi-environment mode, each environment gets
239241 * its own instance of this surface, and callers should resolve it by
240242 * `environmentId` rather than reaching through the local desktop bridge.
241243 */
......@@ -256,27 +258,29 @@ export interface EnvironmentApi {
256258 filesystem: {
257259 browse: (input: FilesystemBrowseInput) => Promise<FilesystemBrowseResult>;
258260 };
259 git: {
260 listBranches: (input: GitListBranchesInput) => Promise<GitListBranchesResult>;
261 createWorktree: (input: GitCreateWorktreeInput) => Promise<GitCreateWorktreeResult>;
262 removeWorktree: (input: GitRemoveWorktreeInput) => Promise<void>;
263 createBranch: (input: GitCreateBranchInput) => Promise<GitCreateBranchResult>;
264 checkout: (input: GitCheckoutInput) => Promise<GitCheckoutResult>;
265 init: (input: GitInitInput) => Promise<void>;
266 resolvePullRequest: (input: GitPullRequestRefInput) => Promise<GitResolvePullRequestResult>;
267 preparePullRequestThread: (
268 input: GitPreparePullRequestThreadInput,
269 ) => Promise<GitPreparePullRequestThreadResult>;
270 pull: (input: GitPullInput) => Promise<GitPullResult>;
271 refreshStatus: (input: GitStatusInput) => Promise<GitStatusResult>;
261 vcs: {
262 listRefs: (input: VcsListRefsInput) => Promise<VcsListRefsResult>;
263 createWorktree: (input: VcsCreateWorktreeInput) => Promise<VcsCreateWorktreeResult>;
264 removeWorktree: (input: VcsRemoveWorktreeInput) => Promise<void>;
265 createRef: (input: VcsCreateRefInput) => Promise<VcsCreateRefResult>;
266 switchRef: (input: VcsSwitchRefInput) => Promise<VcsSwitchRefResult>;
267 init: (input: VcsInitInput) => Promise<void>;
268 pull: (input: VcsPullInput) => Promise<VcsPullResult>;
269 refreshStatus: (input: VcsStatusInput) => Promise<VcsStatusResult>;
272270 onStatus: (
273 input: GitStatusInput,
274 callback: (status: GitStatusResult) => void,
271 input: VcsStatusInput,
272 callback: (status: VcsStatusResult) => void,
275273 options?: {
276274 onResubscribe?: () => void;
277275 },
278276 ) => () => void;
279277 };
278 git: {
279 resolvePullRequest: (input: GitPullRequestRefInput) => Promise<GitResolvePullRequestResult>;
280 preparePullRequestThread: (
281 input: GitPreparePullRequestThreadInput,
282 ) => Promise<GitPreparePullRequestThreadResult>;
283 };
280284 orchestration: {
281285 dispatchCommand: (command: ClientOrchestrationCommand) => Promise<{ sequence: number }>;
282286 getTurnDiff: (input: OrchestrationGetTurnDiffInput) => Promise<OrchestrationGetTurnDiffResult>;
packages/contracts/src/rpc.ts+71-60
......@@ -11,28 +11,28 @@ import {
1111} from "./filesystem.ts";
1212import {
1313 GitActionProgressEvent,
14 GitCheckoutInput,
15 GitCheckoutResult,
14 VcsSwitchRefInput,
15 VcsSwitchRefResult,
1616 GitCommandError,
17 GitCreateBranchInput,
18 GitCreateBranchResult,
19 GitCreateWorktreeInput,
20 GitCreateWorktreeResult,
21 GitInitInput,
22 GitListBranchesInput,
23 GitListBranchesResult,
17 VcsCreateRefInput,
18 VcsCreateRefResult,
19 VcsCreateWorktreeInput,
20 VcsCreateWorktreeResult,
21 VcsInitInput,
22 VcsListRefsInput,
23 VcsListRefsResult,
2424 GitManagerServiceError,
2525 GitPreparePullRequestThreadInput,
2626 GitPreparePullRequestThreadResult,
27 GitPullInput,
27 VcsPullInput,
2828 GitPullRequestRefInput,
29 GitPullResult,
30 GitRemoveWorktreeInput,
29 VcsPullResult,
30 VcsRemoveWorktreeInput,
3131 GitResolvePullRequestResult,
3232 GitRunStackedActionInput,
33 GitStatusInput,
34 GitStatusResult,
35 GitStatusStreamEvent,
33 VcsStatusInput,
34 VcsStatusResult,
35 VcsStatusStreamEvent,
3636} from "./git.ts";
3737import { KeybindingsConfigError } from "./keybindings.ts";
3838import {
......@@ -77,6 +77,8 @@ import {
7777 ServerUpsertKeybindingResult,
7878} from "./server.ts";
7979import { ServerSettings, ServerSettingsError, ServerSettingsPatch } from "./settings.ts";
80import { SourceControlDiscoveryResult } from "./sourceControl.ts";
81import { VcsError } from "./vcs.ts";
8082
8183export const WS_METHODS = {
8284 // Project registry methods
......@@ -92,16 +94,18 @@ export const WS_METHODS = {
9294 // Filesystem methods
9395 filesystemBrowse: "filesystem.browse",
9496
95 // Git methods
96 gitPull: "git.pull",
97 gitRefreshStatus: "git.refreshStatus",
97 // VCS methods
98 vcsPull: "vcs.pull",
99 vcsRefreshStatus: "vcs.refreshStatus",
100 vcsListRefs: "vcs.listRefs",
101 vcsCreateWorktree: "vcs.createWorktree",
102 vcsRemoveWorktree: "vcs.removeWorktree",
103 vcsCreateRef: "vcs.createRef",
104 vcsSwitchRef: "vcs.switchRef",
105 vcsInit: "vcs.init",
106
107 // Git workflow methods
98108 gitRunStackedAction: "git.runStackedAction",
99 gitListBranches: "git.listBranches",
100 gitCreateWorktree: "git.createWorktree",
101 gitRemoveWorktree: "git.removeWorktree",
102 gitCreateBranch: "git.createBranch",
103 gitCheckout: "git.checkout",
104 gitInit: "git.init",
105109 gitResolvePullRequest: "git.resolvePullRequest",
106110 gitPreparePullRequestThread: "git.preparePullRequestThread",
107111
......@@ -119,9 +123,10 @@ export const WS_METHODS = {
119123 serverUpsertKeybinding: "server.upsertKeybinding",
120124 serverGetSettings: "server.getSettings",
121125 serverUpdateSettings: "server.updateSettings",
126 serverDiscoverSourceControl: "server.discoverSourceControl",
122127
123128 // Streaming subscriptions
124 subscribeGitStatus: "subscribeGitStatus",
129 subscribeVcsStatus: "subscribeVcsStatus",
125130 subscribeTerminalEvents: "subscribeTerminalEvents",
126131 subscribeServerConfig: "subscribeServerConfig",
127132 subscribeServerLifecycle: "subscribeServerLifecycle",
......@@ -165,6 +170,11 @@ export const WsServerUpdateSettingsRpc = Rpc.make(WS_METHODS.serverUpdateSetting
165170 error: ServerSettingsError,
166171});
167172
173export const WsServerDiscoverSourceControlRpc = Rpc.make(WS_METHODS.serverDiscoverSourceControl, {
174 payload: Schema.Struct({}),
175 success: SourceControlDiscoveryResult,
176});
177
168178export const WsProjectsSearchEntriesRpc = Rpc.make(WS_METHODS.projectsSearchEntries, {
169179 payload: ProjectSearchEntriesInput,
170180 success: ProjectSearchEntriesResult,
......@@ -188,22 +198,22 @@ export const WsFilesystemBrowseRpc = Rpc.make(WS_METHODS.filesystemBrowse, {
188198 error: FilesystemBrowseError,
189199});
190200
191export const WsSubscribeGitStatusRpc = Rpc.make(WS_METHODS.subscribeGitStatus, {
192 payload: GitStatusInput,
193 success: GitStatusStreamEvent,
201export const WsSubscribeVcsStatusRpc = Rpc.make(WS_METHODS.subscribeVcsStatus, {
202 payload: VcsStatusInput,
203 success: VcsStatusStreamEvent,
194204 error: GitManagerServiceError,
195205 stream: true,
196206});
197207
198export const WsGitPullRpc = Rpc.make(WS_METHODS.gitPull, {
199 payload: GitPullInput,
200 success: GitPullResult,
208export const WsVcsPullRpc = Rpc.make(WS_METHODS.vcsPull, {
209 payload: VcsPullInput,
210 success: VcsPullResult,
201211 error: GitCommandError,
202212});
203213
204export const WsGitRefreshStatusRpc = Rpc.make(WS_METHODS.gitRefreshStatus, {
205 payload: GitStatusInput,
206 success: GitStatusResult,
214export const WsVcsRefreshStatusRpc = Rpc.make(WS_METHODS.vcsRefreshStatus, {
215 payload: VcsStatusInput,
216 success: VcsStatusResult,
207217 error: GitManagerServiceError,
208218});
209219
......@@ -226,38 +236,38 @@ export const WsGitPreparePullRequestThreadRpc = Rpc.make(WS_METHODS.gitPreparePu
226236 error: GitManagerServiceError,
227237});
228238
229export const WsGitListBranchesRpc = Rpc.make(WS_METHODS.gitListBranches, {
230 payload: GitListBranchesInput,
231 success: GitListBranchesResult,
239export const WsVcsListRefsRpc = Rpc.make(WS_METHODS.vcsListRefs, {
240 payload: VcsListRefsInput,
241 success: VcsListRefsResult,
232242 error: GitCommandError,
233243});
234244
235export const WsGitCreateWorktreeRpc = Rpc.make(WS_METHODS.gitCreateWorktree, {
236 payload: GitCreateWorktreeInput,
237 success: GitCreateWorktreeResult,
245export const WsVcsCreateWorktreeRpc = Rpc.make(WS_METHODS.vcsCreateWorktree, {
246 payload: VcsCreateWorktreeInput,
247 success: VcsCreateWorktreeResult,
238248 error: GitCommandError,
239249});
240250
241export const WsGitRemoveWorktreeRpc = Rpc.make(WS_METHODS.gitRemoveWorktree, {
242 payload: GitRemoveWorktreeInput,
251export const WsVcsRemoveWorktreeRpc = Rpc.make(WS_METHODS.vcsRemoveWorktree, {
252 payload: VcsRemoveWorktreeInput,
243253 error: GitCommandError,
244254});
245255
246export const WsGitCreateBranchRpc = Rpc.make(WS_METHODS.gitCreateBranch, {
247 payload: GitCreateBranchInput,
248 success: GitCreateBranchResult,
256export const WsVcsCreateRefRpc = Rpc.make(WS_METHODS.vcsCreateRef, {
257 payload: VcsCreateRefInput,
258 success: VcsCreateRefResult,
249259 error: GitCommandError,
250260});
251261
252export const WsGitCheckoutRpc = Rpc.make(WS_METHODS.gitCheckout, {
253 payload: GitCheckoutInput,
254 success: GitCheckoutResult,
262export const WsVcsSwitchRefRpc = Rpc.make(WS_METHODS.vcsSwitchRef, {
263 payload: VcsSwitchRefInput,
264 success: VcsSwitchRefResult,
255265 error: GitCommandError,
256266});
257267
258export const WsGitInitRpc = Rpc.make(WS_METHODS.gitInit, {
259 payload: GitInitInput,
260 error: GitCommandError,
268export const WsVcsInitRpc = Rpc.make(WS_METHODS.vcsInit, {
269 payload: VcsInitInput,
270 error: VcsError,
261271});
262272
263273export const WsTerminalOpenRpc = Rpc.make(WS_METHODS.terminalOpen, {
......@@ -370,22 +380,23 @@ export const WsRpcGroup = RpcGroup.make(
370380 WsServerUpsertKeybindingRpc,
371381 WsServerGetSettingsRpc,
372382 WsServerUpdateSettingsRpc,
383 WsServerDiscoverSourceControlRpc,
373384 WsProjectsSearchEntriesRpc,
374385 WsProjectsWriteFileRpc,
375386 WsShellOpenInEditorRpc,
376387 WsFilesystemBrowseRpc,
377 WsSubscribeGitStatusRpc,
378 WsGitPullRpc,
379 WsGitRefreshStatusRpc,
388 WsSubscribeVcsStatusRpc,
389 WsVcsPullRpc,
390 WsVcsRefreshStatusRpc,
380391 WsGitRunStackedActionRpc,
381392 WsGitResolvePullRequestRpc,
382393 WsGitPreparePullRequestThreadRpc,
383 WsGitListBranchesRpc,
384 WsGitCreateWorktreeRpc,
385 WsGitRemoveWorktreeRpc,
386 WsGitCreateBranchRpc,
387 WsGitCheckoutRpc,
388 WsGitInitRpc,
394 WsVcsListRefsRpc,
395 WsVcsCreateWorktreeRpc,
396 WsVcsRemoveWorktreeRpc,
397 WsVcsCreateRefRpc,
398 WsVcsSwitchRefRpc,
399 WsVcsInitRpc,
389400 WsTerminalOpenRpc,
390401 WsTerminalWriteRpc,
391402 WsTerminalResizeRpc,
packages/contracts/src/sourceControl.ts created+111
......@@ -0,0 +1,111 @@
1import { Schema } from "effect";
2import { PositiveInt, TrimmedNonEmptyString } from "./baseSchemas.ts";
3import { VcsDriverKind } from "./vcs.ts";
4
5export const SourceControlProviderKind = Schema.Literals([
6 "github",
7 "gitlab",
8 "azure-devops",
9 "bitbucket",
10 "unknown",
11]);
12export type SourceControlProviderKind = typeof SourceControlProviderKind.Type;
13
14export const SourceControlProviderInfo = Schema.Struct({
15 kind: SourceControlProviderKind,
16 name: TrimmedNonEmptyString,
17 baseUrl: Schema.String,
18});
19export type SourceControlProviderInfo = typeof SourceControlProviderInfo.Type;
20
21export const ChangeRequestState = Schema.Literals(["open", "closed", "merged"]);
22export type ChangeRequestState = typeof ChangeRequestState.Type;
23
24export const ChangeRequest = Schema.Struct({
25 provider: SourceControlProviderKind,
26 number: PositiveInt,
27 title: TrimmedNonEmptyString,
28 url: Schema.String,
29 baseRefName: TrimmedNonEmptyString,
30 headRefName: TrimmedNonEmptyString,
31 state: ChangeRequestState,
32 updatedAt: Schema.Option(Schema.DateTimeUtc),
33 isCrossRepository: Schema.optional(Schema.Boolean),
34 headRepositoryNameWithOwner: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
35 headRepositoryOwnerLogin: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
36});
37export type ChangeRequest = typeof ChangeRequest.Type;
38
39export const SourceControlRepositoryCloneUrls = Schema.Struct({
40 nameWithOwner: TrimmedNonEmptyString,
41 url: TrimmedNonEmptyString,
42 sshUrl: TrimmedNonEmptyString,
43});
44export type SourceControlRepositoryCloneUrls = typeof SourceControlRepositoryCloneUrls.Type;
45
46export const SourceControlDiscoveryStatus = Schema.Literals(["available", "missing"]);
47export type SourceControlDiscoveryStatus = typeof SourceControlDiscoveryStatus.Type;
48
49export const SourceControlProviderAuthStatus = Schema.Literals([
50 "authenticated",
51 "unauthenticated",
52 "unknown",
53]);
54export type SourceControlProviderAuthStatus = typeof SourceControlProviderAuthStatus.Type;
55
56export const SourceControlProviderAuth = Schema.Struct({
57 status: SourceControlProviderAuthStatus,
58 account: Schema.Option(TrimmedNonEmptyString),
59 host: Schema.Option(TrimmedNonEmptyString),
60 detail: Schema.Option(TrimmedNonEmptyString),
61});
62export type SourceControlProviderAuth = typeof SourceControlProviderAuth.Type;
63
64const SourceControlDiscoveryItemFields = {
65 label: TrimmedNonEmptyString,
66 executable: TrimmedNonEmptyString,
67 implemented: Schema.Boolean,
68 status: SourceControlDiscoveryStatus,
69 version: Schema.Option(TrimmedNonEmptyString),
70 installHint: TrimmedNonEmptyString,
71 detail: Schema.Option(TrimmedNonEmptyString),
72} as const;
73
74export const SourceControlDiscoveryItem = Schema.Struct({
75 kind: Schema.String,
76 ...SourceControlDiscoveryItemFields,
77});
78export type SourceControlDiscoveryItem = typeof SourceControlDiscoveryItem.Type;
79
80export const VcsDiscoveryItem = Schema.Struct({
81 kind: VcsDriverKind,
82 ...SourceControlDiscoveryItemFields,
83});
84export type VcsDiscoveryItem = typeof VcsDiscoveryItem.Type;
85
86export const SourceControlProviderDiscoveryItem = Schema.Struct({
87 kind: SourceControlProviderKind,
88 ...SourceControlDiscoveryItemFields,
89 auth: SourceControlProviderAuth,
90});
91export type SourceControlProviderDiscoveryItem = typeof SourceControlProviderDiscoveryItem.Type;
92
93export const SourceControlDiscoveryResult = Schema.Struct({
94 versionControlSystems: Schema.Array(VcsDiscoveryItem),
95 sourceControlProviders: Schema.Array(SourceControlProviderDiscoveryItem),
96});
97export type SourceControlDiscoveryResult = typeof SourceControlDiscoveryResult.Type;
98
99export class SourceControlProviderError extends Schema.TaggedErrorClass<SourceControlProviderError>()(
100 "SourceControlProviderError",
101 {
102 provider: SourceControlProviderKind,
103 operation: Schema.String,
104 detail: Schema.String,
105 cause: Schema.optional(Schema.Defect),
106 },
107) {
108 override get message(): string {
109 return `Source control provider ${this.provider} failed in ${this.operation}: ${this.detail}`;
110 }
111}
packages/contracts/src/vcs.ts created+154
......@@ -0,0 +1,154 @@
1import { Schema } from "effect";
2import { TrimmedNonEmptyString } from "./baseSchemas.ts";
3
4export const VcsDriverKind = Schema.Literals(["git", "jj", "unknown"]);
5export type VcsDriverKind = typeof VcsDriverKind.Type;
6
7export const VcsFreshnessSource = Schema.Literals([
8 "live-local",
9 "cached-local",
10 "cached-remote",
11 "explicit-remote",
12]);
13export type VcsFreshnessSource = typeof VcsFreshnessSource.Type;
14
15export const VcsFreshness = Schema.Struct({
16 source: VcsFreshnessSource,
17 observedAt: Schema.DateTimeUtc,
18 expiresAt: Schema.Option(Schema.DateTimeUtc),
19});
20export type VcsFreshness = typeof VcsFreshness.Type;
21
22export const VcsDriverCapabilities = Schema.Struct({
23 kind: VcsDriverKind,
24 supportsWorktrees: Schema.Boolean,
25 supportsBookmarks: Schema.Boolean,
26 supportsAtomicSnapshot: Schema.Boolean,
27 supportsPushDefaultRemote: Schema.Boolean,
28 ignoreClassifier: Schema.Literals(["native", "git-compatible-fallback"]),
29});
30export type VcsDriverCapabilities = typeof VcsDriverCapabilities.Type;
31
32export const VcsRepositoryIdentity = Schema.Struct({
33 kind: VcsDriverKind,
34 rootPath: TrimmedNonEmptyString,
35 metadataPath: Schema.NullOr(TrimmedNonEmptyString),
36 freshness: VcsFreshness,
37});
38export type VcsRepositoryIdentity = typeof VcsRepositoryIdentity.Type;
39
40export const VcsListWorkspaceFilesResult = Schema.Struct({
41 paths: Schema.Array(TrimmedNonEmptyString),
42 truncated: Schema.Boolean,
43 freshness: VcsFreshness,
44});
45export type VcsListWorkspaceFilesResult = typeof VcsListWorkspaceFilesResult.Type;
46
47export const VcsRemote = Schema.Struct({
48 name: TrimmedNonEmptyString,
49 url: TrimmedNonEmptyString,
50 pushUrl: Schema.Option(TrimmedNonEmptyString),
51 isPrimary: Schema.Boolean,
52});
53export type VcsRemote = typeof VcsRemote.Type;
54
55export const VcsListRemotesResult = Schema.Struct({
56 remotes: Schema.Array(VcsRemote),
57 freshness: VcsFreshness,
58});
59export type VcsListRemotesResult = typeof VcsListRemotesResult.Type;
60
61export class VcsProcessSpawnError extends Schema.TaggedErrorClass<VcsProcessSpawnError>()(
62 "VcsProcessSpawnError",
63 {
64 operation: Schema.String,
65 command: Schema.String,
66 cwd: Schema.String,
67 cause: Schema.Defect,
68 },
69) {
70 override get message(): string {
71 return `VCS process failed to spawn in ${this.operation}: ${this.command} (${this.cwd})`;
72 }
73}
74
75export class VcsProcessExitError extends Schema.TaggedErrorClass<VcsProcessExitError>()(
76 "VcsProcessExitError",
77 {
78 operation: Schema.String,
79 command: Schema.String,
80 cwd: Schema.String,
81 exitCode: Schema.Number,
82 detail: Schema.String,
83 },
84) {
85 override get message(): string {
86 return `VCS process failed in ${this.operation}: ${this.command} (${this.cwd}) exited with ${this.exitCode} - ${this.detail}`;
87 }
88}
89
90export class VcsProcessTimeoutError extends Schema.TaggedErrorClass<VcsProcessTimeoutError>()(
91 "VcsProcessTimeoutError",
92 {
93 operation: Schema.String,
94 command: Schema.String,
95 cwd: Schema.String,
96 timeoutMs: Schema.Number,
97 },
98) {
99 override get message(): string {
100 return `VCS process timed out in ${this.operation}: ${this.command} (${this.cwd}) after ${this.timeoutMs}ms`;
101 }
102}
103
104export class VcsOutputDecodeError extends Schema.TaggedErrorClass<VcsOutputDecodeError>()(
105 "VcsOutputDecodeError",
106 {
107 operation: Schema.String,
108 command: Schema.String,
109 cwd: Schema.String,
110 detail: Schema.String,
111 cause: Schema.optional(Schema.Defect),
112 },
113) {
114 override get message(): string {
115 return `VCS output decode failed in ${this.operation}: ${this.command} (${this.cwd}) - ${this.detail}`;
116 }
117}
118
119export class VcsRepositoryDetectionError extends Schema.TaggedErrorClass<VcsRepositoryDetectionError>()(
120 "VcsRepositoryDetectionError",
121 {
122 operation: Schema.String,
123 cwd: Schema.String,
124 detail: Schema.String,
125 cause: Schema.optional(Schema.Defect),
126 },
127) {
128 override get message(): string {
129 return `VCS repository detection failed in ${this.operation}: ${this.cwd} - ${this.detail}`;
130 }
131}
132
133export class VcsUnsupportedOperationError extends Schema.TaggedErrorClass<VcsUnsupportedOperationError>()(
134 "VcsUnsupportedOperationError",
135 {
136 operation: Schema.String,
137 kind: VcsDriverKind,
138 detail: Schema.String,
139 },
140) {
141 override get message(): string {
142 return `VCS operation is unsupported for ${this.kind} in ${this.operation}: ${this.detail}`;
143 }
144}
145
146export const VcsError = Schema.Union([
147 VcsProcessSpawnError,
148 VcsProcessExitError,
149 VcsProcessTimeoutError,
150 VcsOutputDecodeError,
151 VcsRepositoryDetectionError,
152 VcsUnsupportedOperationError,
153]);
154export type VcsError = typeof VcsError.Type;
packages/shared/package.json+4
......@@ -12,6 +12,10 @@
1212 "types": "./src/git.ts",
1313 "import": "./src/git.ts"
1414 },
15 "./sourceControl": {
16 "types": "./src/sourceControl.ts",
17 "import": "./src/sourceControl.ts"
18 },
1519 "./logging": {
1620 "types": "./src/logging.ts",
1721 "import": "./src/logging.ts"
packages/shared/src/git.test.ts+14-14
......@@ -1,4 +1,4 @@
1import type { GitStatusRemoteResult, GitStatusResult } from "@t3tools/contracts";
1import type { VcsStatusRemoteResult, VcsStatusResult } from "@t3tools/contracts";
22import { describe, expect, it } from "vitest";
33
44import {
......@@ -54,17 +54,17 @@ describe("parseGitHubRepositoryNameWithOwnerFromRemoteUrl", () => {
5454});
5555
5656describe("isTemporaryWorktreeBranch", () => {
57 it("matches the generated temporary worktree branch format", () => {
57 it("matches the generated temporary worktree refName format", () => {
5858 expect(isTemporaryWorktreeBranch(buildTemporaryWorktreeBranchName())).toBe(true);
5959 });
6060
61 it("matches generated temporary worktree branches", () => {
61 it("matches generated temporary worktree refs", () => {
6262 expect(isTemporaryWorktreeBranch(`${WORKTREE_BRANCH_PREFIX}/deadbeef`)).toBe(true);
6363 expect(isTemporaryWorktreeBranch(` ${WORKTREE_BRANCH_PREFIX}/deadbeef `)).toBe(true);
6464 expect(isTemporaryWorktreeBranch(`${WORKTREE_BRANCH_PREFIX}/DEADBEEF`)).toBe(true);
6565 });
6666
67 it("rejects non-temporary branch names", () => {
67 it("rejects non-temporary refName names", () => {
6868 expect(isTemporaryWorktreeBranch(`${WORKTREE_BRANCH_PREFIX}/feature/demo`)).toBe(false);
6969 expect(isTemporaryWorktreeBranch("main")).toBe(false);
7070 expect(isTemporaryWorktreeBranch(`${WORKTREE_BRANCH_PREFIX}/deadbeef-extra`)).toBe(false);
......@@ -73,7 +73,7 @@ describe("isTemporaryWorktreeBranch", () => {
7373
7474describe("applyGitStatusStreamEvent", () => {
7575 it("treats a remote-only update as a repository when local state is missing", () => {
76 const remote: GitStatusRemoteResult = {
76 const remote: VcsStatusRemoteResult = {
7777 hasUpstream: true,
7878 aheadCount: 2,
7979 behindCount: 1,
......@@ -82,9 +82,9 @@ describe("applyGitStatusStreamEvent", () => {
8282
8383 expect(applyGitStatusStreamEvent(null, { _tag: "remoteUpdated", remote })).toEqual({
8484 isRepo: true,
85 hasOriginRemote: false,
86 isDefaultBranch: false,
87 branch: null,
85 hasPrimaryRemote: false,
86 isDefaultRef: false,
87 refName: null,
8888 hasWorkingTreeChanges: false,
8989 workingTree: { files: [], insertions: 0, deletions: 0 },
9090 hasUpstream: true,
......@@ -95,16 +95,16 @@ describe("applyGitStatusStreamEvent", () => {
9595 });
9696
9797 it("preserves local-only fields when applying a remote update", () => {
98 const current: GitStatusResult = {
98 const current: VcsStatusResult = {
9999 isRepo: true,
100 hostingProvider: {
100 sourceControlProvider: {
101101 kind: "github",
102102 name: "GitHub",
103103 baseUrl: "https://github.com",
104104 },
105 hasOriginRemote: true,
106 isDefaultBranch: false,
107 branch: "feature/demo",
105 hasPrimaryRemote: true,
106 isDefaultRef: false,
107 refName: "feature/demo",
108108 hasWorkingTreeChanges: true,
109109 workingTree: {
110110 files: [{ path: "src/demo.ts", insertions: 1, deletions: 0 }],
......@@ -117,7 +117,7 @@ describe("applyGitStatusStreamEvent", () => {
117117 pr: null,
118118 };
119119
120 const remote: GitStatusRemoteResult = {
120 const remote: VcsStatusRemoteResult = {
121121 hasUpstream: true,
122122 aheadCount: 2,
123123 behindCount: 1,
packages/shared/src/git.ts+44-100
......@@ -1,19 +1,20 @@
11import type {
2 GitBranch,
3 GitHostingProvider,
4 GitStatusLocalResult,
5 GitStatusRemoteResult,
6 GitStatusResult,
7 GitStatusStreamEvent,
2 VcsRef,
3 SourceControlProviderInfo,
4 VcsStatusLocalResult,
5 VcsStatusRemoteResult,
6 VcsStatusResult,
7 VcsStatusStreamEvent,
88} from "@t3tools/contracts";
99import * as Effect from "effect/Effect";
1010import * as Random from "effect/Random";
11import { detectSourceControlProviderFromRemoteUrl } from "./sourceControl.ts";
1112
1213export const WORKTREE_BRANCH_PREFIX = "t3code";
1314const TEMP_WORKTREE_BRANCH_PATTERN = new RegExp(`^${WORKTREE_BRANCH_PREFIX}\\/[0-9a-f]{8}$`);
1415
1516/**
16 * Sanitize an arbitrary string into a valid, lowercase git branch fragment.
17 * Sanitize an arbitrary string into a valid, lowercase git refName fragment.
1718 * Strips quotes, collapses separators, limits to 64 chars.
1819 */
1920export function sanitizeBranchFragment(raw: string): string {
......@@ -35,7 +36,7 @@ export function sanitizeBranchFragment(raw: string): string {
3536}
3637
3738/**
38 * Sanitize a string into a `feature/…` branch name.
39 * Sanitize a string into a `feature/…` refName name.
3940 * Preserves an existing `feature/` prefix or slash-separated namespace.
4041 */
4142export function sanitizeFeatureBranchName(raw: string): string {
......@@ -49,8 +50,8 @@ export function sanitizeFeatureBranchName(raw: string): string {
4950const AUTO_FEATURE_BRANCH_FALLBACK = "feature/update";
5051
5152/**
52 * Resolve a unique `feature/…` branch name that doesn't collide with
53 * any existing branch. Appends a numeric suffix when needed.
53 * Resolve a unique `feature/…` refName name that doesn't collide with
54 * any existing refName. Appends a numeric suffix when needed.
5455 */
5556export function resolveAutoFeatureBranchName(
5657 existingBranchNames: readonly string[],
......@@ -60,7 +61,7 @@ export function resolveAutoFeatureBranchName(
6061 const resolvedBase = sanitizeFeatureBranchName(
6162 preferred && preferred.length > 0 ? preferred : AUTO_FEATURE_BRANCH_FALLBACK,
6263 );
63 const existingNames = new Set(existingBranchNames.map((branch) => branch.toLowerCase()));
64 const existingNames = new Set(existingBranchNames.map((refName) => refName.toLowerCase()));
6465
6566 if (!existingNames.has(resolvedBase)) {
6667 return resolvedBase;
......@@ -90,8 +91,8 @@ export function buildTemporaryWorktreeBranchName(): string {
9091 return `${WORKTREE_BRANCH_PREFIX}/${token}`;
9192}
9293
93export function isTemporaryWorktreeBranch(branch: string): boolean {
94 return TEMP_WORKTREE_BRANCH_PATTERN.test(branch.trim().toLowerCase());
94export function isTemporaryWorktreeBranch(refName: string): boolean {
95 return TEMP_WORKTREE_BRANCH_PATTERN.test(refName.trim().toLowerCase());
9596}
9697
9798/**
......@@ -165,98 +166,39 @@ function deriveLocalBranchNameCandidatesFromRemoteRef(
165166}
166167
167168/**
168 * Hide `origin/*` remote refs when a matching local branch already exists.
169 * Hide `origin/*` remote refs when a matching local refName already exists.
169170 */
170171export function dedupeRemoteBranchesWithLocalMatches(
171 branches: ReadonlyArray<GitBranch>,
172): ReadonlyArray<GitBranch> {
172 refs: ReadonlyArray<VcsRef>,
173): ReadonlyArray<VcsRef> {
173174 const localBranchNames = new Set(
174 branches.filter((branch) => !branch.isRemote).map((branch) => branch.name),
175 refs.filter((refName) => !refName.isRemote).map((refName) => refName.name),
175176 );
176177
177 return branches.filter((branch) => {
178 if (!branch.isRemote) {
178 return refs.filter((refName) => {
179 if (!refName.isRemote) {
179180 return true;
180181 }
181182
182 if (branch.remoteName !== "origin") {
183 if (refName.remoteName !== "origin") {
183184 return true;
184185 }
185186
186187 const localBranchCandidates = deriveLocalBranchNameCandidatesFromRemoteRef(
187 branch.name,
188 branch.remoteName,
188 refName.name,
189 refName.remoteName,
189190 );
190191 return !localBranchCandidates.some((candidate) => localBranchNames.has(candidate));
191192 });
192193}
193194
194function parseGitRemoteHost(remoteUrl: string): string | null {
195 const trimmed = remoteUrl.trim();
196 if (trimmed.length === 0) {
197 return null;
198 }
199
200 if (trimmed.startsWith("git@")) {
201 const hostWithPath = trimmed.slice("git@".length);
202 const separatorIndex = hostWithPath.search(/[:/]/);
203 if (separatorIndex <= 0) {
204 return null;
205 }
206 return hostWithPath.slice(0, separatorIndex).toLowerCase();
207 }
208
209 try {
210 return new URL(trimmed).hostname.toLowerCase();
211 } catch {
212 return null;
213 }
214}
215
216function toBaseUrl(host: string): string {
217 return `https://${host}`;
218}
219
220function isGitHubHost(host: string): boolean {
221 return host === "github.com" || host.includes("github");
222}
223
224function isGitLabHost(host: string): boolean {
225 return host === "gitlab.com" || host.includes("gitlab");
226}
227
228export function detectGitHostingProviderFromRemoteUrl(
195export function detectSourceControlProviderFromGitRemoteUrl(
229196 remoteUrl: string,
230): GitHostingProvider | null {
231 const host = parseGitRemoteHost(remoteUrl);
232 if (!host) {
233 return null;
234 }
235
236 if (isGitHubHost(host)) {
237 return {
238 kind: "github",
239 name: host === "github.com" ? "GitHub" : "GitHub Self-Hosted",
240 baseUrl: toBaseUrl(host),
241 };
242 }
243
244 if (isGitLabHost(host)) {
245 return {
246 kind: "gitlab",
247 name: host === "gitlab.com" ? "GitLab" : "GitLab Self-Hosted",
248 baseUrl: toBaseUrl(host),
249 };
250 }
251
252 return {
253 kind: "unknown",
254 name: host,
255 baseUrl: toBaseUrl(host),
256 };
197): SourceControlProviderInfo | null {
198 return detectSourceControlProviderFromRemoteUrl(remoteUrl);
257199}
258200
259const EMPTY_GIT_STATUS_REMOTE: GitStatusRemoteResult = {
201const EMPTY_GIT_STATUS_REMOTE: VcsStatusRemoteResult = {
260202 hasUpstream: false,
261203 aheadCount: 0,
262204 behindCount: 0,
......@@ -264,16 +206,16 @@ const EMPTY_GIT_STATUS_REMOTE: GitStatusRemoteResult = {
264206};
265207
266208export function mergeGitStatusParts(
267 local: GitStatusLocalResult,
268 remote: GitStatusRemoteResult | null,
269): GitStatusResult {
209 local: VcsStatusLocalResult,
210 remote: VcsStatusRemoteResult | null,
211): VcsStatusResult {
270212 return {
271213 ...local,
272214 ...(remote ?? EMPTY_GIT_STATUS_REMOTE),
273215 };
274216}
275217
276function toRemoteStatusPart(status: GitStatusResult): GitStatusRemoteResult {
218function toRemoteStatusPart(status: VcsStatusResult): VcsStatusRemoteResult {
277219 return {
278220 hasUpstream: status.hasUpstream,
279221 aheadCount: status.aheadCount,
......@@ -282,22 +224,24 @@ function toRemoteStatusPart(status: GitStatusResult): GitStatusRemoteResult {
282224 };
283225}
284226
285function toLocalStatusPart(status: GitStatusResult): GitStatusLocalResult {
227function toLocalStatusPart(status: VcsStatusResult): VcsStatusLocalResult {
286228 return {
287229 isRepo: status.isRepo,
288 ...(status.hostingProvider ? { hostingProvider: status.hostingProvider } : {}),
289 hasOriginRemote: status.hasOriginRemote,
290 isDefaultBranch: status.isDefaultBranch,
291 branch: status.branch,
230 ...(status.sourceControlProvider
231 ? { sourceControlProvider: status.sourceControlProvider }
232 : {}),
233 hasPrimaryRemote: status.hasPrimaryRemote,
234 isDefaultRef: status.isDefaultRef,
235 refName: status.refName,
292236 hasWorkingTreeChanges: status.hasWorkingTreeChanges,
293237 workingTree: status.workingTree,
294238 };
295239}
296240
297241export function applyGitStatusStreamEvent(
298 current: GitStatusResult | null,
299 event: GitStatusStreamEvent,
300): GitStatusResult {
242 current: VcsStatusResult | null,
243 event: VcsStatusStreamEvent,
244): VcsStatusResult {
301245 switch (event._tag) {
302246 case "snapshot":
303247 return mergeGitStatusParts(event.local, event.remote);
......@@ -308,9 +252,9 @@ export function applyGitStatusStreamEvent(
308252 return mergeGitStatusParts(
309253 {
310254 isRepo: true,
311 hasOriginRemote: false,
312 isDefaultBranch: false,
313 branch: null,
255 hasPrimaryRemote: false,
256 isDefaultRef: false,
257 refName: null,
314258 hasWorkingTreeChanges: false,
315259 workingTree: { files: [], insertions: 0, deletions: 0 },
316260 },
packages/shared/src/sourceControl.test.ts created+59
......@@ -0,0 +1,59 @@
1import { describe, expect, it } from "vitest";
2
3import {
4 detectSourceControlProviderFromRemoteUrl,
5 getChangeRequestTerminologyForKind,
6 resolveChangeRequestPresentation,
7} from "./sourceControl.ts";
8
9describe("source control presentation", () => {
10 it("uses merge request terminology for GitLab", () => {
11 expect(getChangeRequestTerminologyForKind("gitlab")).toEqual({
12 shortLabel: "MR",
13 singular: "merge request",
14 });
15 });
16
17 it("uses pull request terminology for GitHub-compatible providers", () => {
18 expect(getChangeRequestTerminologyForKind("github")).toEqual({
19 shortLabel: "PR",
20 singular: "pull request",
21 });
22 expect(getChangeRequestTerminologyForKind("azure-devops")).toEqual({
23 shortLabel: "PR",
24 singular: "pull request",
25 });
26 expect(getChangeRequestTerminologyForKind("bitbucket")).toEqual({
27 shortLabel: "PR",
28 singular: "pull request",
29 });
30 });
31
32 it("falls back to generic change request copy for unknown providers", () => {
33 expect(
34 resolveChangeRequestPresentation({ kind: "unknown", name: "forge", baseUrl: "" }),
35 ).toEqual(
36 expect.objectContaining({
37 shortName: "change request",
38 longName: "change request",
39 }),
40 );
41 });
42});
43
44describe("detectSourceControlProviderFromRemoteUrl", () => {
45 it("detects common source control hosts", () => {
46 expect(detectSourceControlProviderFromRemoteUrl("git@github.com:owner/repo.git")?.kind).toBe(
47 "github",
48 );
49 expect(
50 detectSourceControlProviderFromRemoteUrl("https://gitlab.com/group/repo.git")?.kind,
51 ).toBe("gitlab");
52 expect(
53 detectSourceControlProviderFromRemoteUrl("https://dev.azure.com/org/project/_git/repo")?.kind,
54 ).toBe("azure-devops");
55 expect(
56 detectSourceControlProviderFromRemoteUrl("git@bitbucket.org:workspace/repo.git")?.kind,
57 ).toBe("bitbucket");
58 });
59});
packages/shared/src/sourceControl.ts created+225
......@@ -0,0 +1,225 @@
1import type { SourceControlProviderInfo, SourceControlProviderKind } from "@t3tools/contracts";
2
3export interface ChangeRequestPresentation {
4 readonly icon: "github" | "gitlab" | "azure-devops" | "bitbucket" | "change-request";
5 readonly providerName: string;
6 readonly shortName: string;
7 readonly longName: string;
8 readonly pluralLongName: string;
9 readonly providerLongName: string;
10 readonly checkoutCommandExample: string;
11 readonly urlExample: string;
12}
13
14export interface ChangeRequestTerminology {
15 readonly shortLabel: string;
16 readonly singular: string;
17}
18
19export const DEFAULT_CHANGE_REQUEST_TERMINOLOGY: ChangeRequestTerminology = {
20 shortLabel: "PR",
21 singular: "pull request",
22};
23
24const GITHUB_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = {
25 icon: "github",
26 providerName: "GitHub",
27 shortName: "PR",
28 longName: "pull request",
29 pluralLongName: "pull requests",
30 providerLongName: "GitHub pull request",
31 checkoutCommandExample: "gh pr checkout 123",
32 urlExample: "https://github.com/owner/repo/pull/42",
33};
34
35const GITLAB_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = {
36 icon: "gitlab",
37 providerName: "GitLab",
38 shortName: "MR",
39 longName: "merge request",
40 pluralLongName: "merge requests",
41 providerLongName: "GitLab merge request",
42 checkoutCommandExample: "glab mr checkout 123",
43 urlExample: "https://gitlab.com/group/project/-/merge_requests/42",
44};
45
46const AZURE_DEVOPS_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = {
47 icon: "azure-devops",
48 providerName: "Azure DevOps",
49 shortName: "PR",
50 longName: "pull request",
51 pluralLongName: "pull requests",
52 providerLongName: "Azure DevOps pull request",
53 checkoutCommandExample: "az repos pr checkout --id 123",
54 urlExample: "https://dev.azure.com/org/project/_git/repo/pullrequest/42",
55};
56
57const BITBUCKET_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = {
58 icon: "bitbucket",
59 providerName: "Bitbucket",
60 shortName: "PR",
61 longName: "pull request",
62 pluralLongName: "pull requests",
63 providerLongName: "Bitbucket pull request",
64 checkoutCommandExample: "bb pr checkout 123",
65 urlExample: "https://bitbucket.org/workspace/repo/pull-requests/42",
66};
67
68const GENERIC_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = {
69 icon: "change-request",
70 providerName: "source control",
71 shortName: "change request",
72 longName: "change request",
73 pluralLongName: "change requests",
74 providerLongName: "change request",
75 checkoutCommandExample: "123",
76 urlExample: "#42",
77};
78
79export function resolveChangeRequestPresentation(
80 provider: SourceControlProviderInfo | null | undefined,
81): ChangeRequestPresentation {
82 switch (provider?.kind) {
83 case "github":
84 case undefined:
85 return GITHUB_CHANGE_REQUEST_PRESENTATION;
86 case "gitlab":
87 return GITLAB_CHANGE_REQUEST_PRESENTATION;
88 case "azure-devops":
89 return AZURE_DEVOPS_CHANGE_REQUEST_PRESENTATION;
90 case "bitbucket":
91 return BITBUCKET_CHANGE_REQUEST_PRESENTATION;
92 case "unknown":
93 return GENERIC_CHANGE_REQUEST_PRESENTATION;
94 }
95}
96
97export function resolveChangeRequestPresentationForKind(
98 kind: SourceControlProviderKind,
99): ChangeRequestPresentation {
100 return resolveChangeRequestPresentation({ kind, name: "", baseUrl: "" });
101}
102
103export function formatChangeRequestAction(
104 verb: "View" | "Create",
105 presentation: ChangeRequestPresentation,
106): string {
107 return `${verb} ${presentation.shortName}`;
108}
109
110export function formatCreateChangeRequestPhrase(presentation: ChangeRequestPresentation): string {
111 return `create ${presentation.shortName}`;
112}
113
114export function getChangeRequestTerminology(
115 provider: SourceControlProviderInfo | null | undefined,
116): ChangeRequestTerminology {
117 if (!provider) {
118 return DEFAULT_CHANGE_REQUEST_TERMINOLOGY;
119 }
120
121 const presentation = resolveChangeRequestPresentation(provider);
122 return {
123 shortLabel: presentation.shortName,
124 singular: presentation.longName,
125 };
126}
127
128export function getChangeRequestTerminologyForKind(
129 kind: SourceControlProviderKind,
130): ChangeRequestTerminology {
131 const presentation = resolveChangeRequestPresentationForKind(kind);
132 return {
133 shortLabel: presentation.shortName,
134 singular: presentation.longName,
135 };
136}
137
138function parseRemoteHost(remoteUrl: string): string | null {
139 const trimmed = remoteUrl.trim();
140 if (trimmed.length === 0) {
141 return null;
142 }
143
144 if (trimmed.startsWith("git@")) {
145 const hostWithPath = trimmed.slice("git@".length);
146 const separatorIndex = hostWithPath.search(/[:/]/);
147 if (separatorIndex <= 0) {
148 return null;
149 }
150 return hostWithPath.slice(0, separatorIndex).toLowerCase();
151 }
152
153 try {
154 return new URL(trimmed).hostname.toLowerCase();
155 } catch {
156 return null;
157 }
158}
159
160function toBaseUrl(host: string): string {
161 return `https://${host}`;
162}
163
164function isGitHubHost(host: string): boolean {
165 return host === "github.com" || host.includes("github");
166}
167
168function isGitLabHost(host: string): boolean {
169 return host === "gitlab.com" || host.includes("gitlab");
170}
171
172function isAzureDevOpsHost(host: string): boolean {
173 return host === "dev.azure.com" || host.endsWith(".visualstudio.com");
174}
175
176function isBitbucketHost(host: string): boolean {
177 return host === "bitbucket.org" || host.includes("bitbucket");
178}
179
180export function detectSourceControlProviderFromRemoteUrl(
181 remoteUrl: string,
182): SourceControlProviderInfo | null {
183 const host = parseRemoteHost(remoteUrl);
184 if (!host) {
185 return null;
186 }
187
188 if (isGitHubHost(host)) {
189 return {
190 kind: "github",
191 name: host === "github.com" ? "GitHub" : "GitHub Self-Hosted",
192 baseUrl: toBaseUrl(host),
193 };
194 }
195
196 if (isGitLabHost(host)) {
197 return {
198 kind: "gitlab",
199 name: host === "gitlab.com" ? "GitLab" : "GitLab Self-Hosted",
200 baseUrl: toBaseUrl(host),
201 };
202 }
203
204 if (isAzureDevOpsHost(host)) {
205 return {
206 kind: "azure-devops",
207 name: "Azure DevOps",
208 baseUrl: toBaseUrl(host),
209 };
210 }
211
212 if (isBitbucketHost(host)) {
213 return {
214 kind: "bitbucket",
215 name: host === "bitbucket.org" ? "Bitbucket" : "Bitbucket Self-Hosted",
216 baseUrl: toBaseUrl(host),
217 };
218 }
219
220 return {
221 kind: "unknown",
222 name: host,
223 baseUrl: toBaseUrl(host),
224 };
225}