diff --git a/.plans/19-version-control-phase-1-vcs-driver-foundation.md b/.plans/19-version-control-phase-1-vcs-driver-foundation.md new file mode 100644 index 0000000000000000000000000000000000000000..e71c22d0ce31f318d39a5ee769a2264f3b0b41ef --- /dev/null +++ b/.plans/19-version-control-phase-1-vcs-driver-foundation.md @@ -0,0 +1,216 @@ +# Version Control Phase 1: VCS Driver Foundation + +## Goal + +Introduce 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. + +The 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. + +## Scope + +- Add VCS-domain contracts in `packages/contracts/src/vcs.ts`. +- Add shared runtime parsing helpers in `packages/shared/src/vcs/*` only when they are useful to both server and web. +- Add server services under `apps/server/src/vcs`: + - `Services/VcsDriver.ts` + - `Services/VcsRepositoryResolver.ts` + - `Services/VcsProcess.ts` + - `Layers/GitVcsDriver.ts` + - `errors.ts` +- Migrate server callers from Git-specific terms where the operation is actually VCS-generic. +- Update active consumers to the new VCS APIs in the same phase; do not add backwards-compatible export shims. +- Leave source-control hosting providers out of this phase except for remote metadata needed to describe repository status. + +## Non-Goals + +- No GitLab, Azure DevOps, or GitHub provider rewrite yet. +- No Jujutsu driver yet, but every interface must be designed so a Jujutsu driver does not have to pretend to be Git. +- No T3 Review implementation yet. +- No broad UI redesign. + +## Driver Model + +Use provider-neutral nouns in new APIs: + +- `VcsDriver`: local repository mechanics. +- `RepositoryIdentity`: detected VCS kind, root path, common metadata path when available, remotes. +- `WorkingCopyStatus`: dirty state, changed files, aggregate insertions/deletions, current branch/bookmark/change name. +- `ChangeSet`: a committed or pending unit of change, not necessarily a Git commit. +- `RefName`: branch, bookmark, tag, or provider-specific ref. + +The initial driver capabilities should be explicit: + +```ts +export interface VcsDriverCapabilities { + readonly kind: "git" | "jj" | "sapling" | "unknown"; + readonly supportsWorktrees: boolean; + readonly supportsBookmarks: boolean; + readonly supportsAtomicSnapshot: boolean; + readonly supportsPushDefaultRemote: boolean; +} +``` + +Do 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: + +- `detectRepository(cwd)` +- `status(cwd, options)` +- `listRefs(cwd, query/pagination)` +- `checkoutRef(cwd, ref)` +- `createRef(cwd, ref, from?)` +- `createWorkspace(cwd, ref, path?)` +- `removeWorkspace(path)` +- `prepareChangeContext(cwd, filePaths?)` +- `createChange(cwd, message, options)` +- `push(cwd, target?)` +- `rangeContext(cwd, base, head)` +- `listWorkspaceFiles(cwd, options)` + +## Effect Process Layer + +Create a small reusable `VcsProcess` service instead of using `runProcess`. + +Requirements: + +- Implement with `ChildProcess` and `ChildProcessSpawner` from `effect/unstable/process`. +- Support scoped acquisition/release for long-running commands and interruption. +- Support bounded stdout/stderr collection with truncation markers. + - DO not eagerly consume full stdout/stderr, return stream apis and expose helpers for consumers so we don't consume streams to memory unnecessarily... +- Support stdin. +- Support timeout through Effect scheduling/interruption, not ad-hoc timers. +- Stream output lines to progress callbacks as Effects. +- Return a typed `ProcessOutput` value for successful execution. +- Fail with typed errors, not generic thrown exceptions. + +Errors should be schema-backed tagged classes, for example: + +- `VcsProcessSpawnError` +- `VcsProcessExitError` +- `VcsProcessTimeoutError` +- `VcsOutputDecodeError` +- `VcsRepositoryDetectionError` +- `VcsUnsupportedOperationError` + +Every 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. + +## Git Driver Rewrite + +Rewrite Git support against `VcsProcess`. + +Carry forward current behavior from: + +- `apps/server/src/git/Layers/GitCore.ts` +- `apps/server/src/git/Layers/GitCore.test.ts` +- current Git status/branch/worktree contracts + +But split the implementation into smaller modules: + +- command execution and hardening config +- repository detection +- status parsing +- branch/ref parsing +- worktree operations +- commit/range context generation +- push/pull operations + +Keep 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. + +## Freshness and Local Caching + +Define freshness rules in the VCS layer before adding more providers. Local VCS status is cheap enough to refresh often; network-backed status is not. + +Treat these as live/local: + +- repository detection for the active cwd +- working copy dirty state +- staged/unstaged/untracked file summaries +- current branch/bookmark/change name +- local branch/bookmark lists +- local worktree/workspace lists + +These 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. + +Treat these as cached or explicit-refresh only: + +- remote tracking branch refreshes +- ahead/behind counts that require network fetches +- default branch discovery from a remote provider +- remote branch lists beyond locally known refs + +The VCS driver should expose freshness metadata with status results: + +```ts +export interface VcsFreshness { + readonly source: "live-local" | "cached-local" | "cached-remote" | "explicit-remote"; + readonly observedAt: string; + readonly expiresAt?: string; +} +``` + +Remote 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`. + +Use Effect `Cache` for repository identity and expensive local metadata: + +- key by resolved repository root plus VCS kind +- invalidate on cwd/root changes and workspace mutation operations +- use short TTLs for local status caches when filesystem events are unavailable +- never hide command failures behind stale values unless the caller explicitly accepts stale data + +## Cutover Policy + +Prefer direct migration and deletion over compatibility wrappers. + +Rules: + +- Update consumers to call `VcsDriver`/`VcsRepositoryResolver` directly as soon as the new API exists. +- Delete migrated `GitCore` service methods and tests in the same PR that moves their consumers. +- Do not keep backwards-compatible export shims, barrel aliases, or old service names for convenience. +- Transitional modules are allowed only when a caller group is too complex or risky to migrate in the same PR. +- Every transitional module must have a narrow owner, a removal checklist, and a test proving it delegates to the new implementation. +- No new feature work may depend on transitional modules. + +Expected transitional candidates: + +- The highest-level `GitManager` orchestration can be migrated in slices if doing the full Commit + PR flow in one PR is too risky. +- 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. + +## Tests + +Add integration-style tests with real temporary Git repositories for the new Git driver: + +- non-repository detection +- status for clean/dirty/untracked/staged states +- branch/ref list with pagination +- checkout/create branch +- worktree create/remove +- commit context generation with file filters +- commit creation with hook progress events +- push behavior against a local bare remote +- status polling does not perform remote network refresh by default +- concurrent duplicate status requests are coalesced +- bounded output/truncation +- timeout/interruption +- typed error shape for command failure and missing executable + +Move or duplicate only the tests needed to prove behavior, then delete the old service tests in the same migration slice. + +## Migration Steps + +1. Add `vcs` contracts and tagged errors. +2. Add `VcsProcess` and unit tests around process execution semantics. +3. Add `VcsDriver` and `VcsRepositoryResolver` service contracts. +4. Implement `GitVcsDriver` with real Git command integration tests. +5. Move `GitStatusBroadcaster` and branch/worktree flows to the VCS service directly. +6. Move commit/range/push callers to the VCS service directly. +7. Delete migrated `GitCore` internals and tests as each caller group moves. +8. Add a transitional adapter only for any remaining `GitManager` path that is explicitly too complex to cut over safely in one PR. +9. Remove every transitional adapter before starting Phase 2 unless the adapter is documented as blocking on the provider cutover. + +## Acceptance Criteria + +- Current Git branch/status/worktree/commit behavior remains intact. +- New Git implementation does not depend on `processRunner.ts`. +- New errors are typed and inspectable by tests. +- VCS interfaces contain no GitHub/GitLab/Azure concepts. +- Active consumers use the new VCS APIs directly; any remaining transitional module has a written removal checklist and no compatibility export shim. +- Background status refresh is local-only by default and cannot hit provider rate limits. +- Jujutsu can be added by implementing a real driver instead of conforming to Git command semantics. +- `bun fmt`, `bun lint`, and `bun typecheck` pass. diff --git a/.plans/20-version-control-phase-2-source-control-provider-foundation.md b/.plans/20-version-control-phase-2-source-control-provider-foundation.md new file mode 100644 index 0000000000000000000000000000000000000000..ac1186ba5f9ba934f589128c3c4e2b0230e88502 --- /dev/null +++ b/.plans/20-version-control-phase-2-source-control-provider-foundation.md @@ -0,0 +1,268 @@ +# Version Control Phase 2: Source Control Provider Foundation + +## Goal + +Introduce 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. + +The 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. + +## Scope + +- Add provider-domain contracts in `packages/contracts/src/sourceControl.ts`. +- Add provider URL/reference parsing helpers in `packages/shared/src/sourceControl/*`. +- Add server services under `apps/server/src/sourceControl`: + - `Services/SourceControlProvider.ts` + - `Services/SourceControlProviderRegistry.ts` + - `Services/SourceControlProcess.ts` + - `Layers/GitHubSourceControlProvider.ts` + - `errors.ts` +- Migrate PR creation, PR lookup, default-branch lookup, clone URL lookup, and PR checkout through the provider layer. +- Update active consumers to the provider APIs directly; do not add backwards-compatible `GitHubCli` export shims. +- Keep GitHub as the only production provider at the end of this phase, but make GitLab and Azure implementation paths obvious and bounded. + +## Non-Goals + +- No GitLab implementation in this phase, except fixtures/contracts that prove the abstraction can represent merge requests. +- No Azure DevOps implementation in this phase, except URL/reference parser test cases if cheap. +- No in-app review UI yet. +- No hard dependency on one CLI forever. The first GitHub driver may use `gh`, but the interface should support REST/GraphQL implementations later. + +## Provider Model + +Use provider-neutral names: + +- `SourceControlProvider`: hosted repository and change-request mechanics. +- `ChangeRequest`: GitHub pull request, GitLab merge request, Azure pull request. +- `ChangeRequestThread`: review or discussion thread. +- `ChangeRequestComment`: top-level or inline comment. +- `ProviderRepository`: owner/project/repo identity plus clone URLs. + +Core provider operations: + +- `detectRemote(remoteUrl)` +- `checkAuth(cwd)` +- `getRepository(cwd | remoteUrl)` +- `getDefaultTargetRef(repository)` +- `listChangeRequests(repository, filters)` +- `getChangeRequest(repository, reference)` +- `createChangeRequest(repository, input)` +- `checkoutChangeRequest(cwd, changeRequest, options)` +- `getCloneUrls(repository)` + +Review-facing operations should be designed now, even if unimplemented: + +- `listReviewThreads(changeRequest)` +- `createReviewComment(changeRequest, input)` +- `replyToReviewThread(thread, input)` +- `resolveReviewThread(thread)` +- `submitReview(changeRequest, input)` + +Each operation should be guarded by capabilities: + +```ts +export interface SourceControlProviderCapabilities { + readonly kind: "github" | "gitlab" | "azure-devops" | "unknown"; + readonly supportsCreateChangeRequest: boolean; + readonly supportsCheckoutChangeRequest: boolean; + readonly supportsReviewThreads: boolean; + readonly supportsInlineComments: boolean; + readonly supportsDraftChangeRequests: boolean; +} +``` + +## Provider Registry + +Add a registry that resolves a provider from repository remotes and explicit user input. + +Rules: + +- Detection should be pure where possible and testable without spawning CLIs. +- Remote URL parsing belongs in `packages/shared`, not server-only provider layers. +- Unknown providers should return explicit unsupported-operation errors, not silently fall back to GitHub. +- Provider selection should be stable per operation and logged with enough context to debug bad remote detection. + +The registry should support multiple provider implementations at runtime, not a single dispatcher file with inline provider branches. + +## Rate Limits and Provider Caching + +Design 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. + +Default behavior: + +- Pure URL/remote parsing is always live because it is local. +- Provider detection from local remotes is live-local. +- Authentication checks are cached. +- Repository metadata is cached. +- Default branch metadata is cached. +- Change-request lists are cached and refreshed on explicit user actions or coarse intervals. +- Full review threads, comments, file diffs, and timeline data are fetched only when the user opens the relevant review surface or explicitly refreshes it. +- Create/update operations invalidate affected cache keys immediately after success. + +The provider API should make freshness explicit: + +```ts +export interface SourceControlFreshness { + readonly source: "live-local" | "cached-provider" | "live-provider"; + readonly observedAt: string; + readonly expiresAt?: string; + readonly stale?: boolean; +} + +export type ProviderRefreshPolicy = + | "cache-first" + | "stale-while-revalidate" + | "force-refresh" + | "local-only"; +``` + +Every 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`. + +Use Effect `Cache` for provider data: + +- auth status: key by provider kind, hostname, workspace identity, and account if known; TTL around minutes, not seconds +- repository metadata/default branch: key by provider repository stable ID or normalized remote URL; TTL around tens of minutes +- change-request summary lists: key by provider repository, state/filter, source ref, target ref; short TTL with stale-while-revalidate +- individual change-request summaries: key by provider repository and provider CR ID; short TTL, invalidated after create/update/comment operations +- review threads/comments/diffs: key by provider CR ID and head SHA/version when available; fetch on demand for T3 Review + +Provider drivers should surface rate-limit signals when available: + +- remaining quota +- reset time +- retry-after duration +- whether the limit is primary, secondary/abuse, or unknown + +Rate-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. + +Avoid rate-limit footguns: + +- no provider calls from render loops or fast status polling +- no listing all PRs/MRs across all repos to infer one branch state +- no silent GitHub fallback for unknown providers +- no unbounded cache cardinality for branch names or free-form search queries +- no per-thread duplicate provider refresh when multiple views observe the same repository + +## GitHub Provider Rewrite + +Rewrite GitHub support as `GitHubSourceControlProvider`. + +Carry forward behavior from: + +- `apps/server/src/git/Layers/GitHubCli.ts` +- `apps/server/src/git/Layers/GitHubCli.test.ts` +- `apps/server/src/git/githubPullRequests.ts` +- GitHub-specific `GitManager` PR paths + +Implementation requirements: + +- Use `SourceControlProcess` built on `effect/unstable/process`, not `runProcess`. +- Decode `gh api` and `gh pr --json` responses with Effect Schema. +- Use typed errors for auth failure, missing CLI, command failure, output decode failure, unsupported reference, and provider mismatch. +- Keep stdout/stderr bounded. +- Avoid global mutable auth caches unless they are Effect `Cache` values with explicit keys, TTLs, and invalidation behavior. +- Parse provider rate-limit headers or CLI/API error payloads when available and map them to typed rate-limit errors. +- Keep GitHub nouns inside the GitHub driver; convert to `ChangeRequest` at the provider boundary. + +## GitManager Cutover + +Refactor `GitManager` so it coordinates three independent services: + +- `VcsDriver` for local repository mechanics. +- `SourceControlProviderRegistry` for hosted provider selection. +- `TextGeneration` for message/body generation. + +`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. + +The Commit + PR flow should become: + +1. Resolve VCS repository and local status. +2. Resolve source-control provider from remotes. +3. Generate commit content through the existing text generation service. +4. Create local change through `VcsDriver`. +5. Push through `VcsDriver` or a narrow provider push helper only if the VCS requires provider-specific target syntax. +6. Generate change-request title/body. +7. Create the change request through `SourceControlProvider`. + +## Cutover Policy + +This phase should aggressively remove old GitHub-specific internals. + +Rules: + +- Move each active consumer directly to `SourceControlProviderRegistry` or a concrete provider test layer. +- Delete migrated `GitHubCli` methods, tests, and GitHub-specific helper exports in the same PR that moves their final consumer. +- Do not add compatibility export shims from `apps/server/src/git` to `apps/server/src/sourceControl`. +- Transitional modules are allowed only for a bounded `GitManager` slice that cannot move safely with the rest of the provider cutover. +- Every transitional module must have an owner comment, a removal checklist, and no public exports consumed by new code. +- 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. + +## GitLab and Azure Readiness + +Use the triaged references as implementation inputs, not merge targets: + +- GitLab PR #592 is useful for `glab mr` command mapping and JSON normalization. +- Azure issue #1138 defines a good first Azure slice: remote/URL detection and change-request thread setup for same-repo URLs. + +The abstraction should let Phase 3 add: + +- `GitLabSourceControlProvider` using `glab`. +- `AzureDevOpsSourceControlProvider` using `az repos pr` or REST APIs. + +No provider should need to edit GitHub code to join the registry. + +## T3 Review Design Constraint + +Do not optimize only for creation/checkout. The provider layer must be able to support a future in-app review surface. + +That means contracts should include stable IDs and enough metadata for: + +- file-level diffs +- inline review threads +- resolved/unresolved state +- top-level discussion comments +- pending review submission +- provider URL back-links + +Provider-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. + +## Tests + +Add tests at three levels: + +- Pure parser tests for GitHub, GitLab, and Azure remote URLs and change-request references. +- Provider unit tests with fake `SourceControlProcess` output and schema decode failures. +- Integration-style GitHub CLI tests only where they can run hermetically or be skipped without hiding unit coverage. + +Required cases: + +- GitHub PR URL, number, and branch-ish references. +- GitLab MR URL/reference parsing. +- Azure DevOps PR URL parsing for same-repo URLs. +- unknown provider returns unsupported-operation errors. +- missing CLI and auth failures produce distinct typed errors. +- invalid CLI JSON fails at decode boundary with useful context. + +## Migration Steps + +1. Add `sourceControl` contracts and provider-neutral schemas. +2. Add shared remote/reference parser helpers and tests. +3. Add `SourceControlProcess` and provider errors. +4. Add provider registry with GitHub-only registration. +5. Implement `GitHubSourceControlProvider` from scratch against the new process layer. +6. Cut GitHub PR operations in `GitManager` over to the provider registry. +7. Replace web PR-reference parsing with provider-neutral parser output while keeping current GitHub UX. +8. Add provider cache metrics and tests for cache hit, stale refresh, invalidation, and rate-limit error mapping. +9. Delete the migrated `GitHubCli` implementation, tests, and GitHub-specific helper exports unless an explicit transitional checklist remains. + +## Acceptance Criteria + +- Existing GitHub Commit + PR and PR checkout flows still work. +- `GitManager` no longer imports or depends on `GitHubCli`. +- Active consumers use source-control provider APIs directly; any remaining transitional module has a written removal checklist and no compatibility export shim. +- Source-control contracts can represent GitHub PRs, GitLab MRs, and Azure DevOps PRs. +- Unknown/unsupported providers fail explicitly and visibly. +- GitHub command execution does not depend on `processRunner.ts`. +- Background provider reads are cached/coalesced and do not consume provider API quota on every status refresh. +- Rate-limit responses become typed errors with retry/reset metadata where available. +- The provider API includes the review operations needed by future T3 Review work, even if they are capability-gated. +- `bun fmt`, `bun lint`, and `bun typecheck` pass. diff --git a/.plans/README.md b/.plans/README.md index 7bb69a3b9126ef4c40bbf74c161364f63ef49827..379158d4efdffd9fb429a36d90b658f29ac83b28 100644 --- a/.plans/README.md +++ b/.plans/README.md @@ -10,3 +10,5 @@ 8. `08-precommit-format-and-lint.md` 9. `09-event-state-test-expansion.md` 10. `10-unify-process-session-abstraction.md` +19. `19-version-control-phase-1-vcs-driver-foundation.md` +20. `20-version-control-phase-2-source-control-provider-foundation.md` diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index d650f62308eccd8f1566818d5027310f8c4b8e2a..8945294555ab0b628997ea5e52d9d62daf6fe8dc 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -25,10 +25,7 @@ import { import { CheckpointStoreLive } from "../src/checkpointing/Layers/CheckpointStore.ts"; import { CheckpointStore } from "../src/checkpointing/Services/CheckpointStore.ts"; -import { GitCoreLive } from "../src/git/Layers/GitCore.ts"; -import { GitCore, type GitCoreShape } from "../src/git/Services/GitCore.ts"; -import { GitStatusBroadcaster } from "../src/git/Services/GitStatusBroadcaster.ts"; -import { TextGeneration, type TextGenerationShape } from "../src/git/Services/TextGeneration.ts"; +import { TextGeneration, type TextGenerationShape } from "../src/textGeneration/TextGeneration.ts"; import { OrchestrationCommandReceiptRepositoryLive } from "../src/persistence/Layers/OrchestrationCommandReceipts.ts"; import { OrchestrationEventStoreLive } from "../src/persistence/Layers/OrchestrationEventStore.ts"; import { ProjectionCheckpointRepositoryLive } from "../src/persistence/Layers/ProjectionCheckpoints.ts"; @@ -77,6 +74,11 @@ import { import { deriveServerPaths, ServerConfig } from "../src/config.ts"; import { WorkspaceEntriesLive } from "../src/workspace/Layers/WorkspaceEntries.ts"; import { WorkspacePathsLive } from "../src/workspace/Layers/WorkspacePaths.ts"; +import * as GitVcsDriver from "../src/vcs/GitVcsDriver.ts"; +import * as VcsDriverRegistry from "../src/vcs/VcsDriverRegistry.ts"; +import { VcsStatusBroadcaster } from "../src/vcs/VcsStatusBroadcaster.ts"; +import { GitWorkflowService } from "../src/git/GitWorkflowService.ts"; +import * as VcsProcess from "../src/vcs/VcsProcess.ts"; function runGit(cwd: string, args: ReadonlyArray) { return execFileSync("git", args, { @@ -291,7 +293,7 @@ export const makeOrchestrationIntegrationHarness = ( Layer.provide(providerEventLoggersLayer), ); - const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(GitCoreLive)); + const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(VcsDriverRegistry.layer)); const projectionSnapshotQueryLayer = OrchestrationProjectionSnapshotQueryLive; const runtimeServicesLayer = Layer.mergeAll( projectionSnapshotQueryLayer, @@ -307,31 +309,31 @@ export const makeOrchestrationIntegrationHarness = ( Layer.provideMerge(runtimeServicesLayer), Layer.provideMerge(serverSettingsLayer), ); - const gitCoreLayer = Layer.succeed(GitCore, { - renameBranch: (input: Parameters[0]) => + const gitWorkflowLayer = Layer.mock(GitWorkflowService)({ + renameBranch: (input: Parameters[0]) => Effect.succeed({ branch: input.newBranch }), - } as unknown as GitCoreShape); + }); const textGenerationLayer = Layer.succeed(TextGeneration, { generateBranchName: () => Effect.succeed({ branch: "update" }), generateThreadTitle: () => Effect.succeed({ title: "New thread" }), } as unknown as TextGenerationShape); const providerCommandReactorLayer = ProviderCommandReactorLive.pipe( Layer.provideMerge(runtimeServicesLayer), - Layer.provideMerge(gitCoreLayer), + Layer.provideMerge(gitWorkflowLayer), Layer.provideMerge(textGenerationLayer), Layer.provideMerge(serverSettingsLayer), ); const checkpointReactorLayer = CheckpointReactorLive.pipe( Layer.provideMerge(runtimeServicesLayer), Layer.provideMerge( - Layer.succeed(GitStatusBroadcaster, { + Layer.succeed(VcsStatusBroadcaster, { getStatus: () => Effect.die("getStatus should not be called in this test"), refreshLocalStatus: () => Effect.succeed({ isRepo: true, - hasOriginRemote: false, - isDefaultBranch: true, - branch: "main", + hasPrimaryRemote: false, + isDefaultRef: true, + refName: "main", hasWorkingTreeChanges: false, workingTree: { files: [], insertions: 0, deletions: 0 }, }), @@ -342,11 +344,12 @@ export const makeOrchestrationIntegrationHarness = ( Layer.provideMerge( WorkspaceEntriesLive.pipe( Layer.provide(WorkspacePathsLive), - Layer.provideMerge(gitCoreLayer), + Layer.provideMerge(VcsDriverRegistry.layer), Layer.provide(NodeServices.layer), ), ), Layer.provideMerge(WorkspacePathsLive), + Layer.provideMerge(VcsProcess.layer), ); const orchestrationReactorLayer = OrchestrationReactorLive.pipe( Layer.provideMerge(runtimeIngestionLayer), diff --git a/apps/server/src/checkpointing/Errors.ts b/apps/server/src/checkpointing/Errors.ts index cb873559c16a6dfe03d58abd963a43016dc4b004..c6875e585cf996c634c6526839d1c2cba4107f01 100644 --- a/apps/server/src/checkpointing/Errors.ts +++ b/apps/server/src/checkpointing/Errors.ts @@ -1,6 +1,6 @@ import { Schema } from "effect"; import type { ProjectionRepositoryError } from "../persistence/Errors.ts"; -import { GitCommandError } from "@t3tools/contracts"; +import type { VcsError } from "@t3tools/contracts"; /** * CheckpointUnavailableError - Expected checkpoint does not exist. @@ -35,9 +35,6 @@ export class CheckpointInvariantError extends Schema.TaggedErrorClass, -): Effect.Effect { +): Effect.Effect { return Effect.gen(function* () { - const gitCore = yield* GitCore; - const result = yield* gitCore.execute({ + const process = yield* VcsProcess.VcsProcess; + const result = yield* process.run({ operation: "CheckpointStore.test.git", + command: "git", cwd, args, timeoutMs: 10_000, @@ -66,12 +70,11 @@ function initRepoWithCommit( cwd: string, ): Effect.Effect< void, - GitCommandError | PlatformError.PlatformError, - GitCore | FileSystem.FileSystem + VcsError | PlatformError.PlatformError, + VcsProcess.VcsProcess | FileSystem.FileSystem > { return Effect.gen(function* () { - const core = yield* GitCore; - yield* core.initRepo({ cwd }); + yield* git(cwd, ["init"]); yield* git(cwd, ["config", "user.email", "test@test.com"]); yield* git(cwd, ["config", "user.name", "Test"]); yield* writeTextFile(path.join(cwd, "README.md"), "# test\n"); diff --git a/apps/server/src/checkpointing/Layers/CheckpointStore.ts b/apps/server/src/checkpointing/Layers/CheckpointStore.ts index 211877e9b1ab97a7613201a35245617136053184..c8b5956460481a14f9b5b2ddc08d02fe8bc1ffbf 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointStore.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointStore.ts @@ -14,8 +14,8 @@ import { randomUUID } from "node:crypto"; import { Effect, Layer, FileSystem, Path } from "effect"; import { CheckpointInvariantError } from "../Errors.ts"; -import { GitCommandError } from "@t3tools/contracts"; -import { GitCore } from "../../git/Services/GitCore.ts"; +import { VcsProcessExitError } from "@t3tools/contracts"; +import { VcsDriverRegistry } from "../../vcs/VcsDriverRegistry.ts"; import { CheckpointStore, type CheckpointStoreShape } from "../Services/CheckpointStore.ts"; import { CheckpointRef } from "@t3tools/contracts"; @@ -24,10 +24,26 @@ const CHECKPOINT_DIFF_MAX_OUTPUT_BYTES = 10_000_000; const makeCheckpointStore = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const git = yield* GitCore; + const vcsRegistry = yield* VcsDriverRegistry; + const vcs = { + execute: (input: { + readonly operation: string; + readonly cwd: string; + readonly args: ReadonlyArray; + readonly stdin?: string; + readonly env?: NodeJS.ProcessEnv; + readonly allowNonZeroExit?: boolean; + readonly timeoutMs?: number; + readonly maxOutputBytes?: number; + readonly truncateOutputAtMaxBytes?: boolean; + }) => + vcsRegistry + .resolve({ cwd: input.cwd, requestedKind: "git" }) + .pipe(Effect.flatMap((handle) => handle.driver.execute(input))), + }; - const resolveHeadCommit = (cwd: string): Effect.Effect => - git + const resolveHeadCommit = (cwd: string) => + vcs .execute({ operation: "CheckpointStore.resolveHeadCommit", cwd, @@ -36,7 +52,7 @@ const makeCheckpointStore = Effect.gen(function* () { }) .pipe( Effect.map((result) => { - if (result.code !== 0) { + if (result.exitCode !== 0) { return null; } const commit = result.stdout.trim(); @@ -44,21 +60,18 @@ const makeCheckpointStore = Effect.gen(function* () { }), ); - const hasHeadCommit = (cwd: string): Effect.Effect => - git + const hasHeadCommit = (cwd: string) => + vcs .execute({ operation: "CheckpointStore.hasHeadCommit", cwd, args: ["rev-parse", "--verify", "HEAD"], allowNonZeroExit: true, }) - .pipe(Effect.map((result) => result.code === 0)); + .pipe(Effect.map((result) => result.exitCode === 0)); - const resolveCheckpointCommit = ( - cwd: string, - checkpointRef: CheckpointRef, - ): Effect.Effect => - git + const resolveCheckpointCommit = (cwd: string, checkpointRef: CheckpointRef) => + vcs .execute({ operation: "CheckpointStore.resolveCheckpointCommit", cwd, @@ -67,7 +80,7 @@ const makeCheckpointStore = Effect.gen(function* () { }) .pipe( Effect.map((result) => { - if (result.code !== 0) { + if (result.exitCode !== 0) { return null; } const commit = result.stdout.trim(); @@ -76,7 +89,7 @@ const makeCheckpointStore = Effect.gen(function* () { ); const isGitRepository: CheckpointStoreShape["isGitRepository"] = (cwd) => - git + vcs .execute({ operation: "CheckpointStore.isGitRepository", cwd, @@ -84,7 +97,7 @@ const makeCheckpointStore = Effect.gen(function* () { allowNonZeroExit: true, }) .pipe( - Effect.map((result) => result.code === 0 && result.stdout.trim() === "true"), + Effect.map((result) => result.exitCode === 0 && result.stdout.trim() === "true"), Effect.catch(() => Effect.succeed(false)), ); @@ -108,7 +121,7 @@ const makeCheckpointStore = Effect.gen(function* () { const headExists = yield* hasHeadCommit(input.cwd); if (headExists) { - yield* git.execute({ + yield* vcs.execute({ operation, cwd: input.cwd, args: ["read-tree", "HEAD"], @@ -116,14 +129,14 @@ const makeCheckpointStore = Effect.gen(function* () { }); } - yield* git.execute({ + yield* vcs.execute({ operation, cwd: input.cwd, args: ["add", "-A", "--", "."], env: commitEnv, }); - const writeTreeResult = yield* git.execute({ + const writeTreeResult = yield* vcs.execute({ operation, cwd: input.cwd, args: ["write-tree"], @@ -131,16 +144,17 @@ const makeCheckpointStore = Effect.gen(function* () { }); const treeOid = writeTreeResult.stdout.trim(); if (treeOid.length === 0) { - return yield* new GitCommandError({ + return yield* new VcsProcessExitError({ operation, command: "git write-tree", cwd: input.cwd, + exitCode: 0, detail: "git write-tree returned an empty tree oid.", }); } const message = `t3 checkpoint ref=${input.checkpointRef}`; - const commitTreeResult = yield* git.execute({ + const commitTreeResult = yield* vcs.execute({ operation, cwd: input.cwd, args: ["commit-tree", treeOid, "-m", message], @@ -148,15 +162,16 @@ const makeCheckpointStore = Effect.gen(function* () { }); const commitOid = commitTreeResult.stdout.trim(); if (commitOid.length === 0) { - return yield* new GitCommandError({ + return yield* new VcsProcessExitError({ operation, command: "git commit-tree", cwd: input.cwd, + exitCode: 0, detail: "git commit-tree returned an empty commit oid.", }); } - yield* git.execute({ + yield* vcs.execute({ operation, cwd: input.cwd, args: ["update-ref", input.checkpointRef, commitOid], @@ -197,12 +212,12 @@ const makeCheckpointStore = Effect.gen(function* () { return false; } - yield* git.execute({ + yield* vcs.execute({ operation, cwd: input.cwd, args: ["restore", "--source", commitOid, "--worktree", "--staged", "--", "."], }); - yield* git.execute({ + yield* vcs.execute({ operation, cwd: input.cwd, args: ["clean", "-fd", "--", "."], @@ -210,7 +225,7 @@ const makeCheckpointStore = Effect.gen(function* () { const headExists = yield* hasHeadCommit(input.cwd); if (headExists) { - yield* git.execute({ + yield* vcs.execute({ operation, cwd: input.cwd, args: ["reset", "--quiet", "--", "."], @@ -235,15 +250,16 @@ const makeCheckpointStore = Effect.gen(function* () { } if (!fromCommitOid || !toCommitOid) { - return yield* new GitCommandError({ + return yield* new VcsProcessExitError({ operation, command: "git diff", cwd: input.cwd, + exitCode: 1, detail: "Checkpoint ref is unavailable for diff operation.", }); } - const result = yield* git.execute({ + const result = yield* vcs.execute({ operation, cwd: input.cwd, args: ["diff", "--patch", "--minimal", "--no-color", fromCommitOid, toCommitOid], @@ -262,7 +278,7 @@ const makeCheckpointStore = Effect.gen(function* () { yield* Effect.forEach( input.checkpointRefs, (checkpointRef) => - git.execute({ + vcs.execute({ operation, cwd: input.cwd, args: ["update-ref", "-d", checkpointRef], diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..2d95c5219f75f81698943d94c54691c618363b8c --- /dev/null +++ b/apps/server/src/git/GitManager.test.ts @@ -0,0 +1,3179 @@ +import fs from "node:fs"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it } from "@effect/vitest"; +import { Effect, FileSystem, Layer, PlatformError, Scope } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { expect } from "vitest"; +import type { + GitActionProgressEvent, + GitPreparePullRequestThreadInput, + ModelSelection, + ThreadId, +} from "@t3tools/contracts"; + +import { GitCommandError, GitHubCliError, TextGenerationError } from "@t3tools/contracts"; +import { type GitManagerShape } from "./GitManager.ts"; +import { + type GitHubCliShape, + type GitHubPullRequestSummary, + GitHubCli, +} from "../sourceControl/GitHubCli.ts"; +import { type TextGenerationShape, TextGeneration } from "../textGeneration/TextGeneration.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import * as GitHubSourceControlProvider from "../sourceControl/GitHubSourceControlProvider.ts"; +import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; +import { makeGitManager } from "./GitManager.ts"; +import { ServerConfig } from "../config.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; +import { + ProjectSetupScriptRunner, + type ProjectSetupScriptRunnerInput, + type ProjectSetupScriptRunnerShape, +} from "../project/Services/ProjectSetupScriptRunner.ts"; + +interface FakeGhScenario { + prListSequence?: string[]; + prListByHeadSelector?: Record; + prListSequenceByHeadSelector?: Record; + createdPrUrl?: string; + defaultBranch?: string; + pullRequest?: { + number: number; + title: string; + url: string; + baseRefName: string; + headRefName: string; + state?: "open" | "closed" | "merged"; + isCrossRepository?: boolean; + headRepositoryNameWithOwner?: string | null; + headRepositoryOwnerLogin?: string | null; + }; + repositoryCloneUrls?: Record; + failWith?: GitHubCliError; +} + +function fakeGhOutput(stdout: string): VcsProcess.VcsProcessOutput { + return { + exitCode: ChildProcessSpawner.ExitCode(0), + stdout, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }; +} + +interface FakeGitTextGeneration { + generateCommitMessage: (input: { + cwd: string; + branch: string | null; + stagedSummary: string; + stagedPatch: string; + includeBranch?: boolean; + modelSelection: ModelSelection; + }) => Effect.Effect< + { subject: string; body: string; branch?: string | undefined }, + TextGenerationError + >; + generatePrContent: (input: { + cwd: string; + baseBranch: string; + headBranch: string; + commitSummary: string; + diffSummary: string; + diffPatch: string; + modelSelection: ModelSelection; + }) => Effect.Effect<{ title: string; body: string }, TextGenerationError>; + generateBranchName: (input: { + cwd: string; + message: string; + modelSelection: ModelSelection; + }) => Effect.Effect<{ branch: string }, TextGenerationError>; + generateThreadTitle: (input: { + cwd: string; + message: string; + modelSelection: ModelSelection; + }) => Effect.Effect<{ title: string }, TextGenerationError>; +} + +type FakePullRequest = NonNullable; + +function normalizeFakePullRequestSummary(raw: unknown): GitHubPullRequestSummary | null { + if (!raw || typeof raw !== "object") { + return null; + } + + const record = raw as Record; + const number = record.number; + const title = record.title; + const url = record.url; + const baseRefName = record.baseRefName; + const headRefName = record.headRefName; + const headRepository = + typeof record.headRepository === "object" && record.headRepository !== null + ? (record.headRepository as Record) + : null; + const headRepositoryOwner = + typeof record.headRepositoryOwner === "object" && record.headRepositoryOwner !== null + ? (record.headRepositoryOwner as Record) + : null; + + if ( + typeof number !== "number" || + typeof title !== "string" || + typeof url !== "string" || + typeof baseRefName !== "string" || + typeof headRefName !== "string" + ) { + return null; + } + + const state = + typeof record.state === "string" + ? record.state === "OPEN" || record.state === "open" + ? "open" + : record.state === "CLOSED" || record.state === "closed" + ? "closed" + : "merged" + : undefined; + const isCrossRepository = + typeof record.isCrossRepository === "boolean" ? record.isCrossRepository : undefined; + const headRepositoryNameWithOwner = + typeof record.headRepositoryNameWithOwner === "string" + ? record.headRepositoryNameWithOwner + : typeof headRepository?.nameWithOwner === "string" + ? headRepository.nameWithOwner + : undefined; + const headRepositoryOwnerLogin = + typeof record.headRepositoryOwnerLogin === "string" + ? record.headRepositoryOwnerLogin + : typeof headRepositoryOwner?.login === "string" + ? headRepositoryOwner.login + : undefined; + + return { + number, + title, + url, + baseRefName, + headRefName, + ...(state ? { state } : {}), + ...(isCrossRepository !== undefined ? { isCrossRepository } : {}), + ...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}), + ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}), + }; +} + +function runGitSyncForFakeGh(cwd: string, args: readonly string[]): void { + const result = spawnSync("git", args, { + cwd, + encoding: "utf8", + }); + if (result.status === 0) { + return; + } + throw new GitHubCliError({ + operation: "execute", + detail: `Failed to simulate gh checkout with git ${args.join(" ")}: ${result.stderr?.trim() || "unknown error"}`, + }); +} + +function isGitHubCliError(error: unknown): error is GitHubCliError { + return ( + typeof error === "object" && + error !== null && + "_tag" in error && + (error as { _tag?: unknown })._tag === "GitHubCliError" + ); +} + +function makeTempDir( + prefix: string, +): Effect.Effect { + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + return yield* fileSystem.makeTempDirectoryScoped({ prefix }); + }); +} + +function removePath( + targetPath: string, +): Effect.Effect { + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem.remove(targetPath, { recursive: true, force: true }); + }); +} + +function makeDirectory( + dirPath: string, +): Effect.Effect { + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem.makeDirectory(dirPath, { recursive: true }); + }); +} + +function runGit( + cwd: string, + args: readonly string[], + allowNonZeroExit = false, +): Effect.Effect< + { + readonly exitCode: GitVcsDriver.ExecuteGitResult["exitCode"]; + readonly stdout: string; + readonly stderr: string; + }, + GitCommandError, + GitVcsDriver.GitVcsDriver +> { + return Effect.gen(function* () { + const git = yield* GitVcsDriver.GitVcsDriver; + const result = yield* git.execute({ + operation: "GitManager.test.runGit", + cwd, + args, + allowNonZeroExit, + }); + return { + exitCode: result.exitCode, + stdout: result.stdout, + stderr: result.stderr, + }; + }); +} + +function initRepo( + cwd: string, +): Effect.Effect< + void, + PlatformError.PlatformError | GitCommandError, + FileSystem.FileSystem | Scope.Scope | GitVcsDriver.GitVcsDriver +> { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* runGit(cwd, ["init", "--initial-branch=main"]); + yield* runGit(cwd, ["config", "user.email", "test@example.com"]); + yield* runGit(cwd, ["config", "user.name", "Test User"]); + yield* fs.writeFileString(path.join(cwd, "README.md"), "hello\n"); + yield* runGit(cwd, ["add", "README.md"]); + yield* runGit(cwd, ["commit", "-m", "Initial commit"]); + }); +} + +function createBareRemote(): Effect.Effect< + string, + PlatformError.PlatformError | GitCommandError, + FileSystem.FileSystem | Scope.Scope | GitVcsDriver.GitVcsDriver +> { + return Effect.gen(function* () { + const remoteDir = yield* makeTempDir("t3code-git-remote-"); + yield* runGit(remoteDir, ["init", "--bare"]); + return remoteDir; + }); +} + +function configureRemote( + cwd: string, + remoteName: string, + remotePath: string, + fetchNamespace: string, +): Effect.Effect { + return Effect.gen(function* () { + yield* runGit(cwd, ["config", `remote.${remoteName}.url`, remotePath]); + yield* runGit(cwd, [ + "config", + "--replace-all", + `remote.${remoteName}.fetch`, + `+refs/heads/*:refs/remotes/${fetchNamespace}/*`, + ]); + }); +} + +function createTextGeneration(overrides: Partial = {}): TextGenerationShape { + const implementation: FakeGitTextGeneration = { + generateCommitMessage: (input) => + Effect.succeed({ + subject: "Implement stacked git actions", + body: "", + ...(input.includeBranch ? { branch: "feature/implement-stacked-git-actions" } : {}), + }), + generatePrContent: () => + Effect.succeed({ + title: "Add stacked git actions", + body: "## Summary\n- Add stacked git workflow\n\n## Testing\n- Not run", + }), + generateBranchName: () => + Effect.succeed({ + branch: "update-workflow", + }), + generateThreadTitle: () => + Effect.succeed({ + title: "Update workflow", + }), + ...overrides, + }; + + return { + generateCommitMessage: (input) => + implementation.generateCommitMessage(input).pipe( + Effect.mapError( + (cause) => + new TextGenerationError({ + operation: "generateCommitMessage", + detail: "fake text generation failed", + ...(cause !== undefined ? { cause } : {}), + }), + ), + ), + generatePrContent: (input) => + implementation.generatePrContent(input).pipe( + Effect.mapError( + (cause) => + new TextGenerationError({ + operation: "generatePrContent", + detail: "fake text generation failed", + ...(cause !== undefined ? { cause } : {}), + }), + ), + ), + generateBranchName: (input) => + implementation.generateBranchName(input).pipe( + Effect.mapError( + (cause) => + new TextGenerationError({ + operation: "generateBranchName", + detail: "fake text generation failed", + ...(cause !== undefined ? { cause } : {}), + }), + ), + ), + generateThreadTitle: (input) => + implementation.generateThreadTitle(input).pipe( + Effect.mapError( + (cause) => + new TextGenerationError({ + operation: "generateThreadTitle", + detail: "fake text generation failed", + ...(cause !== undefined ? { cause } : {}), + }), + ), + ), + }; +} + +function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { + service: GitHubCliShape; + ghCalls: string[]; +} { + const prListQueue = [...(scenario.prListSequence ?? [])]; + const prListQueueByHeadSelector = new Map( + Object.entries(scenario.prListSequenceByHeadSelector ?? {}).map(([headSelector, values]) => [ + headSelector, + [...values], + ]), + ); + const ghCalls: string[] = []; + + const execute: GitHubCliShape["execute"] = (input) => { + const args = [...input.args]; + ghCalls.push(args.join(" ")); + + if (scenario.failWith) { + return Effect.fail(scenario.failWith); + } + + if (args[0] === "pr" && args[1] === "list") { + const headSelectorIndex = args.findIndex((value) => value === "--head"); + const headSelector = + headSelectorIndex >= 0 && headSelectorIndex < args.length - 1 + ? args[headSelectorIndex + 1] + : undefined; + const mappedQueue = + typeof headSelector === "string" + ? prListQueueByHeadSelector.get(headSelector)?.shift() + : undefined; + const mappedStdout = + typeof headSelector === "string" + ? scenario.prListByHeadSelector?.[headSelector] + : undefined; + const stdout = (mappedQueue ?? mappedStdout ?? prListQueue.shift() ?? "[]") + "\n"; + return Effect.succeed(fakeGhOutput(stdout)); + } + + if (args[0] === "pr" && args[1] === "create") { + return Effect.succeed( + fakeGhOutput( + (scenario.createdPrUrl ?? "https://github.com/pingdotgg/codething-mvp/pull/101") + "\n", + ), + ); + } + + if (args[0] === "pr" && args[1] === "view") { + const pullRequest: FakePullRequest = scenario.pullRequest ?? { + number: 101, + title: "Pull request", + url: "https://github.com/pingdotgg/codething-mvp/pull/101", + baseRefName: "main", + headRefName: "feature/pull-request", + state: "open", + }; + return Effect.succeed( + fakeGhOutput( + JSON.stringify({ + ...pullRequest, + ...(pullRequest.headRepositoryNameWithOwner + ? { + headRepository: { + nameWithOwner: pullRequest.headRepositoryNameWithOwner, + }, + } + : {}), + ...(pullRequest.headRepositoryOwnerLogin + ? { + headRepositoryOwner: { + login: pullRequest.headRepositoryOwnerLogin, + }, + } + : {}), + }) + "\n", + ), + ); + } + + if (args[0] === "pr" && args[1] === "checkout") { + return Effect.try({ + try: () => { + const headBranch = scenario.pullRequest?.headRefName; + if (headBranch) { + const existingBranch = spawnSync( + "git", + ["show-ref", "--verify", "--quiet", `refs/heads/${headBranch}`], + { + cwd: input.cwd, + encoding: "utf8", + }, + ); + if (existingBranch.status === 0) { + runGitSyncForFakeGh(input.cwd, ["checkout", headBranch]); + } else { + runGitSyncForFakeGh(input.cwd, ["checkout", "-b", headBranch]); + } + } + return fakeGhOutput(""); + }, + catch: (error) => + isGitHubCliError(error) + ? error + : new GitHubCliError({ + operation: "execute", + detail: + error instanceof Error + ? `Failed to simulate gh checkout: ${error.message}` + : "Failed to simulate gh checkout.", + }), + }); + } + + if (args[0] === "repo" && args[1] === "view") { + const repository = args[2]; + if (typeof repository === "string" && args.includes("nameWithOwner,url,sshUrl")) { + const cloneUrls = scenario.repositoryCloneUrls?.[repository]; + if (!cloneUrls) { + return Effect.fail( + new GitHubCliError({ + operation: "execute", + detail: `Unexpected repository lookup: ${repository}`, + }), + ); + } + return Effect.succeed( + fakeGhOutput( + JSON.stringify({ + nameWithOwner: repository, + url: cloneUrls.url, + sshUrl: cloneUrls.sshUrl, + }) + "\n", + ), + ); + } + return Effect.succeed(fakeGhOutput(`${scenario.defaultBranch ?? "main"}\n`)); + } + + return Effect.fail( + new GitHubCliError({ + operation: "execute", + detail: `Unexpected gh command: ${args.join(" ")}`, + }), + ); + }; + + return { + service: { + execute, + listOpenPullRequests: (input) => + execute({ + cwd: input.cwd, + args: [ + "pr", + "list", + "--head", + input.headSelector, + "--state", + "open", + "--limit", + String(input.limit ?? 1), + "--json", + "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", + ], + }).pipe( + Effect.map((result) => JSON.parse(result.stdout) as unknown[]), + Effect.map((raw) => + raw + .map((entry) => normalizeFakePullRequestSummary(entry)) + .filter((entry): entry is GitHubPullRequestSummary => entry !== null), + ), + ), + createPullRequest: (input) => + execute({ + cwd: input.cwd, + args: [ + "pr", + "create", + "--base", + input.baseBranch, + "--head", + input.headSelector, + "--title", + input.title, + "--body-file", + input.bodyFile, + ], + }).pipe(Effect.asVoid), + getDefaultBranch: (input) => + execute({ + cwd: input.cwd, + args: ["repo", "view", "--json", "defaultBranchRef", "--jq", ".defaultBranchRef.name"], + }).pipe( + Effect.map((result) => { + const value = result.stdout.trim(); + return value.length > 0 ? value : null; + }), + ), + getPullRequest: (input) => + execute({ + cwd: input.cwd, + args: [ + "pr", + "view", + input.reference, + "--json", + "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", + ], + }).pipe(Effect.map((result) => JSON.parse(result.stdout) as GitHubPullRequestSummary)), + getRepositoryCloneUrls: (input) => + execute({ + cwd: input.cwd, + args: ["repo", "view", input.repository, "--json", "nameWithOwner,url,sshUrl"], + }).pipe(Effect.map((result) => JSON.parse(result.stdout))), + checkoutPullRequest: (input) => + execute({ + cwd: input.cwd, + args: ["pr", "checkout", input.reference, ...(input.force ? ["--force"] : [])], + }).pipe(Effect.asVoid), + }, + ghCalls, + }; +} + +function runStackedAction( + manager: GitManagerShape, + input: { + cwd: string; + action: "commit" | "push" | "create_pr" | "commit_push" | "commit_push_pr"; + actionId?: string; + commitMessage?: string; + featureBranch?: boolean; + filePaths?: readonly string[]; + }, + options?: Parameters[1], +) { + return manager.runStackedAction( + { + ...input, + actionId: input.actionId ?? "test-action-id", + }, + options, + ); +} + +function resolvePullRequest(manager: GitManagerShape, input: { cwd: string; reference: string }) { + return manager.resolvePullRequest(input); +} + +function preparePullRequestThread( + manager: GitManagerShape, + input: GitPreparePullRequestThreadInput, +) { + return manager.preparePullRequestThread(input); +} + +function makeManager(input?: { + ghScenario?: FakeGhScenario; + textGeneration?: Partial; + setupScriptRunner?: ProjectSetupScriptRunnerShape; +}) { + const { service: gitHubCli, ghCalls } = createGitHubCliWithFakeGh(input?.ghScenario); + const textGeneration = createTextGeneration(input?.textGeneration); + const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-git-manager-test-", + }); + + const serverSettingsLayer = ServerSettingsService.layerTest(); + + const vcsDriverLayer = GitVcsDriver.layer.pipe( + Layer.provideMerge(VcsProcess.layer), + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(ServerConfigLayer), + ); + const sourceControlRegistryLayer = Layer.effect( + SourceControlProviderRegistry.SourceControlProviderRegistry, + GitHubSourceControlProvider.make().pipe( + Effect.map((provider) => + SourceControlProviderRegistry.SourceControlProviderRegistry.of({ + get: () => Effect.succeed(provider), + resolveHandle: () => Effect.succeed({ provider, context: null }), + resolve: () => Effect.succeed(provider), + }), + ), + Effect.provide(Layer.succeed(GitHubCli, gitHubCli)), + ), + ); + + const managerLayer = Layer.mergeAll( + Layer.succeed(TextGeneration, textGeneration), + Layer.succeed( + ProjectSetupScriptRunner, + input?.setupScriptRunner ?? { + runForThread: () => Effect.succeed({ status: "no-script" as const }), + }, + ), + vcsDriverLayer, + serverSettingsLayer, + ).pipe(Layer.provideMerge(sourceControlRegistryLayer), Layer.provideMerge(NodeServices.layer)); + + return makeGitManager().pipe( + Effect.provide(managerLayer), + Effect.map((manager) => ({ manager, ghCalls })), + ); +} + +const asThreadId = (threadId: string) => threadId as ThreadId; + +const GitManagerTestLayer = GitVcsDriver.layer.pipe( + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-manager-test-" })), + Layer.provideMerge(VcsProcess.layer), + Layer.provideMerge(NodeServices.layer), +); + +it.layer(GitManagerTestLayer)("GitManager", (it) => { + it.effect("status includes PR metadata when branch already has an open PR", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/status-open-pr"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-open-pr"]); + + const { manager } = yield* makeManager({ + ghScenario: { + prListSequence: [ + JSON.stringify([ + { + number: 13, + title: "Existing PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/13", + baseRefName: "main", + headRefName: "feature/status-open-pr", + }, + ]), + ], + }, + }); + + const status = yield* manager.status({ cwd: repoDir }); + expect(status.isRepo).toBe(true); + expect(status.hasPrimaryRemote).toBe(true); + expect(status.isDefaultRef).toBe(false); + expect(status.refName).toBe("feature/status-open-pr"); + expect(status.pr).toEqual({ + number: 13, + title: "Existing PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/13", + baseRef: "main", + headRef: "feature/status-open-pr", + state: "open", + }); + }), + ); + + it.effect("status trims PR metadata returned by gh before publishing it", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/status-trimmed-pr"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-trimmed-pr"]); + + const { manager } = yield* makeManager({ + ghScenario: { + prListSequence: [ + JSON.stringify([ + { + number: 14, + title: " Existing PR title \n", + url: " https://github.com/pingdotgg/codething-mvp/pull/14 ", + baseRefName: " main ", + headRefName: "\tfeature/status-trimmed-pr\t", + }, + ]), + ], + }, + }); + + const status = yield* manager.status({ cwd: repoDir }); + + expect(status.pr).toEqual({ + number: 14, + title: "Existing PR title", + url: "https://github.com/pingdotgg/codething-mvp/pull/14", + baseRef: "main", + headRef: "feature/status-trimmed-pr", + state: "open", + }); + }), + ); + + it.effect("status ignores invalid gh pr list entries and keeps valid ones", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/status-valid-pr-entry"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-valid-pr-entry"]); + + const { manager } = yield* makeManager({ + ghScenario: { + prListSequence: [ + JSON.stringify([ + { + number: 0, + title: "invalid", + url: "https://github.com/pingdotgg/codething-mvp/pull/0", + baseRefName: "main", + headRefName: "feature/invalid", + }, + { + number: 15, + title: " Valid PR title ", + url: " https://github.com/pingdotgg/codething-mvp/pull/15 ", + baseRefName: " main ", + headRefName: "\tfeature/status-valid-pr-entry\t", + headRepository: { + nameWithOwner: " ", + }, + headRepositoryOwner: { + login: " ", + }, + }, + ]), + ], + }, + }); + + const status = yield* manager.status({ cwd: repoDir }); + + expect(status.pr).toEqual({ + number: 15, + title: "Valid PR title", + url: "https://github.com/pingdotgg/codething-mvp/pull/15", + baseRef: "main", + headRef: "feature/status-valid-pr-entry", + state: "open", + }); + }), + ); + + it.effect("status preserves lowercase merged and closed PR states from gh json", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/status-lowercase-state"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-lowercase-state"]); + + const { manager } = yield* makeManager({ + ghScenario: { + prListSequence: [ + JSON.stringify([ + { + number: 16, + title: "Closed PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/16", + baseRefName: "main", + headRefName: "feature/status-lowercase-state", + state: "closed", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + { + number: 17, + title: "Merged PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/17", + baseRefName: "main", + headRefName: "feature/status-lowercase-state", + state: "merged", + updatedAt: "2026-01-02T00:00:00.000Z", + }, + ]), + ], + }, + }); + + const status = yield* manager.status({ cwd: repoDir }); + + expect(status.pr).toEqual({ + number: 17, + title: "Merged PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/17", + baseRef: "main", + headRef: "feature/status-lowercase-state", + state: "merged", + }); + }), + ); + + it.effect("status returns an explicit non-repo result for non-git directories", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir("t3code-git-manager-non-repo-"); + const { manager } = yield* makeManager(); + + const status = yield* manager.status({ cwd }); + + expect(status).toEqual({ + isRepo: false, + hasPrimaryRemote: false, + isDefaultRef: false, + refName: null, + hasWorkingTreeChanges: false, + workingTree: { + files: [], + insertions: 0, + deletions: 0, + }, + hasUpstream: false, + aheadCount: 0, + behindCount: 0, + pr: null, + }); + }), + ); + + it.effect("status returns an explicit non-repo result for deleted directories", () => + Effect.gen(function* () { + const rootDir = yield* makeTempDir("t3code-git-manager-missing-dir-"); + const cwd = path.join(rootDir, "deleted-repo"); + yield* makeDirectory(cwd); + yield* removePath(cwd); + const { manager } = yield* makeManager(); + + const status = yield* manager.status({ cwd }); + + expect(status).toEqual({ + isRepo: false, + hasPrimaryRemote: false, + isDefaultRef: false, + refName: null, + hasWorkingTreeChanges: false, + workingTree: { + files: [], + insertions: 0, + deletions: 0, + }, + hasUpstream: false, + aheadCount: 0, + behindCount: 0, + pr: null, + }); + }), + ); + + it.effect("status briefly caches repeated lookups for the same cwd", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/status-cache"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-cache"]); + + const existingPr = { + number: 113, + title: "Cached PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/113", + baseRefName: "main", + headRefName: "feature/status-cache", + }; + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListSequence: [JSON.stringify([existingPr]), JSON.stringify([existingPr])], + }, + }); + + const first = yield* manager.status({ cwd: repoDir }); + const second = yield* manager.status({ cwd: repoDir }); + + expect(first.pr?.number).toBe(113); + expect(second.pr?.number).toBe(113); + expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(1); + }), + ); + + it.effect( + "status ignores unrelated fork PRs when the current branch tracks the same repository", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + + const { manager } = yield* makeManager({ + ghScenario: { + prListSequence: [ + JSON.stringify([ + { + number: 1661, + title: "Fork PR from main", + url: "https://github.com/pingdotgg/t3code/pull/1661", + baseRefName: "main", + headRefName: "main", + state: "OPEN", + updatedAt: "2026-04-01T15:00:00Z", + isCrossRepository: true, + headRepository: { + nameWithOwner: "lnieuwenhuis/t3code", + }, + headRepositoryOwner: { + login: "lnieuwenhuis", + }, + }, + ]), + ], + }, + }); + + const status = yield* manager.status({ cwd: repoDir }); + expect(status.refName).toBe("main"); + expect(status.pr).toBeNull(); + }), + ); + + it.effect( + "status detects cross-repo PRs from the upstream remote URL owner", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); + yield* runGit(repoDir, ["checkout", "-b", "statemachine"]); + fs.writeFileSync(path.join(repoDir, "fork-pr.txt"), "fork pr\n"); + yield* runGit(repoDir, ["add", "fork-pr.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Fork PR branch"]); + yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]); + yield* runGit(repoDir, ["checkout", "-b", "t3code/pr-488/statemachine"]); + yield* runGit(repoDir, ["branch", "--set-upstream-to", "fork-seed/statemachine"]); + yield* runGit(repoDir, [ + "config", + "remote.fork-seed.url", + "git@github.com:jasonLaster/codething-mvp.git", + ]); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListSequence: [ + JSON.stringify([]), + JSON.stringify([]), + JSON.stringify([ + { + number: 488, + title: "Rebase this PR on latest main", + url: "https://github.com/pingdotgg/codething-mvp/pull/488", + baseRefName: "main", + headRefName: "statemachine", + state: "OPEN", + updatedAt: "2026-03-10T07:00:00Z", + isCrossRepository: true, + headRepository: { + nameWithOwner: "jasonLaster/codething-mvp", + }, + headRepositoryOwner: { + login: "jasonLaster", + }, + }, + ]), + ], + }, + }); + + const status = yield* manager.status({ cwd: repoDir }); + expect(status.refName).toBe("t3code/pr-488/statemachine"); + expect(status.pr).toEqual({ + number: 488, + title: "Rebase this PR on latest main", + url: "https://github.com/pingdotgg/codething-mvp/pull/488", + baseRef: "main", + headRef: "statemachine", + state: "open", + }); + expect(ghCalls).toContain( + "pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + ); + }), + 20_000, + ); + + it.effect( + "status ignores synthetic local branch aliases when the upstream remote name contains slashes", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originDir = yield* createBareRemote(); + const upstreamDir = yield* createBareRemote(); + yield* configureRemote(repoDir, "origin", originDir, "origin"); + yield* configureRemote(repoDir, "my-org/upstream", upstreamDir, "my-org/upstream"); + + yield* runGit(repoDir, ["checkout", "-b", "effect-atom"]); + yield* runGit(repoDir, ["push", "-u", "origin", "effect-atom"]); + yield* runGit(repoDir, ["push", "-u", "my-org/upstream", "effect-atom"]); + yield* runGit(repoDir, [ + "config", + "remote.origin.url", + "git@github.com:pingdotgg/codething-mvp.git", + ]); + yield* runGit(repoDir, ["config", "remote.origin.pushurl", originDir]); + yield* runGit(repoDir, [ + "config", + "remote.my-org/upstream.url", + "git@github.com:pingdotgg/codething-mvp.git", + ]); + yield* runGit(repoDir, ["config", "remote.my-org/upstream.pushurl", upstreamDir]); + yield* runGit(repoDir, ["checkout", "main"]); + yield* runGit(repoDir, ["branch", "-D", "effect-atom"]); + yield* runGit(repoDir, ["checkout", "--track", "my-org/upstream/effect-atom"]); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListByHeadSelector: { + "effect-atom": JSON.stringify([ + { + number: 1618, + title: "Correct PR", + url: "https://github.com/pingdotgg/t3code/pull/1618", + baseRefName: "main", + headRefName: "effect-atom", + state: "OPEN", + updatedAt: "2026-03-01T10:00:00Z", + }, + ]), + "upstream/effect-atom": JSON.stringify([ + { + number: 1518, + title: "Wrong PR", + url: "https://github.com/pingdotgg/t3code/pull/1518", + baseRefName: "main", + headRefName: "upstream/effect-atom", + state: "OPEN", + updatedAt: "2026-04-01T10:00:00Z", + }, + ]), + "pingdotgg:effect-atom": JSON.stringify([]), + "my-org/upstream:effect-atom": JSON.stringify([]), + "pingdotgg:upstream/effect-atom": JSON.stringify([ + { + number: 1518, + title: "Wrong PR", + url: "https://github.com/pingdotgg/t3code/pull/1518", + baseRefName: "main", + headRefName: "upstream/effect-atom", + state: "OPEN", + updatedAt: "2026-04-01T10:00:00Z", + }, + ]), + "my-org/upstream:upstream/effect-atom": JSON.stringify([ + { + number: 1518, + title: "Wrong PR", + url: "https://github.com/pingdotgg/t3code/pull/1518", + baseRefName: "main", + headRefName: "upstream/effect-atom", + state: "OPEN", + updatedAt: "2026-04-01T10:00:00Z", + }, + ]), + }, + }, + }); + + const status = yield* manager.status({ cwd: repoDir }); + expect(status.refName).toBe("upstream/effect-atom"); + expect(status.pr).toEqual({ + number: 1618, + title: "Correct PR", + url: "https://github.com/pingdotgg/t3code/pull/1618", + baseRef: "main", + headRef: "effect-atom", + state: "open", + }); + expect(ghCalls.some((call) => call.includes("pr list --head upstream/effect-atom "))).toBe( + false, + ); + expect( + ghCalls.some((call) => call.includes("pr list --head pingdotgg:upstream/effect-atom ")), + ).toBe(false); + expect( + ghCalls.some((call) => + call.includes("pr list --head my-org/upstream:upstream/effect-atom "), + ), + ).toBe(false); + }), + 20_000, + ); + + it.effect("status returns merged PR state when latest PR was merged", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/status-merged-pr"]); + + const { manager } = yield* makeManager({ + ghScenario: { + prListSequence: [ + JSON.stringify([ + { + number: 22, + title: "Merged PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/22", + baseRefName: "main", + headRefName: "feature/status-merged-pr", + state: "MERGED", + mergedAt: "2026-01-30T10:00:00Z", + updatedAt: "2026-01-30T10:00:00Z", + }, + ]), + ], + }, + }); + + const status = yield* manager.status({ cwd: repoDir }); + expect(status.refName).toBe("feature/status-merged-pr"); + expect(status.pr).toEqual({ + number: 22, + title: "Merged PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/22", + baseRef: "main", + headRef: "feature/status-merged-pr", + state: "merged", + }); + }), + ); + + it.effect("status hides merged PRs on the default branch", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + + const { manager } = yield* makeManager({ + ghScenario: { + prListSequence: [ + JSON.stringify([ + { + number: 23, + title: "Merged PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/23", + baseRefName: "feature/status-default-branch-target", + headRefName: "main", + state: "MERGED", + mergedAt: "2026-01-30T10:00:00Z", + updatedAt: "2026-01-30T10:00:00Z", + }, + ]), + ], + }, + }); + + const status = yield* manager.status({ cwd: repoDir }); + expect(status.refName).toBe("main"); + expect(status.pr).toBeNull(); + }), + ); + + it.effect("status prefers open PR when merged PR has newer updatedAt", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/status-open-over-merged"]); + + const { manager } = yield* makeManager({ + ghScenario: { + prListSequence: [ + JSON.stringify([ + { + number: 45, + title: "Merged PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/45", + baseRefName: "main", + headRefName: "feature/status-open-over-merged", + state: "MERGED", + mergedAt: "2026-01-31T10:00:00Z", + updatedAt: "2026-02-01T10:00:00Z", + }, + { + number: 46, + title: "Open PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/46", + baseRefName: "main", + headRefName: "feature/status-open-over-merged", + state: "OPEN", + updatedAt: "2026-01-30T10:00:00Z", + }, + ]), + ], + }, + }); + + const status = yield* manager.status({ cwd: repoDir }); + expect(status.refName).toBe("feature/status-open-over-merged"); + expect(status.pr).toEqual({ + number: 46, + title: "Open PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/46", + baseRef: "main", + headRef: "feature/status-open-over-merged", + state: "open", + }); + }), + ); + + it.effect("status is resilient to gh lookup failures and returns pr null", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/status-no-gh"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-no-gh"]); + + const { manager } = yield* makeManager({ + ghScenario: { + failWith: new GitHubCliError({ + operation: "execute", + detail: "GitHub CLI (`gh`) is required but not available on PATH.", + }), + }, + }); + + const status = yield* manager.status({ cwd: repoDir }); + expect(status.refName).toBe("feature/status-no-gh"); + expect(status.pr).toBeNull(); + }), + ); + + it.effect("creates a commit when working tree is dirty", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + fs.writeFileSync(path.join(repoDir, "README.md"), "hello\nworld\n"); + + const { manager } = yield* makeManager(); + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit", + }); + + expect(result.branch.status).toBe("skipped_not_requested"); + expect(result.commit.status).toBe("created"); + expect(result.push.status).toBe("skipped_not_requested"); + expect(result.pr.status).toBe("skipped_not_requested"); + expect(result.toast).toMatchObject({ + description: "Implement stacked git actions", + cta: { + kind: "run_action", + label: "Push", + action: { + kind: "push", + }, + }, + }); + expect(result.toast.title).toMatch(/^Committed [0-9a-f]{7}$/); + expect( + yield* runGit(repoDir, ["log", "-1", "--pretty=%s"]).pipe( + Effect.map((result) => result.stdout.trim()), + ), + ).toBe("Implement stacked git actions"); + }), + ); + + it.effect("uses custom commit message when provided", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + fs.writeFileSync(path.join(repoDir, "README.md"), "hello\ncustom\n"); + let generatedCount = 0; + + const { manager } = yield* makeManager({ + textGeneration: { + generateCommitMessage: (input) => + Effect.sync(() => { + generatedCount += 1; + return { + subject: "this should not be used", + body: "", + ...(input.includeBranch ? { branch: "feature/unused" } : {}), + }; + }), + }, + }); + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit", + commitMessage: "feat: custom summary line\n\n- details from user", + }); + + expect(result.branch.status).toBe("skipped_not_requested"); + expect(result.commit.status).toBe("created"); + expect(result.commit.subject).toBe("feat: custom summary line"); + expect(generatedCount).toBe(0); + expect( + yield* runGit(repoDir, ["log", "-1", "--pretty=%s"]).pipe( + Effect.map((result) => result.stdout.trim()), + ), + ).toBe("feat: custom summary line"); + expect( + yield* runGit(repoDir, ["log", "-1", "--pretty=%b"]).pipe( + Effect.map((result) => result.stdout.trim()), + ), + ).toContain("- details from user"); + }), + ); + + it.effect("commits only selected files when filePaths is provided", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + fs.writeFileSync(path.join(repoDir, "a.txt"), "file a\n"); + fs.writeFileSync(path.join(repoDir, "b.txt"), "file b\n"); + + const { manager } = yield* makeManager(); + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit", + filePaths: ["a.txt"], + }); + + expect(result.commit.status).toBe("created"); + + // b.txt should remain in the working tree + const statusStdout = yield* runGit(repoDir, ["status", "--porcelain"]).pipe( + Effect.map((r) => r.stdout), + ); + expect(statusStdout).toContain("b.txt"); + expect(statusStdout).not.toContain("a.txt"); + }), + ); + + it.effect("creates feature branch, commits, and pushes with featureBranch option", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + fs.writeFileSync(path.join(repoDir, "README.md"), "hello\nfeature-branch\n"); + let generatedCount = 0; + + const { manager } = yield* makeManager({ + textGeneration: { + generateCommitMessage: (input) => + Effect.sync(() => { + generatedCount += 1; + return { + subject: "Implement stacked git actions", + body: "", + ...(input.includeBranch ? { branch: "feature/implement-stacked-git-actions" } : {}), + }; + }), + }, + }); + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit_push", + featureBranch: true, + }); + + expect(result.branch.status).toBe("created"); + expect(result.branch.name).toBe("feature/implement-stacked-git-actions"); + expect(result.commit.status).toBe("created"); + expect(result.push.status).toBe("pushed"); + expect(result.toast).toMatchObject({ + description: "Implement stacked git actions", + cta: { + kind: "run_action", + label: "Create PR", + action: { + kind: "create_pr", + }, + }, + }); + expect(result.toast.title).toMatch( + /^Pushed [0-9a-f]{7} to origin\/feature\/implement-stacked-git-actions$/, + ); + expect( + yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "HEAD"]).pipe( + Effect.map((result) => result.stdout.trim()), + ), + ).toBe("feature/implement-stacked-git-actions"); + + const mainSha = yield* runGit(repoDir, ["rev-parse", "main"]).pipe( + Effect.map((r) => r.stdout.trim()), + ); + const mergeBase = yield* runGit(repoDir, ["merge-base", "main", "HEAD"]).pipe( + Effect.map((r) => r.stdout.trim()), + ); + expect(mergeBase).toBe(mainSha); + expect(generatedCount).toBe(1); + }), + ); + + it.effect("featureBranch uses custom commit message and derives branch name", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + fs.writeFileSync(path.join(repoDir, "README.md"), "hello\ncustom-feature\n"); + let generatedCount = 0; + + const { manager } = yield* makeManager({ + textGeneration: { + generateCommitMessage: (input) => + Effect.sync(() => { + generatedCount += 1; + return { + subject: "unused", + body: "", + ...(input.includeBranch ? { branch: "feature/unused" } : {}), + }; + }), + }, + }); + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit", + featureBranch: true, + commitMessage: "feat: custom summary line\n\n- details from user", + }); + + expect(result.branch.status).toBe("created"); + expect(result.branch.name).toBe("feature/feat-custom-summary-line"); + expect(result.commit.status).toBe("created"); + expect(result.commit.subject).toBe("feat: custom summary line"); + expect(generatedCount).toBe(0); + + const mainSha = yield* runGit(repoDir, ["rev-parse", "main"]).pipe( + Effect.map((r) => r.stdout.trim()), + ); + const mergeBase = yield* runGit(repoDir, ["merge-base", "main", result.branch.name!]).pipe( + Effect.map((r) => r.stdout.trim()), + ); + expect(mergeBase).toBe(mainSha); + }), + ); + + it.effect("skips commit when there are no uncommitted changes", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + + const { manager } = yield* makeManager(); + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit", + }); + + expect(result.branch.status).toBe("skipped_not_requested"); + expect(result.commit.status).toBe("skipped_no_changes"); + expect(result.push.status).toBe("skipped_not_requested"); + expect(result.pr.status).toBe("skipped_not_requested"); + }), + ); + + it.effect("featureBranch returns error when worktree is clean", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + + const { manager } = yield* makeManager(); + const errorMessage = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit", + featureBranch: true, + }).pipe( + Effect.flip, + Effect.map((error) => error.message), + ); + + expect(errorMessage).toContain("no changes to commit"); + }), + ); + + it.effect("commits and pushes with upstream auto-setup when needed", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/stacked-flow"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + fs.writeFileSync(path.join(repoDir, "feature.txt"), "feature\n"); + + const { manager } = yield* makeManager(); + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit_push", + }); + + expect(result.branch.status).toBe("skipped_not_requested"); + expect(result.commit.status).toBe("created"); + expect(result.push.status).toBe("pushed"); + expect(result.push.setUpstream).toBe(true); + expect( + yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"]).pipe( + Effect.map((result) => result.stdout.trim()), + ), + ).toBe("origin/feature/stacked-flow"); + }), + ); + + it.effect( + "pushes and creates PR from a no-upstream branch when local commits are ahead of base", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/no-upstream-pr"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + fs.writeFileSync(path.join(repoDir, "feature.txt"), "feature\n"); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListSequence: [ + "[]", + JSON.stringify([ + { + number: 77, + title: "Add no-upstream PR flow", + url: "https://github.com/pingdotgg/codething-mvp/pull/77", + baseRefName: "main", + headRefName: "feature/no-upstream-pr", + }, + ]), + ], + }, + }); + + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit_push_pr", + }); + + expect(result.branch.status).toBe("skipped_not_requested"); + expect(result.commit.status).toBe("created"); + expect(result.push.status).toBe("pushed"); + expect(result.push.setUpstream).toBe(true); + expect(result.pr.status).toBe("created"); + expect( + yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"]).pipe( + Effect.map((result) => result.stdout.trim()), + ), + ).toBe("origin/feature/no-upstream-pr"); + expect( + ghCalls.some((call) => + call.includes("pr create --base main --head feature/no-upstream-pr"), + ), + ).toBe(true); + }), + ); + + it.effect("skips push when branch is already up to date", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/up-to-date"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/up-to-date"]); + + const { manager } = yield* makeManager(); + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit_push", + }); + + expect(result.branch.status).toBe("skipped_not_requested"); + expect(result.commit.status).toBe("skipped_no_changes"); + expect(result.push.status).toBe("skipped_up_to_date"); + }), + ); + + it.effect("pushes existing clean commits without rerunning commit logic", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/push-only"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + fs.writeFileSync(path.join(repoDir, "push-only.txt"), "push only\n"); + yield* runGit(repoDir, ["add", "push-only.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Push only branch"]); + + const { manager } = yield* makeManager(); + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "push", + }); + + expect(result.commit.status).toBe("skipped_not_requested"); + expect(result.push.status).toBe("pushed"); + expect(result.pr.status).toBe("skipped_not_requested"); + expect( + yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"]).pipe( + Effect.map((output) => output.stdout.trim()), + ), + ).toBe("origin/feature/push-only"); + }), + ); + + it.effect("create_pr pushes a clean branch before creating the PR when needed", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/create-pr-only"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + fs.writeFileSync(path.join(repoDir, "create-pr-only.txt"), "create pr\n"); + yield* runGit(repoDir, ["add", "create-pr-only.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Create PR only branch"]); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListSequence: [ + "[]", + JSON.stringify([ + { + number: 303, + title: "Create PR only branch", + url: "https://github.com/pingdotgg/codething-mvp/pull/303", + baseRefName: "main", + headRefName: "feature/create-pr-only", + }, + ]), + ], + }, + }); + + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "create_pr", + }); + + expect(result.commit.status).toBe("skipped_not_requested"); + expect(result.push.status).toBe("pushed"); + expect(result.push.setUpstream).toBe(true); + expect(result.pr.status).toBe("created"); + expect(result.pr.number).toBe(303); + expect( + ghCalls.some((call) => + call.includes("pr create --base main --head feature/create-pr-only"), + ), + ).toBe(true); + }), + ); + + it.effect("create_pr falls back to main when source control provider detection fails", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/provider-fallback"]); + fs.writeFileSync(path.join(repoDir, "provider-fallback.txt"), "fallback\n"); + yield* runGit(repoDir, ["add", "provider-fallback.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Provider fallback"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListSequence: [ + "[]", + JSON.stringify([ + { + number: 404, + title: "Provider fallback", + url: "https://github.com/pingdotgg/codething-mvp/pull/404", + baseRefName: "main", + headRefName: "feature/provider-fallback", + }, + ]), + ], + }, + }); + + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "create_pr", + }); + + expect(result.pr.status).toBe("created"); + expect(result.pr.number).toBe(404); + expect( + ghCalls.some((call) => + call.includes("pr create --base main --head feature/provider-fallback"), + ), + ).toBe(true); + }), + ); + + it.effect("returns existing PR metadata for commit/push/pr action", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/existing-pr"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/existing-pr"]); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListSequence: [ + JSON.stringify([ + { + number: 42, + title: "Existing PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/42", + baseRefName: "main", + headRefName: "feature/existing-pr", + }, + ]), + ], + }, + }); + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit_push_pr", + }); + + expect(result.branch.status).toBe("skipped_not_requested"); + expect(result.pr.status).toBe("opened_existing"); + expect(result.pr.number).toBe(42); + expect(result.toast).toEqual({ + title: "Opened PR #42", + description: "Existing PR", + cta: { + kind: "open_pr", + label: "View PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/42", + }, + }); + expect(ghCalls.some((call) => call.startsWith("pr view "))).toBe(false); + }), + ); + + it.effect( + "returns existing cross-repo PR metadata using the fork owner selector", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "statemachine"]); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); + yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]); + yield* runGit(repoDir, [ + "config", + "remote.fork-seed.url", + "git@github.com:octocat/codething-mvp.git", + ]); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListSequence: [ + JSON.stringify([]), + JSON.stringify([ + { + number: 142, + title: "Existing fork PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/142", + baseRefName: "main", + headRefName: "statemachine", + state: "OPEN", + isCrossRepository: true, + headRepository: { + nameWithOwner: "octocat/codething-mvp", + }, + headRepositoryOwner: { + login: "octocat", + }, + }, + ]), + ], + }, + }); + + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit_push_pr", + }); + + expect(result.pr.status).toBe("opened_existing"); + expect(result.pr.number).toBe(142); + expect( + ghCalls.some((call) => + call.includes("pr list --head octocat:statemachine --state open --limit 1"), + ), + ).toBe(true); + expect(ghCalls.some((call) => call.startsWith("pr create "))).toBe(false); + }), + 12_000, + ); + + it.effect( + "returns the correct existing PR when a slash remote checks out to a synthetic local alias", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originDir = yield* createBareRemote(); + const upstreamDir = yield* createBareRemote(); + yield* configureRemote(repoDir, "origin", originDir, "origin"); + yield* configureRemote(repoDir, "my-org/upstream", upstreamDir, "my-org/upstream"); + + yield* runGit(repoDir, ["checkout", "-b", "effect-atom"]); + yield* runGit(repoDir, ["push", "-u", "origin", "effect-atom"]); + yield* runGit(repoDir, ["push", "-u", "my-org/upstream", "effect-atom"]); + yield* runGit(repoDir, [ + "config", + "remote.origin.url", + "git@github.com:pingdotgg/codething-mvp.git", + ]); + yield* runGit(repoDir, ["config", "remote.origin.pushurl", originDir]); + yield* runGit(repoDir, [ + "config", + "remote.my-org/upstream.url", + "git@github.com:pingdotgg/codething-mvp.git", + ]); + yield* runGit(repoDir, ["config", "remote.my-org/upstream.pushurl", upstreamDir]); + yield* runGit(repoDir, ["checkout", "main"]); + yield* runGit(repoDir, ["branch", "-D", "effect-atom"]); + yield* runGit(repoDir, ["checkout", "--track", "my-org/upstream/effect-atom"]); + fs.writeFileSync(path.join(repoDir, "changes.txt"), "change\n"); + yield* runGit(repoDir, ["add", "changes.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Feature commit"]); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListByHeadSelector: { + "effect-atom": JSON.stringify([ + { + number: 1618, + title: "Correct PR", + url: "https://github.com/pingdotgg/t3code/pull/1618", + baseRefName: "main", + headRefName: "effect-atom", + }, + ]), + "upstream/effect-atom": JSON.stringify([ + { + number: 1518, + title: "Wrong PR", + url: "https://github.com/pingdotgg/t3code/pull/1518", + baseRefName: "main", + headRefName: "upstream/effect-atom", + }, + ]), + "pingdotgg:effect-atom": JSON.stringify([]), + "my-org/upstream:effect-atom": JSON.stringify([]), + "pingdotgg:upstream/effect-atom": JSON.stringify([ + { + number: 1518, + title: "Wrong PR", + url: "https://github.com/pingdotgg/t3code/pull/1518", + baseRefName: "main", + headRefName: "upstream/effect-atom", + }, + ]), + "my-org/upstream:upstream/effect-atom": JSON.stringify([ + { + number: 1518, + title: "Wrong PR", + url: "https://github.com/pingdotgg/t3code/pull/1518", + baseRefName: "main", + headRefName: "upstream/effect-atom", + }, + ]), + }, + }, + }); + + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit_push_pr", + }); + + expect(result.pr.status).toBe("opened_existing"); + expect(result.pr.number).toBe(1618); + expect(ghCalls.some((call) => call.includes("pr list --head upstream/effect-atom "))).toBe( + false, + ); + }), + 20_000, + ); + + it.effect( + "prefers owner-qualified selectors before bare branch names for cross-repo PRs", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "statemachine"]); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); + yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]); + yield* runGit(repoDir, ["checkout", "-b", "t3code/pr-142/statemachine"]); + yield* runGit(repoDir, ["branch", "--set-upstream-to", "fork-seed/statemachine"]); + yield* runGit(repoDir, [ + "config", + "remote.fork-seed.url", + "git@github.com:octocat/codething-mvp.git", + ]); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListByHeadSelector: { + "t3code/pr-142/statemachine": JSON.stringify([]), + statemachine: JSON.stringify([ + { + number: 41, + title: "Unrelated same-repo PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/41", + baseRefName: "main", + headRefName: "statemachine", + }, + ]), + "octocat:statemachine": JSON.stringify([ + { + number: 142, + title: "Existing fork PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/142", + baseRefName: "main", + headRefName: "statemachine", + state: "OPEN", + isCrossRepository: true, + headRepository: { + nameWithOwner: "octocat/codething-mvp", + }, + headRepositoryOwner: { + login: "octocat", + }, + }, + ]), + "fork-seed:statemachine": JSON.stringify([]), + }, + }, + }); + + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit_push_pr", + }); + + expect(result.pr.status).toBe("opened_existing"); + expect(result.pr.number).toBe(142); + + const ownerSelectorCallIndex = ghCalls.findIndex((call) => + call.includes("pr list --head octocat:statemachine --state open --limit 1"), + ); + expect(ownerSelectorCallIndex).toBeGreaterThanOrEqual(0); + expect(ghCalls.some((call) => call.startsWith("pr create "))).toBe(false); + }), + 12_000, + ); + + it.effect( + "stops probing head selectors after finding an existing PR", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "statemachine"]); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); + yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]); + yield* runGit(repoDir, ["checkout", "-b", "t3code/pr-142/statemachine"]); + yield* runGit(repoDir, ["branch", "--set-upstream-to", "fork-seed/statemachine"]); + yield* runGit(repoDir, [ + "config", + "remote.fork-seed.url", + "git@github.com:octocat/codething-mvp.git", + ]); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListByHeadSelector: { + "octocat:statemachine": JSON.stringify([ + { + number: 142, + title: "Existing fork PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/142", + baseRefName: "main", + headRefName: "statemachine", + state: "OPEN", + isCrossRepository: true, + headRepository: { + nameWithOwner: "octocat/codething-mvp", + }, + headRepositoryOwner: { + login: "octocat", + }, + }, + ]), + "fork-seed:statemachine": JSON.stringify([]), + "t3code/pr-142/statemachine": JSON.stringify([]), + statemachine: JSON.stringify([]), + }, + }, + }); + + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit_push_pr", + }); + + expect(result.pr.status).toBe("opened_existing"); + expect(result.pr.number).toBe(142); + + const openLookupCalls = ghCalls.filter((call) => call.includes("--state open --limit 1")); + expect(openLookupCalls).toHaveLength(1); + expect(openLookupCalls[0]).toContain( + "pr list --head octocat:statemachine --state open --limit 1", + ); + }), + 12_000, + ); + + it.effect("creates PR when one does not already exist", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature-create-pr"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + fs.writeFileSync(path.join(repoDir, "changes.txt"), "change\n"); + yield* runGit(repoDir, ["add", "changes.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Feature commit"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature-create-pr"]); + yield* runGit(repoDir, ["config", "branch.feature-create-pr.gh-merge-base", "main"]); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListSequence: [ + "[]", + JSON.stringify([ + { + number: 88, + title: "Add stacked git actions", + url: "https://github.com/pingdotgg/codething-mvp/pull/88", + baseRefName: "main", + headRefName: "feature-create-pr", + }, + ]), + ], + }, + }); + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit_push_pr", + }); + + expect(result.branch.status).toBe("skipped_not_requested"); + expect(result.pr.status).toBe("created"); + expect(result.pr.number).toBe(88); + expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(2); + expect( + ghCalls.some((call) => call.includes("pr create --base main --head feature-create-pr")), + ).toBe(true); + expect(ghCalls.some((call) => call.startsWith("pr view "))).toBe(false); + }), + ); + + it.effect( + "creates a new PR instead of reusing an unrelated fork PR with the same head branch", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/no-fork-match"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + fs.writeFileSync(path.join(repoDir, "changes.txt"), "change\n"); + yield* runGit(repoDir, ["add", "changes.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Feature commit"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/no-fork-match"]); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListSequence: [ + JSON.stringify([ + { + number: 1661, + title: "Fork PR with same branch name", + url: "https://github.com/pingdotgg/t3code/pull/1661", + baseRefName: "main", + headRefName: "feature/no-fork-match", + state: "OPEN", + isCrossRepository: true, + headRepository: { + nameWithOwner: "lnieuwenhuis/t3code", + }, + headRepositoryOwner: { + login: "lnieuwenhuis", + }, + }, + ]), + JSON.stringify([ + { + number: 188, + title: "Add stacked git actions", + url: "https://github.com/pingdotgg/codething-mvp/pull/188", + baseRefName: "main", + headRefName: "feature/no-fork-match", + state: "OPEN", + isCrossRepository: false, + }, + ]), + ], + }, + }); + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit_push_pr", + }); + + expect(result.pr.status).toBe("created"); + expect(result.pr.number).toBe(188); + expect(result.toast).toEqual({ + title: "Created PR #188", + description: "Add stacked git actions", + cta: { + kind: "open_pr", + label: "View PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/188", + }, + }); + expect( + ghCalls.some((call) => + call.includes("pr create --base main --head feature/no-fork-match"), + ), + ).toBe(true); + }), + ); + + it.effect("creates cross-repo PRs with the fork owner selector and default base branch", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); + yield* runGit(repoDir, ["checkout", "-b", "statemachine"]); + fs.writeFileSync(path.join(repoDir, "changes.txt"), "change\n"); + yield* runGit(repoDir, ["add", "changes.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Feature commit"]); + yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]); + yield* runGit(repoDir, ["checkout", "-b", "t3code/pr-91/statemachine"]); + yield* runGit(repoDir, ["branch", "--set-upstream-to", "fork-seed/statemachine"]); + yield* runGit(repoDir, [ + "config", + "remote.fork-seed.url", + "git@github.com:octocat/codething-mvp.git", + ]); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListSequenceByHeadSelector: { + "octocat:statemachine": [ + JSON.stringify([]), + JSON.stringify([ + { + number: 188, + title: "Add stacked git actions", + url: "https://github.com/pingdotgg/codething-mvp/pull/188", + baseRefName: "main", + headRefName: "statemachine", + state: "OPEN", + isCrossRepository: true, + headRepository: { + nameWithOwner: "octocat/codething-mvp", + }, + headRepositoryOwner: { + login: "octocat", + }, + }, + ]), + ], + "fork-seed:statemachine": [JSON.stringify([])], + statemachine: [JSON.stringify([])], + }, + }, + }); + + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit_push_pr", + }); + + expect(result.pr.status).toBe("created"); + expect(result.pr.number).toBe(188); + expect( + ghCalls.some((call) => call.includes("pr create --base main --head octocat:statemachine")), + ).toBe(true); + expect( + ghCalls.some((call) => + call.includes("pr create --base statemachine --head octocat:statemachine"), + ), + ).toBe(false); + }), + ); + + it.effect("rejects push/pr actions from detached HEAD", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "--detach", "HEAD"]); + + const { manager } = yield* makeManager(); + const errorMessage = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit_push", + }).pipe( + Effect.flip, + Effect.map((error) => error.message), + ); + expect(errorMessage).toContain("detached HEAD"); + }), + ); + + it.effect("surfaces missing gh binary errors", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/gh-missing"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/gh-missing"]); + + const { manager } = yield* makeManager({ + ghScenario: { + failWith: new GitHubCliError({ + operation: "execute", + detail: "GitHub CLI (`gh`) is required but not available on PATH.", + }), + }, + }); + + const errorMessage = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit_push_pr", + }).pipe( + Effect.flip, + Effect.map((error) => error.message), + ); + expect(errorMessage).toContain("GitHub CLI (`gh`) is required"); + }), + ); + + it.effect("surfaces gh auth errors with guidance", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/gh-auth"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/gh-auth"]); + + const { manager } = yield* makeManager({ + ghScenario: { + failWith: new GitHubCliError({ + operation: "execute", + detail: "GitHub CLI is not authenticated. Run `gh auth login` and retry.", + }), + }, + }); + + const errorMessage = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit_push_pr", + }).pipe( + Effect.flip, + Effect.map((error) => error.message), + ); + expect(errorMessage).toContain("gh auth login"); + }), + ); + + it.effect("resolves pull requests from #number references", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 42, + title: "Resolve PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/42", + baseRefName: "main", + headRefName: "feature/resolve-pr", + state: "open", + }, + }, + }); + + const result = yield* resolvePullRequest(manager, { + cwd: repoDir, + reference: "#42", + }); + + expect(result.pullRequest).toEqual({ + number: 42, + title: "Resolve PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/42", + baseBranch: "main", + headBranch: "feature/resolve-pr", + state: "open", + }); + expect(ghCalls.some((call) => call.startsWith("pr view 42 "))).toBe(true); + }), + ); + + it.effect("prepares pull request threads in local mode by checking out the PR branch", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-local"]); + fs.writeFileSync(path.join(repoDir, "local.txt"), "local\n"); + yield* runGit(repoDir, ["add", "local.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Local PR branch"]); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 64, + title: "Local PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/64", + baseRefName: "main", + headRefName: "feature/pr-local", + state: "open", + }, + }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "#64", + mode: "local", + }); + + expect(result.branch).toBe("feature/pr-local"); + expect(result.worktreePath).toBeNull(); + const branch = (yield* runGit(repoDir, ["branch", "--show-current"])).stdout.trim(); + expect(branch).toBe("feature/pr-local"); + expect(ghCalls).toContain("pr checkout 64 --force"); + }), + ); + + it.effect("prepares pull request threads in worktree mode on the PR head branch", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-worktree"]); + fs.writeFileSync(path.join(repoDir, "worktree.txt"), "worktree\n"); + yield* runGit(repoDir, ["add", "worktree.txt"]); + yield* runGit(repoDir, ["commit", "-m", "PR worktree branch"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-worktree"]); + yield* runGit(repoDir, ["push", "origin", "HEAD:refs/pull/77/head"]); + yield* runGit(repoDir, ["checkout", "main"]); + + const { manager } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 77, + title: "Worktree PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/77", + baseRefName: "main", + headRefName: "feature/pr-worktree", + state: "open", + }, + }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "77", + mode: "worktree", + }); + + expect(result.branch).toBe("feature/pr-worktree"); + expect(result.worktreePath).not.toBeNull(); + expect(fs.existsSync(result.worktreePath as string)).toBe(true); + const worktreeBranch = (yield* runGit(result.worktreePath as string, [ + "branch", + "--show-current", + ])).stdout.trim(); + expect(worktreeBranch).toBe("feature/pr-worktree"); + }), + ); + + it.effect("launches setup only when creating a new PR worktree", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-worktree-setup"]); + fs.writeFileSync(path.join(repoDir, "setup.txt"), "setup\n"); + yield* runGit(repoDir, ["add", "setup.txt"]); + yield* runGit(repoDir, ["commit", "-m", "PR worktree setup branch"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-worktree-setup"]); + yield* runGit(repoDir, ["push", "origin", "HEAD:refs/pull/177/head"]); + yield* runGit(repoDir, ["checkout", "main"]); + + const setupCalls: ProjectSetupScriptRunnerInput[] = []; + const { manager } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 177, + title: "Worktree setup PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/177", + baseRefName: "main", + headRefName: "feature/pr-worktree-setup", + state: "open", + }, + }, + setupScriptRunner: { + runForThread: (setupInput) => + Effect.sync(() => { + setupCalls.push(setupInput); + return { status: "no-script" as const }; + }), + }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "177", + mode: "worktree", + threadId: asThreadId("thread-pr-setup"), + }); + + expect(result.worktreePath).not.toBeNull(); + expect(setupCalls).toHaveLength(1); + expect(setupCalls[0]).toEqual({ + threadId: "thread-pr-setup", + projectCwd: repoDir, + worktreePath: result.worktreePath as string, + }); + }), + ); + + it.effect("preserves fork upstream tracking when preparing a worktree PR thread", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originDir = yield* createBareRemote(); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-fork"]); + fs.writeFileSync(path.join(repoDir, "fork.txt"), "fork\n"); + yield* runGit(repoDir, ["add", "fork.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Fork PR branch"]); + yield* runGit(repoDir, ["push", "-u", "fork-seed", "feature/pr-fork"]); + yield* runGit(repoDir, ["checkout", "main"]); + + const { manager } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 81, + title: "Fork PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/81", + baseRefName: "main", + headRefName: "feature/pr-fork", + state: "open", + isCrossRepository: true, + headRepositoryNameWithOwner: "octocat/codething-mvp", + headRepositoryOwnerLogin: "octocat", + }, + repositoryCloneUrls: { + "octocat/codething-mvp": { + url: forkDir, + sshUrl: forkDir, + }, + }, + }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "81", + mode: "worktree", + }); + + expect(result.worktreePath).not.toBeNull(); + const upstreamRef = (yield* runGit(result.worktreePath as string, [ + "rev-parse", + "--abbrev-ref", + "@{upstream}", + ])).stdout.trim(); + expect(upstreamRef).toBe("fork-seed/feature/pr-fork"); + expect(upstreamRef.startsWith("origin/")).toBe(false); + expect( + (yield* runGit(result.worktreePath as string, [ + "config", + "--get", + "remote.fork-seed.url", + ])).stdout.trim(), + ).toBe(forkDir); + }), + ); + + it.effect("preserves fork upstream tracking when preparing a local PR thread", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originDir = yield* createBareRemote(); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-local-fork"]); + fs.writeFileSync(path.join(repoDir, "local-fork.txt"), "local fork\n"); + yield* runGit(repoDir, ["add", "local-fork.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Local fork PR branch"]); + yield* runGit(repoDir, ["push", "-u", "fork-seed", "feature/pr-local-fork"]); + yield* runGit(repoDir, ["checkout", "main"]); + yield* runGit(repoDir, ["branch", "-D", "feature/pr-local-fork"]); + + const { manager } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 82, + title: "Local Fork PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/82", + baseRefName: "main", + headRefName: "feature/pr-local-fork", + state: "open", + isCrossRepository: true, + headRepositoryNameWithOwner: "octocat/codething-mvp", + headRepositoryOwnerLogin: "octocat", + }, + repositoryCloneUrls: { + "octocat/codething-mvp": { + url: forkDir, + sshUrl: forkDir, + }, + }, + }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "82", + mode: "local", + }); + + expect(result.worktreePath).toBeNull(); + expect(result.branch).toBe("feature/pr-local-fork"); + expect( + (yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"])).stdout.trim(), + ).toBe("fork-seed/feature/pr-local-fork"); + }), + ); + + it.effect("derives fork repository identity from PR URL when GitHub omits nameWithOwner", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originDir = yield* createBareRemote(); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["remote", "add", "binbandit-seed", forkDir]); + yield* runGit(repoDir, ["checkout", "-b", "fix/git-action-default-without-origin"]); + fs.writeFileSync(path.join(repoDir, "derived-fork.txt"), "derived fork\n"); + yield* runGit(repoDir, ["add", "derived-fork.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Derived fork PR branch"]); + yield* runGit(repoDir, [ + "push", + "-u", + "binbandit-seed", + "fix/git-action-default-without-origin", + ]); + yield* runGit(repoDir, ["checkout", "main"]); + yield* runGit(repoDir, ["branch", "-D", "fix/git-action-default-without-origin"]); + + const { manager } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 642, + title: "fix: use commit as the default git action without origin", + url: "https://github.com/pingdotgg/t3code/pull/642", + baseRefName: "main", + headRefName: "fix/git-action-default-without-origin", + state: "open", + isCrossRepository: true, + headRepositoryOwnerLogin: "binbandit", + }, + repositoryCloneUrls: { + "binbandit/t3code": { + url: forkDir, + sshUrl: forkDir, + }, + }, + }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "642", + mode: "local", + }); + + expect(result.branch).toBe("fix/git-action-default-without-origin"); + expect(result.worktreePath).toBeNull(); + expect( + (yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"])).stdout.trim(), + ).toBe("binbandit-seed/fix/git-action-default-without-origin"); + }), + ); + + it.effect("reuses an existing dedicated worktree for the PR head branch", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-existing-worktree"]); + fs.writeFileSync(path.join(repoDir, "existing.txt"), "existing\n"); + yield* runGit(repoDir, ["add", "existing.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Existing worktree branch"]); + yield* runGit(repoDir, ["checkout", "main"]); + const worktreePath = path.join(repoDir, "..", `pr-existing-${Date.now()}`); + yield* runGit(repoDir, ["worktree", "add", worktreePath, "feature/pr-existing-worktree"]); + + const setupCalls: ProjectSetupScriptRunnerInput[] = []; + const { manager } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 78, + title: "Existing worktree PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/78", + baseRefName: "main", + headRefName: "feature/pr-existing-worktree", + state: "open", + }, + }, + setupScriptRunner: { + runForThread: (setupInput) => + Effect.sync(() => { + setupCalls.push(setupInput); + return { status: "no-script" as const }; + }), + }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "78", + mode: "worktree", + threadId: asThreadId("thread-pr-existing-worktree"), + }); + + expect(result.worktreePath && fs.realpathSync.native(result.worktreePath)).toBe( + fs.realpathSync.native(worktreePath), + ); + expect(result.branch).toBe("feature/pr-existing-worktree"); + expect(setupCalls).toHaveLength(0); + }), + ); + + it.effect( + "does not block fork PR worktree prep when the fork head branch collides with root main", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originDir = yield* createBareRemote(); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); + yield* runGit(repoDir, ["checkout", "-b", "fork-main-source"]); + fs.writeFileSync(path.join(repoDir, "fork-main.txt"), "fork main\n"); + yield* runGit(repoDir, ["add", "fork-main.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Fork main branch"]); + yield* runGit(repoDir, ["push", "-u", "fork-seed", "fork-main-source:main"]); + yield* runGit(repoDir, ["checkout", "main"]); + const mainBefore = (yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim(); + + const { manager } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 91, + title: "Fork main PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/91", + baseRefName: "main", + headRefName: "main", + state: "open", + isCrossRepository: true, + headRepositoryNameWithOwner: "octocat/codething-mvp", + headRepositoryOwnerLogin: "octocat", + }, + repositoryCloneUrls: { + "octocat/codething-mvp": { + url: forkDir, + sshUrl: forkDir, + }, + }, + }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "91", + mode: "worktree", + }); + + expect(result.branch).toBe("t3code/pr-91/main"); + expect(result.worktreePath).not.toBeNull(); + expect((yield* runGit(repoDir, ["branch", "--show-current"])).stdout.trim()).toBe("main"); + expect((yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim()).toBe(mainBefore); + expect( + (yield* runGit(result.worktreePath as string, [ + "branch", + "--show-current", + ])).stdout.trim(), + ).toBe("t3code/pr-91/main"); + }), + ); + + it.effect( + "does not overwrite an existing local main branch when preparing a fork PR worktree", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originDir = yield* createBareRemote(); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); + yield* runGit(repoDir, ["checkout", "-b", "fork-main-source"]); + fs.writeFileSync(path.join(repoDir, "fork-main-second.txt"), "fork main second\n"); + yield* runGit(repoDir, ["add", "fork-main-second.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Fork main second branch"]); + yield* runGit(repoDir, ["push", "-u", "fork-seed", "fork-main-source:main"]); + yield* runGit(repoDir, ["checkout", "main"]); + const localMainBefore = (yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim(); + yield* runGit(repoDir, ["checkout", "-b", "feature/root-branch"]); + + const { manager } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 92, + title: "Fork main overwrite PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/92", + baseRefName: "main", + headRefName: "main", + state: "open", + isCrossRepository: true, + headRepositoryNameWithOwner: "octocat/codething-mvp", + headRepositoryOwnerLogin: "octocat", + }, + repositoryCloneUrls: { + "octocat/codething-mvp": { + url: forkDir, + sshUrl: forkDir, + }, + }, + }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "92", + mode: "worktree", + }); + + expect(result.branch).toBe("t3code/pr-92/main"); + expect((yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim()).toBe(localMainBefore); + expect( + (yield* runGit(result.worktreePath as string, [ + "rev-parse", + "--abbrev-ref", + "@{upstream}", + ])).stdout.trim(), + ).toBe("fork-seed/main"); + }), + ); + + it.effect("reuses an existing PR worktree and restores fork upstream tracking", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originDir = yield* createBareRemote(); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-reused-fork"]); + fs.writeFileSync(path.join(repoDir, "reused-fork.txt"), "reused fork\n"); + yield* runGit(repoDir, ["add", "reused-fork.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Reused fork PR branch"]); + yield* runGit(repoDir, ["push", "-u", "fork-seed", "feature/pr-reused-fork"]); + yield* runGit(repoDir, ["checkout", "main"]); + const worktreePath = path.join(repoDir, "..", `pr-reused-fork-${Date.now()}`); + yield* runGit(repoDir, ["worktree", "add", worktreePath, "feature/pr-reused-fork"]); + yield* runGit(worktreePath, ["branch", "--unset-upstream"], true); + + const { manager } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 83, + title: "Reused Fork PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/83", + baseRefName: "main", + headRefName: "feature/pr-reused-fork", + state: "open", + isCrossRepository: true, + headRepositoryNameWithOwner: "octocat/codething-mvp", + headRepositoryOwnerLogin: "octocat", + }, + repositoryCloneUrls: { + "octocat/codething-mvp": { + url: forkDir, + sshUrl: forkDir, + }, + }, + }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "83", + mode: "worktree", + }); + + expect(result.worktreePath && fs.realpathSync.native(result.worktreePath)).toBe( + fs.realpathSync.native(worktreePath), + ); + expect( + (yield* runGit(worktreePath, ["rev-parse", "--abbrev-ref", "@{upstream}"])).stdout.trim(), + ).toBe("fork-seed/feature/pr-reused-fork"); + }), + ); + + it.effect("does not fail PR worktree prep when setup terminal startup fails", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-setup-failure"]); + fs.writeFileSync(path.join(repoDir, "setup-failure.txt"), "setup failure\n"); + yield* runGit(repoDir, ["add", "setup-failure.txt"]); + yield* runGit(repoDir, ["commit", "-m", "PR setup failure branch"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-setup-failure"]); + yield* runGit(repoDir, ["push", "origin", "HEAD:refs/pull/184/head"]); + yield* runGit(repoDir, ["checkout", "main"]); + + const { manager } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 184, + title: "Setup failure PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/184", + baseRefName: "main", + headRefName: "feature/pr-setup-failure", + state: "open", + }, + }, + setupScriptRunner: { + runForThread: () => Effect.fail(new Error("terminal start failed")), + }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "184", + mode: "worktree", + threadId: asThreadId("thread-pr-setup-failure"), + }); + + expect(result.branch).toBe("feature/pr-setup-failure"); + expect(result.worktreePath).not.toBeNull(); + expect(fs.existsSync(result.worktreePath as string)).toBe(true); + }), + ); + + it.effect("rejects worktree prep when the PR head branch is checked out in the main repo", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-root-only"]); + + const { manager } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 79, + title: "Root-only PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/79", + baseRefName: "main", + headRefName: "feature/pr-root-only", + state: "open", + }, + }, + }); + + const errorMessage = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "79", + mode: "worktree", + }).pipe( + Effect.flip, + Effect.map((error) => error.message), + ); + + expect(errorMessage).toContain("already checked out in the main repo"); + }), + ); + + it.effect("emits ordered progress events for commit hooks", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + fs.writeFileSync(path.join(repoDir, "hooked.txt"), "hooked\n"); + fs.writeFileSync( + path.join(repoDir, ".git", "hooks", "pre-commit"), + '#!/bin/sh\necho "hook: start" >&2\nsleep 1\necho "hook: end" >&2\n', + { mode: 0o755 }, + ); + + const { manager } = yield* makeManager(); + const events: GitActionProgressEvent[] = []; + + const result = yield* runStackedAction( + manager, + { + cwd: repoDir, + action: "commit", + }, + { + actionId: "action-1", + progressReporter: { + publish: (event) => + Effect.sync(() => { + events.push(event); + }), + }, + }, + ); + + expect(result.commit.status).toBe("created"); + expect(events.map((event) => event.kind)).toContain("action_started"); + expect(events).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "phase_started", + phase: "commit", + }), + expect.objectContaining({ + kind: "hook_started", + hookName: "pre-commit", + }), + expect.objectContaining({ + kind: "hook_output", + text: "hook: start", + }), + expect.objectContaining({ + kind: "hook_output", + text: "hook: end", + }), + expect.objectContaining({ + kind: "hook_finished", + hookName: "pre-commit", + }), + expect.objectContaining({ + kind: "action_finished", + }), + ]), + ); + }), + ); + + it.effect("emits action_failed when a commit hook rejects", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + fs.writeFileSync(path.join(repoDir, "hook-failure.txt"), "broken\n"); + fs.writeFileSync( + path.join(repoDir, ".git", "hooks", "pre-commit"), + '#!/bin/sh\necho "hook: fail" >&2\nexit 1\n', + { mode: 0o755 }, + ); + + const { manager } = yield* makeManager(); + const events: GitActionProgressEvent[] = []; + + const errorMessage = yield* runStackedAction( + manager, + { + cwd: repoDir, + action: "commit", + }, + { + actionId: "action-2", + progressReporter: { + publish: (event) => + Effect.sync(() => { + events.push(event); + }), + }, + }, + ).pipe( + Effect.flip, + Effect.map((error) => error.message), + ); + + expect(errorMessage).toContain("hook: fail"); + expect(events).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "hook_started", + hookName: "pre-commit", + }), + expect.objectContaining({ + kind: "action_failed", + phase: "commit", + }), + ]), + ); + }), + ); + + it.effect("create_pr emits only the PR phase when the branch is already pushed", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-only-follow-up"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + fs.writeFileSync(path.join(repoDir, "pr-only.txt"), "pr only\n"); + yield* runGit(repoDir, ["add", "pr-only.txt"]); + yield* runGit(repoDir, ["commit", "-m", "PR only branch"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-only-follow-up"]); + + const { manager } = yield* makeManager({ + ghScenario: { + prListSequence: [ + JSON.stringify([]), + JSON.stringify([ + { + number: 201, + title: "PR only branch", + url: "https://github.com/pingdotgg/codething-mvp/pull/201", + baseRefName: "main", + headRefName: "feature/pr-only-follow-up", + state: "OPEN", + isCrossRepository: false, + }, + ]), + ], + }, + }); + const events: GitActionProgressEvent[] = []; + + const result = yield* runStackedAction( + manager, + { + cwd: repoDir, + action: "create_pr", + }, + { + actionId: "action-pr-only", + progressReporter: { + publish: (event) => + Effect.sync(() => { + events.push(event); + }), + }, + }, + ); + + expect(result.commit.status).toBe("skipped_not_requested"); + expect(result.push.status).toBe("skipped_not_requested"); + expect(result.pr.status).toBe("created"); + expect( + events.filter( + (event): event is Extract => + event.kind === "phase_started", + ), + ).toEqual([ + expect.objectContaining({ + kind: "phase_started", + phase: "pr", + label: "Preparing PR...", + }), + expect.objectContaining({ + kind: "phase_started", + phase: "pr", + label: "Generating PR content...", + }), + expect.objectContaining({ + kind: "phase_started", + phase: "pr", + label: "Creating pull request...", + }), + ]); + }), + ); +}); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts new file mode 100644 index 0000000000000000000000000000000000000000..abebc70d02b95075afa32e28a580c73ff3e36f18 --- /dev/null +++ b/apps/server/src/git/GitManager.ts @@ -0,0 +1,1769 @@ +import { randomUUID } from "node:crypto"; + +import { + Array as Arr, + Cache, + Context, + DateTime, + Duration, + Effect, + Exit, + FileSystem, + Layer, + Option, + Order, + Path, + Ref, +} from "effect"; +import { + GitActionProgressEvent, + GitActionProgressPhase, + GitCommandError, + GitPreparePullRequestThreadInput, + GitPreparePullRequestThreadResult, + GitPullRequestRefInput, + GitResolvePullRequestResult, + GitRunStackedActionInput, + GitRunStackedActionResult, + GitStackedAction, + VcsStatusInput, + type VcsStatusLocalResult, + type VcsStatusRemoteResult, + VcsStatusResult, + ModelSelection, +} from "@t3tools/contracts"; +import { + detectSourceControlProviderFromGitRemoteUrl, + mergeGitStatusParts, + resolveAutoFeatureBranchName, + sanitizeBranchFragment, + sanitizeFeatureBranchName, +} from "@t3tools/shared/git"; +import { + getChangeRequestTerminologyForKind, + type ChangeRequestTerminology, +} from "@t3tools/shared/sourceControl"; + +import { GitManagerError } from "@t3tools/contracts"; +import { TextGeneration } from "../textGeneration/TextGeneration.ts"; +import { ProjectSetupScriptRunner } from "../project/Services/ProjectSetupScriptRunner.ts"; +import { extractBranchNameFromRemoteRef } from "./remoteRefs.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; +import type { GitManagerServiceError } from "@t3tools/contracts"; +import { GitVcsDriver, type GitStatusDetails } from "../vcs/GitVcsDriver.ts"; +import { SourceControlProviderRegistry } from "../sourceControl/SourceControlProviderRegistry.ts"; +import type { ChangeRequest } from "@t3tools/contracts"; + +export interface GitActionProgressReporter { + readonly publish: (event: GitActionProgressEvent) => Effect.Effect; +} + +export interface GitRunStackedActionOptions { + readonly actionId?: string; + readonly progressReporter?: GitActionProgressReporter; +} + +export interface GitManagerShape { + readonly status: ( + input: VcsStatusInput, + ) => Effect.Effect; + readonly localStatus: ( + input: VcsStatusInput, + ) => Effect.Effect; + readonly remoteStatus: ( + input: VcsStatusInput, + ) => Effect.Effect; + readonly invalidateLocalStatus: (cwd: string) => Effect.Effect; + readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect; + readonly invalidateStatus: (cwd: string) => Effect.Effect; + readonly resolvePullRequest: ( + input: GitPullRequestRefInput, + ) => Effect.Effect; + readonly preparePullRequestThread: ( + input: GitPreparePullRequestThreadInput, + ) => Effect.Effect; + readonly runStackedAction: ( + input: GitRunStackedActionInput, + options?: GitRunStackedActionOptions, + ) => Effect.Effect; +} + +export class GitManager extends Context.Service()( + "t3/git/GitManager", +) {} + +const COMMIT_TIMEOUT_MS = 10 * 60_000; +const MAX_PROGRESS_TEXT_LENGTH = 500; +const SHORT_SHA_LENGTH = 7; +const TOAST_DESCRIPTION_MAX = 72; +const STATUS_RESULT_CACHE_TTL = Duration.seconds(1); +const STATUS_RESULT_CACHE_CAPACITY = 2_048; +type StripProgressContext = T extends any ? Omit : never; +type GitActionProgressPayload = StripProgressContext; +type GitActionProgressEmitter = (event: GitActionProgressPayload) => Effect.Effect; + +function isNotGitRepositoryError(error: GitCommandError): boolean { + return error.message.toLowerCase().includes("not a git repository"); +} + +interface OpenPrInfo { + number: number; + title: string; + url: string; + baseRefName: string; + headRefName: string; +} + +interface PullRequestInfo extends OpenPrInfo, PullRequestHeadRemoteInfo { + state: "open" | "closed" | "merged"; + updatedAt: Option.Option; +} + +const pullRequestUpdatedAtDescOrder: Order.Order = Order.mapInput( + Order.flip(Option.makeOrder(DateTime.Order)), + (pullRequest) => pullRequest.updatedAt, +); + +interface ResolvedPullRequest { + number: number; + title: string; + url: string; + baseBranch: string; + headBranch: string; + state: "open" | "closed" | "merged"; +} + +interface PullRequestHeadRemoteInfo { + isCrossRepository?: boolean | undefined; + headRepositoryNameWithOwner?: string | null | undefined; + headRepositoryOwnerLogin?: string | null | undefined; +} + +interface BranchHeadContext { + localBranch: string; + headBranch: string; + headSelectors: ReadonlyArray; + preferredHeadSelector: string; + remoteName: string | null; + headRepositoryNameWithOwner: string | null; + headRepositoryOwnerLogin: string | null; + isCrossRepository: boolean; +} + +function parseRepositoryNameFromPullRequestUrl(url: string): string | null { + const trimmed = url.trim(); + const match = /^https:\/\/github\.com\/[^/]+\/([^/]+)\/pull\/\d+(?:\/.*)?$/i.exec(trimmed); + const repositoryName = match?.[1]?.trim() ?? ""; + return repositoryName.length > 0 ? repositoryName : null; +} + +function resolveHeadRepositoryNameWithOwner( + pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo, +): string | null { + const explicitRepository = pullRequest.headRepositoryNameWithOwner?.trim() ?? ""; + if (explicitRepository.length > 0) { + return explicitRepository; + } + + if (!pullRequest.isCrossRepository) { + return null; + } + + const ownerLogin = pullRequest.headRepositoryOwnerLogin?.trim() ?? ""; + const repositoryName = parseRepositoryNameFromPullRequestUrl(pullRequest.url); + if (ownerLogin.length === 0 || !repositoryName) { + return null; + } + + return `${ownerLogin}/${repositoryName}`; +} + +function resolvePullRequestWorktreeLocalBranchName( + pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo, +): string { + if (!pullRequest.isCrossRepository) { + return pullRequest.headBranch; + } + + const sanitizedHeadBranch = sanitizeBranchFragment(pullRequest.headBranch).trim(); + const suffix = sanitizedHeadBranch.length > 0 ? sanitizedHeadBranch : "head"; + return `t3code/pr-${pullRequest.number}/${suffix}`; +} + +function parseGitHubRepositoryNameWithOwnerFromRemoteUrl(url: string | null): string | null { + const trimmed = url?.trim() ?? ""; + if (trimmed.length === 0) { + return null; + } + + const match = + /^(?:git@github\.com:|ssh:\/\/git@github\.com\/|https:\/\/github\.com\/|git:\/\/github\.com\/)([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/i.exec( + trimmed, + ); + const repositoryNameWithOwner = match?.[1]?.trim() ?? ""; + return repositoryNameWithOwner.length > 0 ? repositoryNameWithOwner : null; +} + +function parseRepositoryOwnerLogin(nameWithOwner: string | null): string | null { + const trimmed = nameWithOwner?.trim() ?? ""; + if (trimmed.length === 0) { + return null; + } + const [ownerLogin] = trimmed.split("/"); + const normalizedOwnerLogin = ownerLogin?.trim() ?? ""; + return normalizedOwnerLogin.length > 0 ? normalizedOwnerLogin : null; +} + +function normalizeOptionalString(value: string | null | undefined): string | null { + const trimmed = value?.trim() ?? ""; + return trimmed.length > 0 ? trimmed : null; +} + +function normalizeOptionalRepositoryNameWithOwner(value: string | null | undefined): string | null { + const normalized = normalizeOptionalString(value); + return normalized ? normalized.toLowerCase() : null; +} + +function normalizeOptionalOwnerLogin(value: string | null | undefined): string | null { + const normalized = normalizeOptionalString(value); + return normalized ? normalized.toLowerCase() : null; +} + +function resolvePullRequestHeadRepositoryNameWithOwner( + pr: PullRequestHeadRemoteInfo & { url: string }, +) { + const explicitRepository = normalizeOptionalString(pr.headRepositoryNameWithOwner); + if (explicitRepository) { + return explicitRepository; + } + + if (!pr.isCrossRepository) { + return null; + } + + const ownerLogin = normalizeOptionalString(pr.headRepositoryOwnerLogin); + const repositoryName = parseRepositoryNameFromPullRequestUrl(pr.url); + if (!ownerLogin || !repositoryName) { + return null; + } + + return `${ownerLogin}/${repositoryName}`; +} + +function matchesBranchHeadContext( + pr: PullRequestInfo, + headContext: Pick< + BranchHeadContext, + "headBranch" | "headRepositoryNameWithOwner" | "headRepositoryOwnerLogin" | "isCrossRepository" + >, +): boolean { + if (pr.headRefName !== headContext.headBranch) { + return false; + } + + const expectedHeadRepository = normalizeOptionalRepositoryNameWithOwner( + headContext.headRepositoryNameWithOwner, + ); + const expectedHeadOwner = + normalizeOptionalOwnerLogin(headContext.headRepositoryOwnerLogin) ?? + parseRepositoryOwnerLogin(expectedHeadRepository); + const prHeadRepository = normalizeOptionalRepositoryNameWithOwner( + resolvePullRequestHeadRepositoryNameWithOwner(pr), + ); + const prHeadOwner = + normalizeOptionalOwnerLogin(pr.headRepositoryOwnerLogin) ?? + parseRepositoryOwnerLogin(prHeadRepository); + + if (headContext.isCrossRepository) { + if (pr.isCrossRepository === false) { + return false; + } + if ((expectedHeadRepository || expectedHeadOwner) && !prHeadRepository && !prHeadOwner) { + return false; + } + if (expectedHeadRepository && prHeadRepository && expectedHeadRepository !== prHeadRepository) { + return false; + } + if (expectedHeadOwner && prHeadOwner && expectedHeadOwner !== prHeadOwner) { + return false; + } + return true; + } + + if (pr.isCrossRepository === true) { + return false; + } + if (expectedHeadRepository && prHeadRepository && expectedHeadRepository !== prHeadRepository) { + return false; + } + if (expectedHeadOwner && prHeadOwner && expectedHeadOwner !== prHeadOwner) { + return false; + } + return true; +} + +function toPullRequestInfo(summary: ChangeRequest): PullRequestInfo { + return { + number: summary.number, + title: summary.title, + url: summary.url, + baseRefName: summary.baseRefName, + headRefName: summary.headRefName, + state: summary.state ?? "open", + updatedAt: summary.updatedAt, + ...(summary.isCrossRepository !== undefined + ? { isCrossRepository: summary.isCrossRepository } + : {}), + ...(summary.headRepositoryNameWithOwner !== undefined + ? { headRepositoryNameWithOwner: summary.headRepositoryNameWithOwner } + : {}), + ...(summary.headRepositoryOwnerLogin !== undefined + ? { headRepositoryOwnerLogin: summary.headRepositoryOwnerLogin } + : {}), + }; +} + +function gitManagerError(operation: string, detail: string, cause?: unknown): GitManagerError { + return new GitManagerError({ + operation, + detail, + ...(cause !== undefined ? { cause } : {}), + }); +} + +function limitContext(value: string, maxChars: number): string { + if (value.length <= maxChars) return value; + return `${value.slice(0, maxChars)}\n\n[truncated]`; +} + +function shortenSha(sha: string | undefined): string | null { + if (!sha) return null; + return sha.slice(0, SHORT_SHA_LENGTH); +} + +function truncateText( + value: string | undefined, + maxLength = TOAST_DESCRIPTION_MAX, +): string | undefined { + if (!value) return undefined; + if (value.length <= maxLength) return value; + if (maxLength <= 3) return "...".slice(0, maxLength); + return `${value.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`; +} + +function withDescription(title: string, description: string | undefined) { + return description ? { title, description } : { title }; +} + +function summarizeGitActionResult( + result: Pick, + terms: ChangeRequestTerminology, +): { + title: string; + description?: string; +} { + if (result.pr.status === "created" || result.pr.status === "opened_existing") { + const prNumber = result.pr.number ? ` #${result.pr.number}` : ""; + const title = `${result.pr.status === "created" ? "Created" : "Opened"} ${terms.shortLabel}${prNumber}`; + return withDescription(title, truncateText(result.pr.title)); + } + + if (result.push.status === "pushed") { + const shortSha = shortenSha(result.commit.commitSha); + const branch = result.push.upstreamBranch ?? result.push.branch; + const pushedCommitPart = shortSha ? ` ${shortSha}` : ""; + const branchPart = branch ? ` to ${branch}` : ""; + return withDescription( + `Pushed${pushedCommitPart}${branchPart}`, + truncateText(result.commit.subject), + ); + } + + if (result.commit.status === "created") { + const shortSha = shortenSha(result.commit.commitSha); + const title = shortSha ? `Committed ${shortSha}` : "Committed changes"; + return withDescription(title, truncateText(result.commit.subject)); + } + + return { title: "Done" }; +} + +function sanitizeCommitMessage(generated: { + subject: string; + body: string; + branch?: string | undefined; +}): { + subject: string; + body: string; + branch?: string | undefined; +} { + const rawSubject = generated.subject.trim().split(/\r?\n/g)[0]?.trim() ?? ""; + const subject = rawSubject.replace(/[.]+$/g, "").trim(); + const safeSubject = subject.length > 0 ? subject.slice(0, 72).trimEnd() : "Update project files"; + return { + subject: safeSubject, + body: generated.body.trim(), + ...(generated.branch !== undefined ? { branch: generated.branch } : {}), + }; +} + +function sanitizeProgressText(value: string): string | null { + const trimmed = value.trim(); + if (trimmed.length === 0) { + return null; + } + if (trimmed.length <= MAX_PROGRESS_TEXT_LENGTH) { + return trimmed; + } + return trimmed.slice(0, MAX_PROGRESS_TEXT_LENGTH).trimEnd(); +} + +interface CommitAndBranchSuggestion { + subject: string; + body: string; + branch?: string | undefined; + commitMessage: string; +} + +function isCommitAction( + action: GitStackedAction, +): action is "commit" | "commit_push" | "commit_push_pr" { + return action === "commit" || action === "commit_push" || action === "commit_push_pr"; +} + +function formatCommitMessage(subject: string, body: string): string { + const trimmedBody = body.trim(); + if (trimmedBody.length === 0) { + return subject; + } + return `${subject}\n\n${trimmedBody}`; +} + +function parseCustomCommitMessage(raw: string): { subject: string; body: string } | null { + const normalized = raw.replace(/\r\n/g, "\n").trim(); + if (normalized.length === 0) { + return null; + } + + const [firstLine, ...rest] = normalized.split("\n"); + const subject = firstLine?.trim() ?? ""; + if (subject.length === 0) { + return null; + } + + return { + subject, + body: rest.join("\n").trim(), + }; +} + +function appendUnique(values: string[], next: string | null | undefined): void { + const trimmed = next?.trim() ?? ""; + if (trimmed.length === 0 || values.includes(trimmed)) { + return; + } + values.push(trimmed); +} + +function toStatusPr(pr: PullRequestInfo): { + number: number; + title: string; + url: string; + baseRef: string; + headRef: string; + state: "open" | "closed" | "merged"; +} { + return { + number: pr.number, + title: pr.title, + url: pr.url, + baseRef: pr.baseRefName, + headRef: pr.headRefName, + state: pr.state, + }; +} + +function normalizePullRequestReference(reference: string): string { + const trimmed = reference.trim(); + const hashNumber = /^#(\d+)$/.exec(trimmed); + return hashNumber?.[1] ?? trimmed; +} + +function toResolvedPullRequest(pr: { + number: number; + title: string; + url: string; + baseRefName: string; + headRefName: string; + state?: "open" | "closed" | "merged"; +}): ResolvedPullRequest { + return { + number: pr.number, + title: pr.title, + url: pr.url, + baseBranch: pr.baseRefName, + headBranch: pr.headRefName, + state: pr.state ?? "open", + }; +} + +function shouldPreferSshRemote(url: string | null): boolean { + if (!url) return false; + const trimmed = url.trim(); + return trimmed.startsWith("git@") || trimmed.startsWith("ssh://"); +} + +function toPullRequestHeadRemoteInfo(pr: { + isCrossRepository?: boolean | undefined; + headRepositoryNameWithOwner?: string | null | undefined; + headRepositoryOwnerLogin?: string | null | undefined; +}): PullRequestHeadRemoteInfo { + return { + ...(pr.isCrossRepository !== undefined ? { isCrossRepository: pr.isCrossRepository } : {}), + ...(pr.headRepositoryNameWithOwner !== undefined + ? { headRepositoryNameWithOwner: pr.headRepositoryNameWithOwner } + : {}), + ...(pr.headRepositoryOwnerLogin !== undefined + ? { headRepositoryOwnerLogin: pr.headRepositoryOwnerLogin } + : {}), + }; +} + +export const makeGitManager = Effect.fn("makeGitManager")(function* () { + const gitCore = yield* GitVcsDriver; + const sourceControlProviders = yield* SourceControlProviderRegistry; + const textGeneration = yield* TextGeneration; + const projectSetupScriptRunner = yield* ProjectSetupScriptRunner; + + const sourceControlProvider = (cwd: string) => sourceControlProviders.resolve({ cwd }); + const serverSettingsService = yield* ServerSettingsService; + + const createProgressEmitter = ( + input: { cwd: string; action: GitStackedAction }, + options?: GitRunStackedActionOptions, + ) => { + const actionId = options?.actionId ?? randomUUID(); + const reporter = options?.progressReporter; + + const emit = (event: GitActionProgressPayload) => + reporter + ? reporter.publish({ + actionId, + cwd: input.cwd, + action: input.action, + ...event, + } as GitActionProgressEvent) + : Effect.void; + + return { + actionId, + emit, + }; + }; + + const configurePullRequestHeadUpstreamBase = Effect.fn("configurePullRequestHeadUpstream")( + function* ( + cwd: string, + pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo, + localBranch = pullRequest.headBranch, + ) { + const repositoryNameWithOwner = resolveHeadRepositoryNameWithOwner(pullRequest) ?? ""; + if (repositoryNameWithOwner.length === 0) { + return; + } + + const cloneUrls = yield* (yield* sourceControlProvider(cwd)).getRepositoryCloneUrls({ + cwd, + repository: repositoryNameWithOwner, + }); + const originRemoteUrl = yield* gitCore.readConfigValue(cwd, "remote.origin.url"); + const remoteUrl = shouldPreferSshRemote(originRemoteUrl) ? cloneUrls.sshUrl : cloneUrls.url; + const preferredRemoteName = + pullRequest.headRepositoryOwnerLogin?.trim() || + repositoryNameWithOwner.split("/")[0]?.trim() || + "fork"; + const remoteName = yield* gitCore.ensureRemote({ + cwd, + preferredName: preferredRemoteName, + url: remoteUrl, + }); + + yield* gitCore.setBranchUpstream({ + cwd, + branch: localBranch, + remoteName, + remoteBranch: pullRequest.headBranch, + }); + }, + ); + + const configurePullRequestHeadUpstream = ( + cwd: string, + pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo, + localBranch = pullRequest.headBranch, + ) => + configurePullRequestHeadUpstreamBase(cwd, pullRequest, localBranch).pipe( + Effect.catch((error) => + Effect.logWarning( + `GitManager.configurePullRequestHeadUpstream: failed to configure upstream for ${localBranch} -> ${pullRequest.headBranch} in ${cwd}: ${error.message}`, + ).pipe(Effect.asVoid), + ), + ); + + const materializePullRequestHeadBranchBase = Effect.fn("materializePullRequestHeadBranch")( + function* ( + cwd: string, + pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo, + localBranch = pullRequest.headBranch, + ) { + const repositoryNameWithOwner = resolveHeadRepositoryNameWithOwner(pullRequest) ?? ""; + + if (repositoryNameWithOwner.length === 0) { + yield* gitCore.fetchPullRequestBranch({ + cwd, + prNumber: pullRequest.number, + branch: localBranch, + }); + return; + } + + const cloneUrls = yield* (yield* sourceControlProvider(cwd)).getRepositoryCloneUrls({ + cwd, + repository: repositoryNameWithOwner, + }); + const originRemoteUrl = yield* gitCore.readConfigValue(cwd, "remote.origin.url"); + const remoteUrl = shouldPreferSshRemote(originRemoteUrl) ? cloneUrls.sshUrl : cloneUrls.url; + const preferredRemoteName = + pullRequest.headRepositoryOwnerLogin?.trim() || + repositoryNameWithOwner.split("/")[0]?.trim() || + "fork"; + const remoteName = yield* gitCore.ensureRemote({ + cwd, + preferredName: preferredRemoteName, + url: remoteUrl, + }); + + yield* gitCore.fetchRemoteBranch({ + cwd, + remoteName, + remoteBranch: pullRequest.headBranch, + localBranch, + }); + yield* gitCore.setBranchUpstream({ + cwd, + branch: localBranch, + remoteName, + remoteBranch: pullRequest.headBranch, + }); + }, + ); + + const materializePullRequestHeadBranch = ( + cwd: string, + pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo, + localBranch = pullRequest.headBranch, + ) => + materializePullRequestHeadBranchBase(cwd, pullRequest, localBranch).pipe( + Effect.catch(() => + gitCore.fetchPullRequestBranch({ + cwd, + prNumber: pullRequest.number, + branch: localBranch, + }), + ), + ); + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const tempDir = process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp"; + const canonicalizeExistingPath = (value: string) => + fileSystem.realPath(value).pipe(Effect.catch(() => Effect.succeed(value))); + const normalizeStatusCacheKey = canonicalizeExistingPath; + const nonRepositoryStatusDetails = { + isRepo: false, + hasOriginRemote: false, + isDefaultBranch: false, + branch: null, + upstreamRef: null, + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: false, + aheadCount: 0, + behindCount: 0, + } satisfies GitStatusDetails; + const readLocalStatus = Effect.fn("readLocalStatus")(function* (cwd: string) { + const details = yield* gitCore + .statusDetailsLocal(cwd) + .pipe( + Effect.catchIf(isNotGitRepositoryError, () => Effect.succeed(nonRepositoryStatusDetails)), + ); + const hostingProvider = details.isRepo + ? yield* resolveHostingProvider(cwd, details.branch) + : null; + + return { + isRepo: details.isRepo, + ...(hostingProvider ? { sourceControlProvider: hostingProvider } : {}), + hasPrimaryRemote: details.hasOriginRemote, + isDefaultRef: details.isDefaultBranch, + refName: details.branch, + hasWorkingTreeChanges: details.hasWorkingTreeChanges, + workingTree: details.workingTree, + } satisfies VcsStatusLocalResult; + }); + const localStatusResultCache = yield* Cache.makeWith(readLocalStatus, { + capacity: STATUS_RESULT_CACHE_CAPACITY, + timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_RESULT_CACHE_TTL : Duration.zero), + }); + const invalidateLocalStatusResultCache = (cwd: string) => + normalizeStatusCacheKey(cwd).pipe( + Effect.flatMap((cacheKey) => Cache.invalidate(localStatusResultCache, cacheKey)), + ); + const readRemoteStatus = Effect.fn("readRemoteStatus")(function* (cwd: string) { + const details = yield* gitCore + .statusDetails(cwd) + .pipe(Effect.catchIf(isNotGitRepositoryError, () => Effect.succeed(null))); + if (details === null || !details.isRepo) { + return null; + } + + const pr = + details.branch !== null + ? yield* findLatestPr(cwd, { + branch: details.branch, + upstreamRef: details.upstreamRef, + }).pipe( + Effect.map((latest) => { + if (!latest) return null; + // On the default branch, only surface open PRs. + // Merged/closed matches are usually reverse-merge history, not the thread's PR context. + if (details.isDefaultBranch && latest.state !== "open") return null; + return toStatusPr(latest); + }), + Effect.catch(() => Effect.succeed(null)), + ) + : null; + + return { + hasUpstream: details.hasUpstream, + aheadCount: details.aheadCount, + behindCount: details.behindCount, + pr, + } satisfies VcsStatusRemoteResult; + }); + const remoteStatusResultCache = yield* Cache.makeWith(readRemoteStatus, { + capacity: STATUS_RESULT_CACHE_CAPACITY, + timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_RESULT_CACHE_TTL : Duration.zero), + }); + const invalidateRemoteStatusResultCache = (cwd: string) => + normalizeStatusCacheKey(cwd).pipe( + Effect.flatMap((cacheKey) => Cache.invalidate(remoteStatusResultCache, cacheKey)), + ); + + const readConfigValueNullable = (cwd: string, key: string) => + gitCore.readConfigValue(cwd, key).pipe(Effect.catch(() => Effect.succeed(null))); + + const resolveHostingProvider = Effect.fn("resolveHostingProvider")(function* ( + cwd: string, + branch: string | null, + ) { + const preferredRemoteName = + branch === null + ? "origin" + : ((yield* readConfigValueNullable(cwd, `branch.${branch}.remote`)) ?? "origin"); + const remoteUrl = + (yield* readConfigValueNullable(cwd, `remote.${preferredRemoteName}.url`)) ?? + (yield* readConfigValueNullable(cwd, "remote.origin.url")); + + return remoteUrl ? detectSourceControlProviderFromGitRemoteUrl(remoteUrl) : null; + }); + + const resolveRemoteRepositoryContext = Effect.fn("resolveRemoteRepositoryContext")(function* ( + cwd: string, + remoteName: string | null, + ) { + if (!remoteName) { + return { + repositoryNameWithOwner: null, + ownerLogin: null, + }; + } + + const remoteUrl = yield* readConfigValueNullable(cwd, `remote.${remoteName}.url`); + const repositoryNameWithOwner = parseGitHubRepositoryNameWithOwnerFromRemoteUrl(remoteUrl); + return { + repositoryNameWithOwner, + ownerLogin: parseRepositoryOwnerLogin(repositoryNameWithOwner), + }; + }); + + const resolveBranchHeadContext = Effect.fn("resolveBranchHeadContext")(function* ( + cwd: string, + details: { branch: string; upstreamRef: string | null }, + ) { + const remoteName = yield* readConfigValueNullable(cwd, `branch.${details.branch}.remote`); + const headBranchFromUpstream = details.upstreamRef + ? extractBranchNameFromRemoteRef(details.upstreamRef, { remoteName }) + : ""; + const headBranch = headBranchFromUpstream.length > 0 ? headBranchFromUpstream : details.branch; + const shouldProbeLocalBranchSelector = + headBranchFromUpstream.length === 0 || headBranch === details.branch; + + const [remoteRepository, originRepository] = yield* Effect.all( + [ + resolveRemoteRepositoryContext(cwd, remoteName), + resolveRemoteRepositoryContext(cwd, "origin"), + ], + { concurrency: "unbounded" }, + ); + + const isCrossRepository = + remoteRepository.repositoryNameWithOwner !== null && + originRepository.repositoryNameWithOwner !== null + ? remoteRepository.repositoryNameWithOwner.toLowerCase() !== + originRepository.repositoryNameWithOwner.toLowerCase() + : remoteName !== null && + remoteName !== "origin" && + remoteRepository.repositoryNameWithOwner !== null; + + const ownerHeadSelector = + remoteRepository.ownerLogin && headBranch.length > 0 + ? `${remoteRepository.ownerLogin}:${headBranch}` + : null; + const remoteAliasHeadSelector = + remoteName && headBranch.length > 0 ? `${remoteName}:${headBranch}` : null; + const shouldProbeRemoteOwnedSelectors = + isCrossRepository || (remoteName !== null && remoteName !== "origin"); + + const headSelectors: string[] = []; + if (isCrossRepository && shouldProbeRemoteOwnedSelectors) { + appendUnique(headSelectors, ownerHeadSelector); + appendUnique( + headSelectors, + remoteAliasHeadSelector !== ownerHeadSelector ? remoteAliasHeadSelector : null, + ); + } + if (shouldProbeLocalBranchSelector) { + appendUnique(headSelectors, details.branch); + } + appendUnique(headSelectors, headBranch !== details.branch ? headBranch : null); + if (!isCrossRepository && shouldProbeRemoteOwnedSelectors) { + appendUnique(headSelectors, ownerHeadSelector); + appendUnique( + headSelectors, + remoteAliasHeadSelector !== ownerHeadSelector ? remoteAliasHeadSelector : null, + ); + } + + return { + localBranch: details.branch, + headBranch, + headSelectors, + preferredHeadSelector: + ownerHeadSelector && isCrossRepository ? ownerHeadSelector : headBranch, + remoteName, + headRepositoryNameWithOwner: remoteRepository.repositoryNameWithOwner, + headRepositoryOwnerLogin: remoteRepository.ownerLogin, + isCrossRepository, + } satisfies BranchHeadContext; + }); + + const findOpenPr = Effect.fn("findOpenPr")(function* ( + cwd: string, + headContext: Pick< + BranchHeadContext, + | "headBranch" + | "headSelectors" + | "headRepositoryNameWithOwner" + | "headRepositoryOwnerLogin" + | "isCrossRepository" + >, + ) { + for (const headSelector of headContext.headSelectors) { + const pullRequests = yield* (yield* sourceControlProvider(cwd)).listChangeRequests({ + cwd, + headSelector, + state: "open", + limit: 1, + }); + const normalizedPullRequests = pullRequests.map(toPullRequestInfo); + + const firstPullRequest = normalizedPullRequests.find((pullRequest) => + matchesBranchHeadContext(pullRequest, headContext), + ); + if (firstPullRequest) { + return { + number: firstPullRequest.number, + title: firstPullRequest.title, + url: firstPullRequest.url, + baseRefName: firstPullRequest.baseRefName, + headRefName: firstPullRequest.headRefName, + state: "open", + updatedAt: Option.none(), + } satisfies PullRequestInfo; + } + } + + return null; + }); + + const findLatestPr = Effect.fn("findLatestPr")(function* ( + cwd: string, + details: { branch: string; upstreamRef: string | null }, + ) { + const headContext = yield* resolveBranchHeadContext(cwd, details); + const parsedByNumber = new Map(); + + for (const headSelector of headContext.headSelectors) { + const pullRequests = yield* (yield* sourceControlProvider(cwd)).listChangeRequests({ + cwd, + headSelector, + state: "all", + limit: 20, + }); + + for (const pr of pullRequests.map(toPullRequestInfo)) { + if (!matchesBranchHeadContext(pr, headContext)) { + continue; + } + parsedByNumber.set(pr.number, pr); + } + } + + const parsed = Arr.sort(parsedByNumber.values(), pullRequestUpdatedAtDescOrder); + + const latestOpenPr = parsed.find((pr) => pr.state === "open"); + if (latestOpenPr) { + return latestOpenPr; + } + return parsed[0] ?? null; + }); + + const buildCompletionToast = Effect.fn("buildCompletionToast")(function* ( + cwd: string, + result: Pick, + ) { + const terms = yield* sourceControlProvider(cwd).pipe( + Effect.map((provider) => getChangeRequestTerminologyForKind(provider.kind)), + Effect.catch(() => Effect.succeed(getChangeRequestTerminologyForKind("unknown"))), + ); + const summary = summarizeGitActionResult(result, terms); + let latestOpenPr: PullRequestInfo | null = null; + let currentBranchIsDefault = false; + let finalBranchContext: { + branch: string; + upstreamRef: string | null; + hasUpstream: boolean; + } | null = null; + + if (result.action !== "commit") { + const finalStatus = yield* gitCore.statusDetails(cwd); + if (finalStatus.branch) { + finalBranchContext = { + branch: finalStatus.branch, + upstreamRef: finalStatus.upstreamRef, + hasUpstream: finalStatus.hasUpstream, + }; + currentBranchIsDefault = finalStatus.isDefaultBranch; + } + } + + const explicitResultPr = + (result.pr.status === "created" || result.pr.status === "opened_existing") && result.pr.url + ? { + url: result.pr.url, + state: "open" as const, + } + : null; + const shouldLookupExistingOpenPr = + (result.action === "commit_push" || result.action === "push") && + result.push.status === "pushed" && + result.branch.status !== "created" && + !currentBranchIsDefault && + explicitResultPr === null && + finalBranchContext?.hasUpstream === true; + + if (shouldLookupExistingOpenPr && finalBranchContext) { + latestOpenPr = yield* resolveBranchHeadContext(cwd, { + branch: finalBranchContext.branch, + upstreamRef: finalBranchContext.upstreamRef, + }).pipe( + Effect.flatMap((headContext) => findOpenPr(cwd, headContext)), + Effect.catch(() => Effect.succeed(null)), + ); + } + + const openPr = latestOpenPr ?? explicitResultPr; + + const cta = + result.action === "commit" && result.commit.status === "created" + ? { + kind: "run_action" as const, + label: "Push", + action: { kind: "push" as const }, + } + : (result.action === "push" || + result.action === "create_pr" || + result.action === "commit_push" || + result.action === "commit_push_pr") && + openPr?.url && + (!currentBranchIsDefault || + result.pr.status === "created" || + result.pr.status === "opened_existing") + ? { + kind: "open_pr" as const, + label: `View ${terms.shortLabel}`, + url: openPr.url, + } + : (result.action === "push" || result.action === "commit_push") && + result.push.status === "pushed" && + !currentBranchIsDefault + ? { + kind: "run_action" as const, + label: `Create ${terms.shortLabel}`, + action: { kind: "create_pr" as const }, + } + : { + kind: "none" as const, + }; + + return { + ...summary, + cta, + }; + }); + + const resolveBaseBranch = Effect.fn("resolveBaseBranch")(function* ( + cwd: string, + branch: string, + upstreamRef: string | null, + headContext: Pick, + ) { + const configured = yield* gitCore.readConfigValue(cwd, `branch.${branch}.gh-merge-base`); + if (configured) return configured; + + if (upstreamRef && !headContext.isCrossRepository) { + const upstreamBranch = extractBranchNameFromRemoteRef(upstreamRef, { + remoteName: headContext.remoteName, + }); + if (upstreamBranch.length > 0 && upstreamBranch !== branch) { + return upstreamBranch; + } + } + + const defaultFromProvider = yield* sourceControlProvider(cwd).pipe( + Effect.flatMap((provider) => provider.getDefaultBranch({ cwd })), + Effect.catch(() => Effect.succeed(null)), + ); + if (defaultFromProvider) { + return defaultFromProvider; + } + + return "main"; + }); + + const resolveCommitAndBranchSuggestion = Effect.fn("resolveCommitAndBranchSuggestion")( + function* (input: { + cwd: string; + branch: string | null; + commitMessage?: string; + /** When true, also produce a semantic feature branch name. */ + includeBranch?: boolean; + filePaths?: readonly string[]; + modelSelection: ModelSelection; + }) { + const context = yield* gitCore.prepareCommitContext(input.cwd, input.filePaths); + if (!context) { + return null; + } + + const customCommit = parseCustomCommitMessage(input.commitMessage ?? ""); + if (customCommit) { + return { + subject: customCommit.subject, + body: customCommit.body, + ...(input.includeBranch + ? { branch: sanitizeFeatureBranchName(customCommit.subject) } + : {}), + commitMessage: formatCommitMessage(customCommit.subject, customCommit.body), + }; + } + + const generated = yield* textGeneration + .generateCommitMessage({ + cwd: input.cwd, + branch: input.branch, + stagedSummary: limitContext(context.stagedSummary, 8_000), + stagedPatch: limitContext(context.stagedPatch, 50_000), + ...(input.includeBranch ? { includeBranch: true } : {}), + modelSelection: input.modelSelection, + }) + .pipe(Effect.map((result) => sanitizeCommitMessage(result))); + + return { + subject: generated.subject, + body: generated.body, + ...(generated.branch !== undefined ? { branch: generated.branch } : {}), + commitMessage: formatCommitMessage(generated.subject, generated.body), + }; + }, + ); + + const runCommitStep = Effect.fn("runCommitStep")(function* ( + modelSelection: ModelSelection, + cwd: string, + action: "commit" | "commit_push" | "commit_push_pr", + branch: string | null, + commitMessage?: string, + preResolvedSuggestion?: CommitAndBranchSuggestion, + filePaths?: readonly string[], + progressReporter?: GitActionProgressReporter, + actionId?: string, + ) { + const emit = (event: GitActionProgressPayload) => + progressReporter && actionId + ? progressReporter.publish({ + actionId, + cwd, + action, + ...event, + } as GitActionProgressEvent) + : Effect.void; + + let suggestion: CommitAndBranchSuggestion | null | undefined = preResolvedSuggestion; + if (!suggestion) { + const needsGeneration = !commitMessage?.trim(); + if (needsGeneration) { + yield* emit({ + kind: "phase_started", + phase: "commit", + label: "Generating commit message...", + }); + } + suggestion = yield* resolveCommitAndBranchSuggestion({ + cwd, + branch, + ...(commitMessage ? { commitMessage } : {}), + ...(filePaths ? { filePaths } : {}), + modelSelection, + }); + } + if (!suggestion) { + return { status: "skipped_no_changes" as const }; + } + + yield* emit({ + kind: "phase_started", + phase: "commit", + label: "Committing...", + }); + + let currentHookName: string | null = null; + const commitProgress = + progressReporter && actionId + ? { + onOutputLine: ({ stream, text }: { stream: "stdout" | "stderr"; text: string }) => { + const sanitized = sanitizeProgressText(text); + if (!sanitized) { + return Effect.void; + } + return emit({ + kind: "hook_output", + hookName: currentHookName, + stream, + text: sanitized, + }); + }, + onHookStarted: (hookName: string) => { + currentHookName = hookName; + return emit({ + kind: "hook_started", + hookName, + }); + }, + onHookFinished: ({ + hookName, + exitCode, + durationMs, + }: { + hookName: string; + exitCode: number | null; + durationMs: number | null; + }) => { + if (currentHookName === hookName) { + currentHookName = null; + } + return emit({ + kind: "hook_finished", + hookName, + exitCode, + durationMs, + }); + }, + } + : null; + const { commitSha } = yield* gitCore.commit(cwd, suggestion.subject, suggestion.body, { + timeoutMs: COMMIT_TIMEOUT_MS, + ...(commitProgress ? { progress: commitProgress } : {}), + }); + if (currentHookName !== null) { + yield* emit({ + kind: "hook_finished", + hookName: currentHookName, + exitCode: 0, + durationMs: null, + }); + currentHookName = null; + } + return { + status: "created" as const, + commitSha, + subject: suggestion.subject, + }; + }); + + const runPrStep = Effect.fn("runPrStep")(function* ( + modelSelection: ModelSelection, + cwd: string, + fallbackBranch: string | null, + emit: GitActionProgressEmitter, + ) { + const provider = yield* sourceControlProvider(cwd); + const terms = getChangeRequestTerminologyForKind(provider.kind); + const details = yield* gitCore.statusDetails(cwd); + const branch = details.branch ?? fallbackBranch; + if (!branch) { + return yield* gitManagerError( + "runPrStep", + "Cannot create a pull request from detached HEAD.", + ); + } + if (!details.hasUpstream) { + return yield* gitManagerError( + "runPrStep", + "Current branch has not been pushed. Push before creating a PR.", + ); + } + + const headContext = yield* resolveBranchHeadContext(cwd, { + branch, + upstreamRef: details.upstreamRef, + }); + + const existing = yield* findOpenPr(cwd, headContext); + if (existing) { + return { + status: "opened_existing" as const, + url: existing.url, + number: existing.number, + baseBranch: existing.baseRefName, + headBranch: existing.headRefName, + title: existing.title, + }; + } + + const baseBranch = yield* resolveBaseBranch(cwd, branch, details.upstreamRef, headContext); + yield* emit({ + kind: "phase_started", + phase: "pr", + label: `Generating ${terms.shortLabel} content...`, + }); + const rangeContext = yield* gitCore.readRangeContext(cwd, baseBranch); + + const generated = yield* textGeneration.generatePrContent({ + cwd, + baseBranch, + headBranch: headContext.headBranch, + commitSummary: limitContext(rangeContext.commitSummary, 20_000), + diffSummary: limitContext(rangeContext.diffSummary, 20_000), + diffPatch: limitContext(rangeContext.diffPatch, 60_000), + modelSelection, + }); + + const bodyFile = path.join(tempDir, `t3code-pr-body-${process.pid}-${randomUUID()}.md`); + yield* fileSystem + .writeFileString(bodyFile, generated.body) + .pipe( + Effect.mapError((cause) => + gitManagerError("runPrStep", "Failed to write pull request body temp file.", cause), + ), + ); + yield* emit({ + kind: "phase_started", + phase: "pr", + label: `Creating ${terms.singular}...`, + }); + yield* provider + .createChangeRequest({ + cwd, + baseRefName: baseBranch, + headSelector: headContext.preferredHeadSelector, + title: generated.title, + bodyFile, + }) + .pipe(Effect.ensuring(fileSystem.remove(bodyFile).pipe(Effect.catch(() => Effect.void)))); + + const created = yield* findOpenPr(cwd, headContext); + if (!created) { + return { + status: "created" as const, + baseBranch, + headBranch: headContext.headBranch, + title: generated.title, + }; + } + + return { + status: "created" as const, + url: created.url, + number: created.number, + baseBranch: created.baseRefName, + headBranch: created.headRefName, + title: created.title, + }; + }); + + const localStatus: GitManagerShape["localStatus"] = Effect.fn("localStatus")(function* (input) { + const cacheKey = yield* normalizeStatusCacheKey(input.cwd); + return yield* Cache.get(localStatusResultCache, cacheKey); + }); + const remoteStatus: GitManagerShape["remoteStatus"] = Effect.fn("remoteStatus")( + function* (input) { + const cacheKey = yield* normalizeStatusCacheKey(input.cwd); + return yield* Cache.get(remoteStatusResultCache, cacheKey); + }, + ); + const status: GitManagerShape["status"] = Effect.fn("status")(function* (input) { + const [local, remote] = yield* Effect.all([localStatus(input), remoteStatus(input)]); + return mergeGitStatusParts(local, remote); + }); + const invalidateLocalStatus: GitManagerShape["invalidateLocalStatus"] = Effect.fn( + "invalidateLocalStatus", + )(function* (cwd) { + yield* invalidateLocalStatusResultCache(cwd); + }); + const invalidateRemoteStatus: GitManagerShape["invalidateRemoteStatus"] = Effect.fn( + "invalidateRemoteStatus", + )(function* (cwd) { + yield* invalidateRemoteStatusResultCache(cwd); + }); + const invalidateStatus: GitManagerShape["invalidateStatus"] = Effect.fn("invalidateStatus")( + function* (cwd) { + yield* invalidateLocalStatusResultCache(cwd); + yield* invalidateRemoteStatusResultCache(cwd); + }, + ); + + const resolvePullRequest: GitManagerShape["resolvePullRequest"] = Effect.fn("resolvePullRequest")( + function* (input) { + const pullRequest = yield* (yield* sourceControlProvider(input.cwd)) + .getChangeRequest({ + cwd: input.cwd, + reference: normalizePullRequestReference(input.reference), + }) + .pipe(Effect.map((resolved) => toResolvedPullRequest(resolved))); + + return { pullRequest }; + }, + ); + + const preparePullRequestThread: GitManagerShape["preparePullRequestThread"] = Effect.fn( + "preparePullRequestThread", + )(function* (input) { + const maybeRunSetupScript = (worktreePath: string) => { + if (!input.threadId) { + return Effect.void; + } + return projectSetupScriptRunner + .runForThread({ + threadId: input.threadId, + projectCwd: input.cwd, + worktreePath, + }) + .pipe( + Effect.catch((error) => + Effect.logWarning( + `GitManager.preparePullRequestThread: failed to launch worktree setup script for thread ${input.threadId} in ${worktreePath}: ${error.message}`, + ).pipe(Effect.asVoid), + ), + ); + }; + return yield* Effect.gen(function* () { + const normalizedReference = normalizePullRequestReference(input.reference); + const rootWorktreePath = yield* canonicalizeExistingPath(input.cwd); + const pullRequestSummary = yield* (yield* sourceControlProvider(input.cwd)).getChangeRequest({ + cwd: input.cwd, + reference: normalizedReference, + }); + const pullRequest = toResolvedPullRequest(pullRequestSummary); + + if (input.mode === "local") { + yield* (yield* sourceControlProvider(input.cwd)).checkoutChangeRequest({ + cwd: input.cwd, + reference: normalizedReference, + force: true, + }); + const details = yield* gitCore.statusDetails(input.cwd); + yield* configurePullRequestHeadUpstream( + input.cwd, + { + ...pullRequest, + ...toPullRequestHeadRemoteInfo(pullRequestSummary), + }, + details.branch ?? pullRequest.headBranch, + ); + return { + pullRequest, + branch: details.branch ?? pullRequest.headBranch, + worktreePath: null, + }; + } + + const ensureExistingWorktreeUpstream = Effect.fn("ensureExistingWorktreeUpstream")(function* ( + worktreePath: string, + ) { + const details = yield* gitCore.statusDetails(worktreePath); + yield* configurePullRequestHeadUpstream( + worktreePath, + { + ...pullRequest, + ...toPullRequestHeadRemoteInfo(pullRequestSummary), + }, + details.branch ?? pullRequest.headBranch, + ); + }); + + const pullRequestWithRemoteInfo = { + ...pullRequest, + ...toPullRequestHeadRemoteInfo(pullRequestSummary), + } as const; + const localPullRequestBranch = + resolvePullRequestWorktreeLocalBranchName(pullRequestWithRemoteInfo); + + const findLocalHeadBranch = Effect.fn("findLocalHeadBranch")(function* (cwd: string) { + const result = yield* gitCore.listRefs({ cwd }); + const localBranch = result.refs.find( + (branch) => !branch.isRemote && branch.name === localPullRequestBranch, + ); + if (localBranch) { + return localBranch; + } + if (localPullRequestBranch === pullRequest.headBranch) { + return null; + } + + for (const branch of result.refs) { + if (branch.isRemote || branch.name !== pullRequest.headBranch || !branch.worktreePath) { + continue; + } + + const worktreePath = yield* canonicalizeExistingPath(branch.worktreePath); + if (worktreePath !== rootWorktreePath) { + return branch; + } + } + + return null; + }); + + const existingBranchBeforeFetch = yield* findLocalHeadBranch(input.cwd); + const existingBranchBeforeFetchPath = existingBranchBeforeFetch?.worktreePath + ? yield* canonicalizeExistingPath(existingBranchBeforeFetch.worktreePath) + : null; + if ( + existingBranchBeforeFetch?.worktreePath && + existingBranchBeforeFetchPath !== rootWorktreePath + ) { + yield* ensureExistingWorktreeUpstream(existingBranchBeforeFetch.worktreePath); + return { + pullRequest, + branch: localPullRequestBranch, + worktreePath: existingBranchBeforeFetch.worktreePath, + }; + } + if (existingBranchBeforeFetchPath === rootWorktreePath) { + return yield* gitManagerError( + "preparePullRequestThread", + "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.", + ); + } + + yield* materializePullRequestHeadBranch( + input.cwd, + pullRequestWithRemoteInfo, + localPullRequestBranch, + ); + + const existingBranchAfterFetch = yield* findLocalHeadBranch(input.cwd); + const existingBranchAfterFetchPath = existingBranchAfterFetch?.worktreePath + ? yield* canonicalizeExistingPath(existingBranchAfterFetch.worktreePath) + : null; + if ( + existingBranchAfterFetch?.worktreePath && + existingBranchAfterFetchPath !== rootWorktreePath + ) { + yield* ensureExistingWorktreeUpstream(existingBranchAfterFetch.worktreePath); + return { + pullRequest, + branch: localPullRequestBranch, + worktreePath: existingBranchAfterFetch.worktreePath, + }; + } + if (existingBranchAfterFetchPath === rootWorktreePath) { + return yield* gitManagerError( + "preparePullRequestThread", + "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.", + ); + } + + const worktree = yield* gitCore.createWorktree({ + cwd: input.cwd, + refName: localPullRequestBranch, + path: null, + }); + yield* ensureExistingWorktreeUpstream(worktree.worktree.path); + yield* maybeRunSetupScript(worktree.worktree.path); + + return { + pullRequest, + branch: worktree.worktree.refName, + worktreePath: worktree.worktree.path, + }; + }).pipe(Effect.ensuring(invalidateStatus(input.cwd))); + }); + + const runFeatureBranchStep = Effect.fn("runFeatureBranchStep")(function* ( + modelSelection: ModelSelection, + cwd: string, + branch: string | null, + commitMessage?: string, + filePaths?: readonly string[], + ) { + const suggestion = yield* resolveCommitAndBranchSuggestion({ + cwd, + branch, + ...(commitMessage ? { commitMessage } : {}), + ...(filePaths ? { filePaths } : {}), + includeBranch: true, + modelSelection, + }); + if (!suggestion) { + return yield* gitManagerError( + "runFeatureBranchStep", + "Cannot create a feature branch because there are no changes to commit.", + ); + } + + const preferredBranch = suggestion.branch ?? sanitizeFeatureBranchName(suggestion.subject); + const existingBranchNames = yield* gitCore.listLocalBranchNames(cwd); + const resolvedBranch = resolveAutoFeatureBranchName(existingBranchNames, preferredBranch); + + yield* gitCore.createRef({ cwd, refName: resolvedBranch }); + yield* Effect.scoped(gitCore.switchRef({ cwd, refName: resolvedBranch })); + + return { + branchStep: { status: "created" as const, name: resolvedBranch }, + resolvedCommitMessage: suggestion.commitMessage, + resolvedCommitSuggestion: suggestion, + }; + }); + + const runStackedAction: GitManagerShape["runStackedAction"] = Effect.fn("runStackedAction")( + function* (input, options) { + const progress = createProgressEmitter(input, options); + const currentPhase = yield* Ref.make>(Option.none()); + + const runAction = Effect.fn("runStackedAction.runAction")(function* (): Effect.fn.Return< + GitRunStackedActionResult, + GitManagerServiceError + > { + const initialStatus = yield* gitCore.statusDetails(input.cwd); + const wantsCommit = isCommitAction(input.action); + const wantsPush = + input.action === "push" || + input.action === "commit_push" || + input.action === "commit_push_pr" || + (input.action === "create_pr" && + (!initialStatus.hasUpstream || initialStatus.aheadCount > 0)); + const wantsPr = input.action === "create_pr" || input.action === "commit_push_pr"; + + if (input.featureBranch && !wantsCommit) { + return yield* gitManagerError( + "runStackedAction", + "Feature-branch checkout is only supported for commit actions.", + ); + } + if (input.action === "push" && initialStatus.hasWorkingTreeChanges) { + return yield* gitManagerError( + "runStackedAction", + "Commit or stash local changes before pushing.", + ); + } + if (input.action === "create_pr" && initialStatus.hasWorkingTreeChanges) { + return yield* gitManagerError( + "runStackedAction", + "Commit local changes before creating a PR.", + ); + } + + const phases: GitActionProgressPhase[] = [ + ...(input.featureBranch ? (["branch"] as const) : []), + ...(wantsCommit ? (["commit"] as const) : []), + ...(wantsPush ? (["push"] as const) : []), + ...(wantsPr ? (["pr"] as const) : []), + ]; + + yield* progress.emit({ + kind: "action_started", + phases, + }); + + if (!input.featureBranch && wantsPush && !initialStatus.branch) { + return yield* gitManagerError("runStackedAction", "Cannot push from detached HEAD."); + } + if (!input.featureBranch && wantsPr && !initialStatus.branch) { + return yield* gitManagerError( + "runStackedAction", + "Cannot create a pull request from detached HEAD.", + ); + } + + let branchStep: { status: "created" | "skipped_not_requested"; name?: string }; + let commitMessageForStep = input.commitMessage; + let preResolvedCommitSuggestion: CommitAndBranchSuggestion | undefined = undefined; + + const modelSelection = yield* serverSettingsService.getSettings.pipe( + Effect.map((settings) => settings.textGenerationModelSelection), + Effect.mapError((cause) => + gitManagerError("runStackedAction", "Failed to get server settings.", cause), + ), + ); + + if (input.featureBranch) { + yield* Ref.set(currentPhase, Option.some("branch")); + yield* progress.emit({ + kind: "phase_started", + phase: "branch", + label: "Preparing feature branch...", + }); + const result = yield* runFeatureBranchStep( + modelSelection, + input.cwd, + initialStatus.branch, + input.commitMessage, + input.filePaths, + ); + branchStep = result.branchStep; + commitMessageForStep = result.resolvedCommitMessage; + preResolvedCommitSuggestion = result.resolvedCommitSuggestion; + } else { + branchStep = { status: "skipped_not_requested" as const }; + } + + const currentBranch = branchStep.name ?? initialStatus.branch; + const commitAction = isCommitAction(input.action) ? input.action : null; + const changeRequestTerms = wantsPr + ? yield* sourceControlProvider(input.cwd).pipe( + Effect.map((provider) => getChangeRequestTerminologyForKind(provider.kind)), + Effect.catch(() => Effect.succeed(getChangeRequestTerminologyForKind("unknown"))), + ) + : null; + + const commit = commitAction + ? yield* Ref.set(currentPhase, Option.some("commit")).pipe( + Effect.flatMap(() => + runCommitStep( + modelSelection, + input.cwd, + commitAction, + currentBranch, + commitMessageForStep, + preResolvedCommitSuggestion, + input.filePaths, + options?.progressReporter, + progress.actionId, + ), + ), + ) + : { status: "skipped_not_requested" as const }; + + const push = wantsPush + ? yield* progress + .emit({ + kind: "phase_started", + phase: "push", + label: "Pushing...", + }) + .pipe( + Effect.tap(() => Ref.set(currentPhase, Option.some("push"))), + Effect.flatMap(() => gitCore.pushCurrentBranch(input.cwd, currentBranch)), + ) + : { status: "skipped_not_requested" as const }; + + const pr = wantsPr + ? yield* progress + .emit({ + kind: "phase_started", + phase: "pr", + label: `Preparing ${changeRequestTerms?.shortLabel ?? "PR"}...`, + }) + .pipe( + Effect.tap(() => Ref.set(currentPhase, Option.some("pr"))), + Effect.flatMap(() => + runPrStep(modelSelection, input.cwd, currentBranch, progress.emit), + ), + ) + : { status: "skipped_not_requested" as const }; + + const toast = yield* buildCompletionToast(input.cwd, { + action: input.action, + branch: branchStep, + commit, + push, + pr, + }); + + const result = { + action: input.action, + branch: branchStep, + commit, + push, + pr, + toast, + }; + yield* progress.emit({ + kind: "action_finished", + result, + }); + return result; + }); + + return yield* runAction().pipe( + Effect.ensuring(invalidateStatus(input.cwd)), + Effect.tapError((error) => + Effect.flatMap(Ref.get(currentPhase), (phase) => + progress.emit({ + kind: "action_failed", + phase: Option.getOrNull(phase), + message: error.message, + }), + ), + ), + ); + }, + ); + + return { + localStatus, + remoteStatus, + status, + invalidateLocalStatus, + invalidateRemoteStatus, + invalidateStatus, + resolvePullRequest, + preparePullRequestThread, + runStackedAction, + } satisfies GitManagerShape; +}); + +export const layer = Layer.effect(GitManager, makeGitManager()); diff --git a/apps/server/src/git/GitWorkflowService.test.ts b/apps/server/src/git/GitWorkflowService.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..1357ae249a90145a351cfd44df3c4c2295fced62 --- /dev/null +++ b/apps/server/src/git/GitWorkflowService.test.ts @@ -0,0 +1,132 @@ +import { assert, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; +import { describe, vi } from "vitest"; + +import { GitManager } from "./GitManager.ts"; +import { GitWorkflowService, layer as GitWorkflowServiceLayer } from "./GitWorkflowService.ts"; +import { GitVcsDriver } from "../vcs/GitVcsDriver.ts"; +import { VcsDriverRegistry, type VcsDriverRegistryShape } from "../vcs/VcsDriverRegistry.ts"; + +function makeLayer(input: { readonly detect: VcsDriverRegistryShape["detect"] }) { + return GitWorkflowServiceLayer.pipe( + Layer.provide( + Layer.mock(VcsDriverRegistry)({ + detect: input.detect, + }), + ), + Layer.provide(Layer.mock(GitVcsDriver)({})), + Layer.provide(Layer.mock(GitManager)({})), + ); +} + +describe("GitWorkflowService", () => { + it.effect("returns an empty local status when no VCS repository is detected", () => + Effect.gen(function* () { + const workflow = yield* GitWorkflowService; + const status = yield* workflow.localStatus({ cwd: "/not-a-repo" }); + + assert.deepStrictEqual(status, { + isRepo: false, + hasPrimaryRemote: false, + isDefaultRef: false, + refName: null, + hasWorkingTreeChanges: false, + workingTree: { + files: [], + insertions: 0, + deletions: 0, + }, + }); + }).pipe( + Effect.provide( + makeLayer({ + detect: () => Effect.succeed(null), + }), + ), + ), + ); + + it.effect("returns an empty full status when no VCS repository is detected", () => + Effect.gen(function* () { + const workflow = yield* GitWorkflowService; + const status = yield* workflow.status({ cwd: "/not-a-repo" }); + + assert.deepStrictEqual(status, { + isRepo: false, + hasPrimaryRemote: false, + isDefaultRef: false, + refName: null, + hasWorkingTreeChanges: false, + workingTree: { + files: [], + insertions: 0, + deletions: 0, + }, + hasUpstream: false, + aheadCount: 0, + behindCount: 0, + pr: null, + }); + }).pipe( + Effect.provide( + makeLayer({ + detect: () => Effect.succeed(null), + }), + ), + ), + ); + + it.effect("does not call GitManager status methods when no VCS repository is detected", () => { + const localStatus = vi.fn(); + const remoteStatus = vi.fn(); + const status = vi.fn(); + + const testLayer = GitWorkflowServiceLayer.pipe( + Layer.provide( + Layer.mock(VcsDriverRegistry)({ + detect: () => Effect.succeed(null), + }), + ), + Layer.provide(Layer.mock(GitVcsDriver)({})), + Layer.provide( + Layer.mock(GitManager)({ + localStatus, + remoteStatus, + status, + }), + ), + ); + + return Effect.gen(function* () { + const workflow = yield* GitWorkflowService; + yield* workflow.localStatus({ cwd: "/not-a-repo" }); + yield* workflow.remoteStatus({ cwd: "/not-a-repo" }); + yield* workflow.status({ cwd: "/not-a-repo" }); + + assert.equal(localStatus.mock.calls.length, 0); + assert.equal(remoteStatus.mock.calls.length, 0); + assert.equal(status.mock.calls.length, 0); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("returns an empty ref list when no VCS repository is detected", () => + Effect.gen(function* () { + const workflow = yield* GitWorkflowService; + const refs = yield* workflow.listRefs({ cwd: "/not-a-repo" }); + + assert.deepStrictEqual(refs, { + refs: [], + isRepo: false, + hasPrimaryRemote: false, + nextCursor: null, + totalCount: 0, + }); + }).pipe( + Effect.provide( + makeLayer({ + detect: () => Effect.succeed(null), + }), + ), + ), + ); +}); diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts new file mode 100644 index 0000000000000000000000000000000000000000..99f0cc2a6d38d54b30c65ba337d4cd620ca228a3 --- /dev/null +++ b/apps/server/src/git/GitWorkflowService.ts @@ -0,0 +1,313 @@ +import { Context, Effect, Layer } from "effect"; + +import { + GitManagerError, + GitCommandError, + type VcsSwitchRefInput, + type VcsSwitchRefResult, + type VcsCreateRefInput, + type VcsCreateRefResult, + type VcsCreateWorktreeInput, + type VcsCreateWorktreeResult, + type VcsListRefsInput, + type VcsListRefsResult, + type GitManagerServiceError, + type GitPreparePullRequestThreadInput, + type GitPreparePullRequestThreadResult, + type GitPullRequestRefInput, + type VcsPullResult, + type VcsRemoveWorktreeInput, + type GitResolvePullRequestResult, + type GitRunStackedActionInput, + type GitRunStackedActionResult, + type VcsStatusInput, + type VcsStatusLocalResult, + type VcsStatusRemoteResult, + type VcsStatusResult, +} from "@t3tools/contracts"; + +import { GitManager, type GitRunStackedActionOptions } from "./GitManager.ts"; +import { GitVcsDriver } from "../vcs/GitVcsDriver.ts"; +import { VcsDriverRegistry } from "../vcs/VcsDriverRegistry.ts"; + +export interface GitWorkflowServiceShape { + readonly status: ( + input: VcsStatusInput, + ) => Effect.Effect; + readonly localStatus: ( + input: VcsStatusInput, + ) => Effect.Effect; + readonly remoteStatus: ( + input: VcsStatusInput, + ) => Effect.Effect; + readonly invalidateLocalStatus: (cwd: string) => Effect.Effect; + readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect; + readonly invalidateStatus: (cwd: string) => Effect.Effect; + readonly pullCurrentBranch: (cwd: string) => Effect.Effect; + readonly runStackedAction: ( + input: GitRunStackedActionInput, + options?: GitRunStackedActionOptions, + ) => Effect.Effect; + readonly resolvePullRequest: ( + input: GitPullRequestRefInput, + ) => Effect.Effect; + readonly preparePullRequestThread: ( + input: GitPreparePullRequestThreadInput, + ) => Effect.Effect; + readonly listRefs: (input: VcsListRefsInput) => Effect.Effect; + readonly createWorktree: ( + input: VcsCreateWorktreeInput, + ) => Effect.Effect; + readonly removeWorktree: (input: VcsRemoveWorktreeInput) => Effect.Effect; + readonly createRef: ( + input: VcsCreateRefInput, + ) => Effect.Effect; + readonly switchRef: ( + input: VcsSwitchRefInput, + ) => Effect.Effect; + readonly renameBranch: (input: { + readonly cwd: string; + readonly oldBranch: string; + readonly newBranch: string; + }) => Effect.Effect<{ readonly branch: string }, GitManagerServiceError>; +} + +export class GitWorkflowService extends Context.Service< + GitWorkflowService, + GitWorkflowServiceShape +>()("t3/git/GitWorkflowService") {} + +const unsupportedGitWorkflow = (operation: string, cwd: string, detail: string) => + new GitManagerError({ + operation, + detail: `${detail} (${cwd})`, + }); + +const unsupportedGitCommand = (operation: string, cwd: string, detail: string) => + new GitCommandError({ + operation, + command: "vcs-route", + cwd, + detail, + }); + +function nonRepositoryLocalStatus(): VcsStatusLocalResult { + return { + isRepo: false, + hasPrimaryRemote: false, + isDefaultRef: false, + refName: null, + hasWorkingTreeChanges: false, + workingTree: { + files: [], + insertions: 0, + deletions: 0, + }, + }; +} + +function nonRepositoryStatus(): VcsStatusResult { + return { + ...nonRepositoryLocalStatus(), + hasUpstream: false, + aheadCount: 0, + behindCount: 0, + pr: null, + }; +} + +function nonRepositoryListRefs(): VcsListRefsResult { + return { + refs: [], + isRepo: false, + hasPrimaryRemote: false, + nextCursor: null, + totalCount: 0, + }; +} + +export const make = Effect.fn("makeGitWorkflowService")(function* () { + const registry = yield* VcsDriverRegistry; + const git = yield* GitVcsDriver; + const gitManager = yield* GitManager; + + const ensureGit = Effect.fn("GitWorkflowService.ensureGit")(function* ( + operation: string, + cwd: string, + ) { + const handle = yield* registry + .resolve({ cwd }) + .pipe( + Effect.mapError((error) => + unsupportedGitWorkflow( + operation, + cwd, + error instanceof Error ? error.message : String(error), + ), + ), + ); + if (handle.kind !== "git") { + return yield* unsupportedGitWorkflow( + operation, + cwd, + `The ${operation} workflow currently supports Git repositories only; detected ${handle.kind}.`, + ); + } + }); + + const ensureGitCommand = Effect.fn("GitWorkflowService.ensureGitCommand")(function* ( + operation: string, + cwd: string, + ) { + const handle = yield* registry + .resolve({ cwd }) + .pipe( + Effect.mapError((error) => + unsupportedGitCommand( + operation, + cwd, + error instanceof Error ? error.message : String(error), + ), + ), + ); + if (handle.kind !== "git") { + return yield* unsupportedGitCommand( + operation, + cwd, + `The ${operation} command currently supports Git repositories only; detected ${handle.kind}.`, + ); + } + }); + + const detectGitRepositoryForStatus = Effect.fn("GitWorkflowService.detectGitRepositoryForStatus")( + function* (operation: string, cwd: string) { + const handle = yield* registry + .detect({ cwd }) + .pipe( + Effect.mapError((error) => + unsupportedGitWorkflow( + operation, + cwd, + error instanceof Error ? error.message : String(error), + ), + ), + ); + if (!handle) { + return false; + } + if (handle.kind !== "git") { + return yield* unsupportedGitWorkflow( + operation, + cwd, + `The ${operation} workflow currently supports Git repositories only; detected ${handle.kind}.`, + ); + } + return true; + }, + ); + + const detectGitRepositoryForCommand = Effect.fn( + "GitWorkflowService.detectGitRepositoryForCommand", + )(function* (operation: string, cwd: string) { + const handle = yield* registry + .detect({ cwd }) + .pipe( + Effect.mapError((error) => + unsupportedGitCommand( + operation, + cwd, + error instanceof Error ? error.message : String(error), + ), + ), + ); + if (!handle) { + return false; + } + if (handle.kind !== "git") { + return yield* unsupportedGitCommand( + operation, + cwd, + `The ${operation} command currently supports Git repositories only; detected ${handle.kind}.`, + ); + } + return true; + }); + + const routeGitManager = + ( + operation: string, + run: (input: Input) => Effect.Effect, + ) => + (input: Input) => + ensureGit(operation, input.cwd).pipe(Effect.andThen(run(input))); + + return GitWorkflowService.of({ + status: (input) => + detectGitRepositoryForStatus("GitWorkflowService.status", input.cwd).pipe( + Effect.flatMap((isGitRepository) => + isGitRepository ? gitManager.status(input) : Effect.succeed(nonRepositoryStatus()), + ), + ), + localStatus: (input) => + detectGitRepositoryForStatus("GitWorkflowService.localStatus", input.cwd).pipe( + Effect.flatMap((isGitRepository) => + isGitRepository + ? gitManager.localStatus(input) + : Effect.succeed(nonRepositoryLocalStatus()), + ), + ), + remoteStatus: (input) => + detectGitRepositoryForStatus("GitWorkflowService.remoteStatus", input.cwd).pipe( + Effect.flatMap((isGitRepository) => + isGitRepository ? gitManager.remoteStatus(input) : Effect.succeed(null), + ), + ), + invalidateLocalStatus: gitManager.invalidateLocalStatus, + invalidateRemoteStatus: gitManager.invalidateRemoteStatus, + invalidateStatus: gitManager.invalidateStatus, + pullCurrentBranch: (cwd) => + ensureGitCommand("GitWorkflowService.pullCurrentBranch", cwd).pipe( + Effect.andThen(git.pullCurrentBranch(cwd)), + ), + runStackedAction: (input, options) => + ensureGit("GitWorkflowService.runStackedAction", input.cwd).pipe( + Effect.andThen(gitManager.runStackedAction(input, options)), + ), + resolvePullRequest: routeGitManager( + "GitWorkflowService.resolvePullRequest", + gitManager.resolvePullRequest, + ), + preparePullRequestThread: routeGitManager( + "GitWorkflowService.preparePullRequestThread", + gitManager.preparePullRequestThread, + ), + listRefs: (input) => + detectGitRepositoryForCommand("GitWorkflowService.listRefs", input.cwd).pipe( + Effect.flatMap((isGitRepository) => + isGitRepository ? git.listRefs(input) : Effect.succeed(nonRepositoryListRefs()), + ), + ), + createWorktree: (input) => + ensureGitCommand("GitWorkflowService.createWorktree", input.cwd).pipe( + Effect.andThen(git.createWorktree(input)), + ), + removeWorktree: (input) => + ensureGitCommand("GitWorkflowService.removeWorktree", input.cwd).pipe( + Effect.andThen(git.removeWorktree(input)), + ), + createRef: (input) => + ensureGitCommand("GitWorkflowService.createRef", input.cwd).pipe( + Effect.andThen(git.createRef(input)), + ), + switchRef: (input) => + ensureGitCommand("GitWorkflowService.switchRef", input.cwd).pipe( + Effect.andThen(Effect.scoped(git.switchRef(input))), + ), + renameBranch: (input) => + ensureGit("GitWorkflowService.renameBranch", input.cwd).pipe( + Effect.andThen(git.renameBranch(input)), + ), + }); +}); + +export const layer = Layer.effect(GitWorkflowService, make()); diff --git a/apps/server/src/git/Layers/ClaudeTextGeneration.test.ts b/apps/server/src/git/Layers/ClaudeTextGeneration.test.ts deleted file mode 100644 index eb7bf62ad487c092c718d377587694ddc42ed6f0..0000000000000000000000000000000000000000 --- a/apps/server/src/git/Layers/ClaudeTextGeneration.test.ts +++ /dev/null @@ -1,339 +0,0 @@ -import { ClaudeSettings, ProviderInstanceId } from "@t3tools/contracts"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { it } from "@effect/vitest"; -import { Effect, FileSystem, Layer, Path, Schema } from "effect"; -import { createModelSelection } from "@t3tools/shared/model"; -import { expect } from "vitest"; - -import { ServerConfig } from "../../config.ts"; -import { type TextGenerationShape } from "../Services/TextGeneration.ts"; -import { sanitizeThreadTitle } from "../Utils.ts"; -import { makeClaudeTextGeneration } from "./ClaudeTextGeneration.ts"; - -const ClaudeTextGenerationTestLayer = ServerConfig.layerTest(process.cwd(), { - prefix: "t3code-claude-text-generation-test-", -}).pipe(Layer.provideMerge(NodeServices.layer)); - -function makeFakeClaudeBinary(dir: string) { - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const binDir = path.join(dir, "bin"); - const claudePath = path.join(binDir, "claude"); - yield* fs.makeDirectory(binDir, { recursive: true }); - - yield* fs.writeFileString( - claudePath, - [ - "#!/bin/sh", - 'args="$*"', - 'stdin_content="$(cat)"', - 'if [ -n "$T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN" ]; then', - ' printf "%s" "$args" | grep -F -- "$T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN" >/dev/null || {', - ' printf "%s\\n" "args missing expected content" >&2', - " exit 2", - " }", - "fi", - 'if [ -n "$T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN" ]; then', - ' if printf "%s" "$args" | grep -F -- "$T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN" >/dev/null; then', - ' printf "%s\\n" "args contained forbidden content" >&2', - " exit 3", - " fi", - "fi", - 'if [ -n "$T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN" ]; then', - ' printf "%s" "$stdin_content" | grep -F -- "$T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN" >/dev/null || {', - ' printf "%s\\n" "stdin missing expected content" >&2', - " exit 4", - " }", - "fi", - 'if [ -n "$T3_FAKE_CLAUDE_HOME_MUST_BE" ] && [ "$HOME" != "$T3_FAKE_CLAUDE_HOME_MUST_BE" ]; then', - ' printf "%s\\n" "HOME was $HOME" >&2', - " exit 5", - "fi", - 'if [ -n "$T3_FAKE_CLAUDE_STDERR" ]; then', - ' printf "%s\\n" "$T3_FAKE_CLAUDE_STDERR" >&2', - "fi", - 'printf "%s" "$T3_FAKE_CLAUDE_OUTPUT"', - 'exit "${T3_FAKE_CLAUDE_EXIT_CODE:-0}"', - "", - ].join("\n"), - ); - yield* fs.chmod(claudePath, 0o755); - return binDir; - }); -} - -function withFakeClaudeEnv( - input: { - output: string; - exitCode?: number; - stderr?: string; - argsMustContain?: string; - argsMustNotContain?: string; - stdinMustContain?: string; - homeMustBe?: string; - claudeConfig?: Partial; - }, - effectFn: (textGeneration: TextGenerationShape) => Effect.Effect, -) { - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-claude-text-" }); - const binDir = yield* makeFakeClaudeBinary(tempDir); - const previousPath = process.env.PATH; - const previousOutput = process.env.T3_FAKE_CLAUDE_OUTPUT; - const previousExitCode = process.env.T3_FAKE_CLAUDE_EXIT_CODE; - const previousStderr = process.env.T3_FAKE_CLAUDE_STDERR; - const previousArgsMustContain = process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN; - const previousArgsMustNotContain = process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN; - const previousStdinMustContain = process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN; - const previousHomeMustBe = process.env.T3_FAKE_CLAUDE_HOME_MUST_BE; - - yield* Effect.acquireRelease( - Effect.sync(() => { - process.env.PATH = `${binDir}:${previousPath ?? ""}`; - process.env.T3_FAKE_CLAUDE_OUTPUT = input.output; - - if (input.exitCode !== undefined) { - process.env.T3_FAKE_CLAUDE_EXIT_CODE = String(input.exitCode); - } else { - delete process.env.T3_FAKE_CLAUDE_EXIT_CODE; - } - - if (input.stderr !== undefined) { - process.env.T3_FAKE_CLAUDE_STDERR = input.stderr; - } else { - delete process.env.T3_FAKE_CLAUDE_STDERR; - } - - if (input.argsMustContain !== undefined) { - process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN = input.argsMustContain; - } else { - delete process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN; - } - - if (input.argsMustNotContain !== undefined) { - process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN = input.argsMustNotContain; - } else { - delete process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN; - } - - if (input.stdinMustContain !== undefined) { - process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN = input.stdinMustContain; - } else { - delete process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN; - } - - if (input.homeMustBe !== undefined) { - process.env.T3_FAKE_CLAUDE_HOME_MUST_BE = input.homeMustBe; - } else { - delete process.env.T3_FAKE_CLAUDE_HOME_MUST_BE; - } - }), - () => - Effect.sync(() => { - process.env.PATH = previousPath; - - if (previousOutput === undefined) { - delete process.env.T3_FAKE_CLAUDE_OUTPUT; - } else { - process.env.T3_FAKE_CLAUDE_OUTPUT = previousOutput; - } - - if (previousExitCode === undefined) { - delete process.env.T3_FAKE_CLAUDE_EXIT_CODE; - } else { - process.env.T3_FAKE_CLAUDE_EXIT_CODE = previousExitCode; - } - - if (previousStderr === undefined) { - delete process.env.T3_FAKE_CLAUDE_STDERR; - } else { - process.env.T3_FAKE_CLAUDE_STDERR = previousStderr; - } - - if (previousArgsMustContain === undefined) { - delete process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN; - } else { - process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN = previousArgsMustContain; - } - - if (previousArgsMustNotContain === undefined) { - delete process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN; - } else { - process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN = previousArgsMustNotContain; - } - - if (previousStdinMustContain === undefined) { - delete process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN; - } else { - process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN = previousStdinMustContain; - } - - if (previousHomeMustBe === undefined) { - delete process.env.T3_FAKE_CLAUDE_HOME_MUST_BE; - } else { - process.env.T3_FAKE_CLAUDE_HOME_MUST_BE = previousHomeMustBe; - } - }), - ); - - const config = Schema.decodeSync(ClaudeSettings)(input.claudeConfig ?? {}); - const textGeneration = yield* makeClaudeTextGeneration(config); - return yield* effectFn(textGeneration); - }).pipe(Effect.scoped); -} - -it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGenerationLive", (it) => { - it.effect("forwards Claude thinking settings for Haiku without passing effort", () => - withFakeClaudeEnv( - { - output: JSON.stringify({ - structured_output: { - subject: "Add important change", - body: "", - }, - }), - argsMustContain: '--settings {"alwaysThinkingEnabled":false}', - argsMustNotContain: "--effort", - }, - (textGeneration) => - Effect.gen(function* () { - const generated = yield* textGeneration.generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/claude-effect", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md", - modelSelection: { - ...createModelSelection(ProviderInstanceId.make("claudeAgent"), "claude-haiku-4-5", [ - { id: "thinking", value: false }, - { id: "effort", value: "high" }, - ]), - }, - }); - - expect(generated.subject).toBe("Add important change"); - }), - ), - ); - - it.effect("forwards Claude fast mode and supported effort", () => - withFakeClaudeEnv( - { - output: JSON.stringify({ - structured_output: { - title: "Improve orchestration flow", - body: "Body", - }, - }), - argsMustContain: '--effort max --settings {"fastMode":true}', - }, - (textGeneration) => - Effect.gen(function* () { - const generated = yield* textGeneration.generatePrContent({ - cwd: process.cwd(), - baseBranch: "main", - headBranch: "feature/claude-effect", - commitSummary: "Improve orchestration", - diffSummary: "1 file changed", - diffPatch: "diff --git a/README.md b/README.md", - modelSelection: { - ...createModelSelection(ProviderInstanceId.make("claudeAgent"), "claude-opus-4-6", [ - { id: "effort", value: "max" }, - { id: "fastMode", value: true }, - ]), - }, - }); - - expect(generated.title).toBe("Improve orchestration flow"); - }), - ), - ); - - it.effect("generates thread titles through the Claude provider", () => - withFakeClaudeEnv( - { - output: JSON.stringify({ - structured_output: { - title: - ' "Reconnect failures after restart because the session state does not recover" ', - }, - }), - stdinMustContain: "You write concise thread titles for coding conversations.", - }, - (textGeneration) => - Effect.gen(function* () { - const generated = yield* textGeneration.generateThreadTitle({ - cwd: process.cwd(), - message: "Please investigate reconnect failures after restarting the session.", - modelSelection: { - instanceId: ProviderInstanceId.make("claudeAgent"), - model: "claude-sonnet-4-6", - }, - }); - - expect(generated.title).toBe( - sanitizeThreadTitle( - '"Reconnect failures after restart because the session state does not recover"', - ), - ); - }), - ), - ); - - it.effect("runs Claude text generation with the configured Claude HOME", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const claudeHome = path.join(process.cwd(), ".claude-work-test"); - return yield* withFakeClaudeEnv( - { - output: JSON.stringify({ - structured_output: { - title: "Use Claude home", - }, - }), - homeMustBe: claudeHome, - claudeConfig: { homePath: claudeHome }, - }, - (textGeneration) => - Effect.gen(function* () { - const generated = yield* textGeneration.generateThreadTitle({ - cwd: process.cwd(), - message: "thread title", - modelSelection: { - instanceId: ProviderInstanceId.make("claudeAgent"), - model: "claude-sonnet-4-6", - }, - }); - - expect(generated.title).toBe(sanitizeThreadTitle("Use Claude home")); - }), - ); - }), - ); - - it.effect("falls back when Claude thread title normalization becomes whitespace-only", () => - withFakeClaudeEnv( - { - output: JSON.stringify({ - structured_output: { - title: ' """ """ ', - }, - }), - }, - (textGeneration) => - Effect.gen(function* () { - const generated = yield* textGeneration.generateThreadTitle({ - cwd: process.cwd(), - message: "Name this thread.", - modelSelection: { - instanceId: ProviderInstanceId.make("claudeAgent"), - model: "claude-sonnet-4-6", - }, - }); - - expect(generated.title).toBe("New thread"); - }), - ), - ); -}); diff --git a/apps/server/src/git/Layers/ClaudeTextGeneration.ts b/apps/server/src/git/Layers/ClaudeTextGeneration.ts deleted file mode 100644 index 33bf7bc0141e57270073d984a38ecf21a41c7315..0000000000000000000000000000000000000000 --- a/apps/server/src/git/Layers/ClaudeTextGeneration.ts +++ /dev/null @@ -1,324 +0,0 @@ -/** - * ClaudeTextGeneration – Text generation layer using the Claude CLI. - * - * Implements the same TextGenerationShape contract as CodexTextGeneration but - * delegates to the `claude` CLI (`claude -p`) with structured JSON output - * instead of the `codex exec` CLI. - * - * @module ClaudeTextGeneration - */ -import { Effect, Option, Schema, Stream } from "effect"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; - -import { type ClaudeSettings, type ModelSelection } from "@t3tools/contracts"; -import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; - -import { TextGenerationError } from "@t3tools/contracts"; -import { type TextGenerationShape } from "../Services/TextGeneration.ts"; -import { - buildBranchNamePrompt, - buildCommitMessagePrompt, - buildPrContentPrompt, - buildThreadTitlePrompt, -} from "../Prompts.ts"; -import { - normalizeCliError, - sanitizeCommitSubject, - sanitizePrTitle, - sanitizeThreadTitle, - toJsonSchemaObject, -} from "../Utils.ts"; -import { - getModelSelectionStringOptionValue, - getProviderOptionDescriptors, -} from "@t3tools/shared/model"; -import { - getClaudeModelCapabilities, - normalizeClaudeCliEffort, - resolveClaudeApiModelId, - resolveClaudeEffort, -} from "../../provider/Layers/ClaudeProvider.ts"; -import { makeClaudeEnvironment } from "../../provider/Drivers/ClaudeHome.ts"; - -const CLAUDE_TIMEOUT_MS = 180_000; - -/** - * Schema for the wrapper JSON returned by `claude -p --output-format json`. - * We only care about `structured_output`. - */ -const ClaudeOutputEnvelope = Schema.Struct({ - structured_output: Schema.Unknown, -}); - -export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(function* ( - claudeSettings: ClaudeSettings, - environment: NodeJS.ProcessEnv = process.env, -) { - const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment); - - const readStreamAsString = ( - operation: string, - stream: Stream.Stream, - ): Effect.Effect => - stream.pipe( - Stream.decodeText(), - Stream.runFold( - () => "", - (acc, chunk) => acc + chunk, - ), - Effect.mapError((cause) => - normalizeCliError("claude", operation, cause, "Failed to collect process output"), - ), - ); - - /** - * Spawn the Claude CLI with structured JSON output and return the parsed, - * schema-validated result. - */ - const runClaudeJson = Effect.fn("runClaudeJson")(function* ({ - operation, - cwd, - prompt, - outputSchemaJson, - modelSelection, - }: { - operation: - | "generateCommitMessage" - | "generatePrContent" - | "generateBranchName" - | "generateThreadTitle"; - cwd: string; - prompt: string; - outputSchemaJson: S; - modelSelection: ModelSelection; - }): Effect.fn.Return { - const jsonSchemaStr = JSON.stringify(toJsonSchemaObject(outputSchemaJson)); - const caps = getClaudeModelCapabilities(modelSelection.model); - const descriptors = getProviderOptionDescriptors({ - caps, - selections: modelSelection.options, - }); - const findDescriptor = (id: string) => descriptors.find((descriptor) => descriptor.id === id); - const rawEffortSelection = getModelSelectionStringOptionValue(modelSelection, "effort"); - const resolvedEffort = resolveClaudeEffort(caps, rawEffortSelection); - const cliEffort = normalizeClaudeCliEffort(resolvedEffort); - const thinkingDescriptor = findDescriptor("thinking"); - const fastModeDescriptor = findDescriptor("fastMode"); - const thinking = - thinkingDescriptor?.type === "boolean" ? thinkingDescriptor.currentValue : undefined; - const fastMode = - fastModeDescriptor?.type === "boolean" ? fastModeDescriptor.currentValue : undefined; - const settings = { - ...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}), - ...(fastMode ? { fastMode: true } : {}), - }; - - const runClaudeCommand = Effect.fn("runClaudeJson.runClaudeCommand")(function* () { - const command = ChildProcess.make( - claudeSettings.binaryPath || "claude", - [ - "-p", - "--output-format", - "json", - "--json-schema", - jsonSchemaStr, - "--model", - resolveClaudeApiModelId(modelSelection), - ...(cliEffort ? ["--effort", cliEffort] : []), - ...(Object.keys(settings).length > 0 ? ["--settings", JSON.stringify(settings)] : []), - "--dangerously-skip-permissions", - ], - { - env: claudeEnvironment, - cwd, - shell: process.platform === "win32", - stdin: { - stream: Stream.encodeText(Stream.make(prompt)), - }, - }, - ); - - const child = yield* commandSpawner - .spawn(command) - .pipe( - Effect.mapError((cause) => - normalizeCliError("claude", operation, cause, "Failed to spawn Claude CLI process"), - ), - ); - - const [stdout, stderr, exitCode] = yield* Effect.all( - [ - readStreamAsString(operation, child.stdout), - readStreamAsString(operation, child.stderr), - child.exitCode.pipe( - Effect.mapError((cause) => - normalizeCliError("claude", operation, cause, "Failed to read Claude CLI exit code"), - ), - ), - ], - { concurrency: "unbounded" }, - ); - - if (exitCode !== 0) { - const stderrDetail = stderr.trim(); - const stdoutDetail = stdout.trim(); - const detail = stderrDetail.length > 0 ? stderrDetail : stdoutDetail; - return yield* new TextGenerationError({ - operation, - detail: - detail.length > 0 - ? `Claude CLI command failed: ${detail}` - : `Claude CLI command failed with code ${exitCode}.`, - }); - } - - return stdout; - }); - - const rawStdout = yield* runClaudeCommand().pipe( - Effect.scoped, - Effect.timeoutOption(CLAUDE_TIMEOUT_MS), - Effect.flatMap( - Option.match({ - onNone: () => - Effect.fail( - new TextGenerationError({ operation, detail: "Claude CLI request timed out." }), - ), - onSome: (value) => Effect.succeed(value), - }), - ), - ); - - const envelope = yield* Schema.decodeEffect(Schema.fromJsonString(ClaudeOutputEnvelope))( - rawStdout, - ).pipe( - Effect.catchTag("SchemaError", (cause) => - Effect.fail( - new TextGenerationError({ - operation, - detail: "Claude CLI returned unexpected output format.", - cause, - }), - ), - ), - ); - - return yield* Schema.decodeEffect(outputSchemaJson)(envelope.structured_output).pipe( - Effect.catchTag("SchemaError", (cause) => - Effect.fail( - new TextGenerationError({ - operation, - detail: "Claude returned invalid structured output.", - cause, - }), - ), - ), - ); - }); - - // --------------------------------------------------------------------------- - // TextGenerationShape methods - // --------------------------------------------------------------------------- - - const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn( - "ClaudeTextGeneration.generateCommitMessage", - )(function* (input) { - const { prompt, outputSchema } = buildCommitMessagePrompt({ - branch: input.branch, - stagedSummary: input.stagedSummary, - stagedPatch: input.stagedPatch, - includeBranch: input.includeBranch === true, - }); - - const generated = yield* runClaudeJson({ - operation: "generateCommitMessage", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - modelSelection: input.modelSelection, - }); - - return { - subject: sanitizeCommitSubject(generated.subject), - body: generated.body.trim(), - ...("branch" in generated && typeof generated.branch === "string" - ? { branch: sanitizeFeatureBranchName(generated.branch) } - : {}), - }; - }); - - const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn( - "ClaudeTextGeneration.generatePrContent", - )(function* (input) { - const { prompt, outputSchema } = buildPrContentPrompt({ - baseBranch: input.baseBranch, - headBranch: input.headBranch, - commitSummary: input.commitSummary, - diffSummary: input.diffSummary, - diffPatch: input.diffPatch, - }); - - const generated = yield* runClaudeJson({ - operation: "generatePrContent", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - modelSelection: input.modelSelection, - }); - - return { - title: sanitizePrTitle(generated.title), - body: generated.body.trim(), - }; - }); - - const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn( - "ClaudeTextGeneration.generateBranchName", - )(function* (input) { - const { prompt, outputSchema } = buildBranchNamePrompt({ - message: input.message, - attachments: input.attachments, - }); - - const generated = yield* runClaudeJson({ - operation: "generateBranchName", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - modelSelection: input.modelSelection, - }); - - return { - branch: sanitizeBranchFragment(generated.branch), - }; - }); - - const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn( - "ClaudeTextGeneration.generateThreadTitle", - )(function* (input) { - const { prompt, outputSchema } = buildThreadTitlePrompt({ - message: input.message, - attachments: input.attachments, - }); - - const generated = yield* runClaudeJson({ - operation: "generateThreadTitle", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - modelSelection: input.modelSelection, - }); - - return { - title: sanitizeThreadTitle(generated.title), - }; - }); - - return { - generateCommitMessage, - generatePrContent, - generateBranchName, - generateThreadTitle, - } satisfies TextGenerationShape; -}); diff --git a/apps/server/src/git/Layers/CodexTextGeneration.test.ts b/apps/server/src/git/Layers/CodexTextGeneration.test.ts deleted file mode 100644 index f38d6a68a8730c55027071af938ed21ba682bba5..0000000000000000000000000000000000000000 --- a/apps/server/src/git/Layers/CodexTextGeneration.test.ts +++ /dev/null @@ -1,592 +0,0 @@ -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { it } from "@effect/vitest"; -import { Effect, FileSystem, Layer, Path, Result, Schema } from "effect"; -import { createModelSelection } from "@t3tools/shared/model"; -import { expect } from "vitest"; - -import { CodexSettings, ProviderInstanceId, TextGenerationError } from "@t3tools/contracts"; - -import { ServerConfig } from "../../config.ts"; -import { type TextGenerationShape } from "../Services/TextGeneration.ts"; -import { makeCodexTextGeneration } from "./CodexTextGeneration.ts"; - -const DEFAULT_TEST_MODEL_SELECTION = createModelSelection( - ProviderInstanceId.make("codex"), - "gpt-5.4-mini", -); - -const CodexTextGenerationTestLayer = ServerConfig.layerTest(process.cwd(), { - prefix: "t3code-codex-text-generation-test-", -}).pipe(Layer.provideMerge(NodeServices.layer)); - -function makeFakeCodexBinary( - dir: string, - input: { - output: string; - exitCode?: number; - stderr?: string; - requireImage?: boolean; - requireFastServiceTier?: boolean; - requireReasoningEffort?: string; - forbidReasoningEffort?: boolean; - stdinMustContain?: string; - stdinMustNotContain?: string; - }, -) { - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const binDir = path.join(dir, "bin"); - const codexPath = path.join(binDir, "codex"); - yield* fs.makeDirectory(binDir, { recursive: true }); - - yield* fs.writeFileString( - codexPath, - [ - "#!/bin/sh", - 'output_path=""', - 'seen_image="0"', - 'seen_fast_service_tier="0"', - 'seen_reasoning_effort=""', - "while [ $# -gt 0 ]; do", - ' if [ "$1" = "--image" ]; then', - " shift", - ' if [ -n "$1" ]; then', - ' seen_image="1"', - " fi", - " shift", - " continue", - " fi", - ' if [ "$1" = "--config" ]; then', - " shift", - ' if [ "$1" = "service_tier=\\"fast\\"" ]; then', - ' seen_fast_service_tier="1"', - " fi", - ' case "$1" in', - " model_reasoning_effort=*)", - ' seen_reasoning_effort="$1"', - " ;;", - " esac", - " shift", - " continue", - " fi", - ' if [ "$1" = "--output-last-message" ]; then', - " shift", - ' output_path="$1"', - " shift", - " continue", - " fi", - " shift", - "done", - 'stdin_content="$(cat)"', - ...(input.requireImage - ? [ - 'if [ "$seen_image" != "1" ]; then', - ' printf "%s\\n" "missing --image input" >&2', - ` exit 2`, - "fi", - ] - : []), - ...(input.requireFastServiceTier - ? [ - 'if [ "$seen_fast_service_tier" != "1" ]; then', - ' printf "%s\\n" "missing fast service tier config" >&2', - ` exit 5`, - "fi", - ] - : []), - ...(input.requireReasoningEffort !== undefined - ? [ - `if [ "$seen_reasoning_effort" != "model_reasoning_effort=\\"${input.requireReasoningEffort}\\"" ]; then`, - ' printf "%s\\n" "unexpected reasoning effort config: $seen_reasoning_effort" >&2', - ` exit 6`, - "fi", - ] - : []), - ...(input.forbidReasoningEffort - ? [ - 'if [ -n "$seen_reasoning_effort" ]; then', - ' printf "%s\\n" "reasoning effort config should be omitted: $seen_reasoning_effort" >&2', - ` exit 7`, - "fi", - ] - : []), - ...(input.stdinMustContain !== undefined - ? [ - `if ! printf "%s" "$stdin_content" | grep -F -- ${JSON.stringify(input.stdinMustContain)} >/dev/null; then`, - ' printf "%s\\n" "stdin missing expected content" >&2', - ` exit 3`, - "fi", - ] - : []), - ...(input.stdinMustNotContain !== undefined - ? [ - `if printf "%s" "$stdin_content" | grep -F -- ${JSON.stringify(input.stdinMustNotContain)} >/dev/null; then`, - ' printf "%s\\n" "stdin contained forbidden content" >&2', - ` exit 4`, - "fi", - ] - : []), - ...(input.stderr !== undefined - ? [`printf "%s\\n" ${JSON.stringify(input.stderr)} >&2`] - : []), - 'if [ -n "$output_path" ]; then', - " cat > \"$output_path\" <<'__T3CODE_FAKE_CODEX_OUTPUT__'", - input.output, - "__T3CODE_FAKE_CODEX_OUTPUT__", - "fi", - `exit ${input.exitCode ?? 0}`, - "", - ].join("\n"), - ); - yield* fs.chmod(codexPath, 0o755); - return codexPath; - }); -} - -function withFakeCodexEnv( - input: { - output: string; - exitCode?: number; - stderr?: string; - requireImage?: boolean; - requireFastServiceTier?: boolean; - requireReasoningEffort?: string; - forbidReasoningEffort?: boolean; - stdinMustContain?: string; - stdinMustNotContain?: string; - }, - effectFn: (textGeneration: TextGenerationShape) => Effect.Effect, -) { - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-codex-text-" }); - const codexPath = yield* makeFakeCodexBinary(tempDir, input); - const config = Schema.decodeSync(CodexSettings)({ binaryPath: codexPath }); - const textGeneration = yield* makeCodexTextGeneration(config); - return yield* effectFn(textGeneration); - }).pipe(Effect.scoped); -} - -it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { - it.effect("generates and sanitizes commit messages without branch by default", () => - withFakeCodexEnv( - { - output: JSON.stringify({ - subject: - " Add important change to the system with too much detail and a trailing period.\nsecondary line", - body: "\n- added migration\n- updated tests\n", - }), - stdinMustNotContain: "branch must be a short semantic git branch fragment", - }, - (textGeneration) => - Effect.gen(function* () { - const generated = yield* textGeneration.generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/codex-effect", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }); - - expect(generated.subject.length).toBeLessThanOrEqual(72); - expect(generated.subject.endsWith(".")).toBe(false); - expect(generated.body).toBe("- added migration\n- updated tests"); - expect(generated.branch).toBeUndefined(); - }), - ), - ); - - it.effect( - "forwards codex fast mode and non-default reasoning effort into codex exec config", - () => - withFakeCodexEnv( - { - output: JSON.stringify({ - subject: "Add important change", - body: "", - }), - requireFastServiceTier: true, - requireReasoningEffort: "xhigh", - stdinMustNotContain: "branch must be a short semantic git branch fragment", - }, - (textGeneration) => - textGeneration.generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/codex-effect", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md", - modelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [ - { id: "reasoningEffort", value: "xhigh" }, - { id: "fastMode", value: true }, - ]), - }), - ), - ); - - it.effect("defaults git text generation codex effort to low", () => - withFakeCodexEnv( - { - output: JSON.stringify({ - subject: "Add important change", - body: "", - }), - requireReasoningEffort: "low", - }, - (textGeneration) => - textGeneration.generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/codex-effect", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }), - ), - ); - - it.effect("generates commit message with branch when includeBranch is true", () => - withFakeCodexEnv( - { - output: JSON.stringify({ - subject: "Add important change", - body: "", - branch: "fix/important-system-change", - }), - stdinMustContain: "branch must be a short semantic git branch fragment", - }, - (textGeneration) => - Effect.gen(function* () { - const generated = yield* textGeneration.generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/codex-effect", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md", - includeBranch: true, - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }); - - expect(generated.subject).toBe("Add important change"); - expect(generated.branch).toBe("feature/fix/important-system-change"); - }), - ), - ); - - it.effect("generates PR content and trims markdown body", () => - withFakeCodexEnv( - { - output: JSON.stringify({ - title: " Improve orchestration flow\nwith ignored suffix", - body: "\n## Summary\n- improve flow\n\n## Testing\n- bun test\n\n", - }), - }, - (textGeneration) => - Effect.gen(function* () { - const generated = yield* textGeneration.generatePrContent({ - cwd: process.cwd(), - baseBranch: "main", - headBranch: "feature/codex-effect", - commitSummary: "feat: improve orchestration flow", - diffSummary: "2 files changed", - diffPatch: "diff --git a/a.ts b/a.ts", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }); - - expect(generated.title).toBe("Improve orchestration flow"); - expect(generated.body.startsWith("## Summary")).toBe(true); - expect(generated.body.endsWith("\n\n")).toBe(false); - }), - ), - ); - - it.effect("generates branch names and normalizes branch fragments", () => - withFakeCodexEnv( - { - output: JSON.stringify({ - branch: " Feat/Session ", - }), - stdinMustNotContain: "Image attachments supplied to the model", - }, - (textGeneration) => - Effect.gen(function* () { - const generated = yield* textGeneration.generateBranchName({ - cwd: process.cwd(), - message: "Please update session handling.", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }); - - expect(generated.branch).toBe("feat/session"); - }), - ), - ); - - it.effect("generates thread titles and trims them for sidebar use", () => - withFakeCodexEnv( - { - output: JSON.stringify({ - title: - ' "Investigate websocket reconnect regressions after worktree restore" \nignored line', - }), - }, - (textGeneration) => - Effect.gen(function* () { - const generated = yield* textGeneration.generateThreadTitle({ - cwd: process.cwd(), - message: "Please investigate websocket reconnect regressions after a worktree restore.", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }); - - expect(generated.title).toBe("Investigate websocket reconnect regressions aft..."); - }), - ), - ); - - it.effect("falls back when thread title normalization becomes whitespace-only", () => - withFakeCodexEnv( - { - output: JSON.stringify({ - title: ' """ """ ', - }), - }, - (textGeneration) => - Effect.gen(function* () { - const generated = yield* textGeneration.generateThreadTitle({ - cwd: process.cwd(), - message: "Name this thread.", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }); - - expect(generated.title).toBe("New thread"); - }), - ), - ); - - it.effect("trims whitespace exposed after quote removal in thread titles", () => - withFakeCodexEnv( - { - output: JSON.stringify({ - title: ` "' hello world '" `, - }), - }, - (textGeneration) => - Effect.gen(function* () { - const generated = yield* textGeneration.generateThreadTitle({ - cwd: process.cwd(), - message: "Name this thread.", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }); - - expect(generated.title).toBe("hello world"); - }), - ), - ); - - it.effect("omits attachment metadata section when no attachments are provided", () => - withFakeCodexEnv( - { - output: JSON.stringify({ - branch: "fix/session-timeout", - }), - stdinMustNotContain: "Attachment metadata:", - }, - (textGeneration) => - Effect.gen(function* () { - const generated = yield* textGeneration.generateBranchName({ - cwd: process.cwd(), - message: "Fix timeout behavior.", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }); - - expect(generated.branch).toBe("fix/session-timeout"); - }), - ), - ); - - it.effect("passes image attachments through as codex image inputs", () => - withFakeCodexEnv( - { - output: JSON.stringify({ - branch: "fix/ui-regression", - }), - requireImage: true, - stdinMustContain: "Attachment metadata:", - }, - (textGeneration) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const { attachmentsDir } = yield* ServerConfig; - const attachmentId = `thread-branch-image-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; - const attachmentPath = path.join(attachmentsDir, `${attachmentId}.png`); - yield* fs.makeDirectory(attachmentsDir, { recursive: true }); - yield* fs.writeFile(attachmentPath, Buffer.from("hello")); - - const generated = yield* textGeneration.generateBranchName({ - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - cwd: process.cwd(), - message: "Fix layout bug from screenshot.", - attachments: [ - { - type: "image", - id: attachmentId, - name: "bug.png", - mimeType: "image/png", - sizeBytes: 5, - }, - ], - }); - - expect(generated.branch).toBe("fix/ui-regression"); - }), - ), - ); - - it.effect("resolves persisted attachment ids to files for codex image inputs", () => - withFakeCodexEnv( - { - output: JSON.stringify({ - branch: "fix/ui-regression", - }), - requireImage: true, - }, - (textGeneration) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const { attachmentsDir } = yield* ServerConfig; - const attachmentId = `thread-1-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; - const imagePath = path.join(attachmentsDir, `${attachmentId}.png`); - yield* fs.makeDirectory(attachmentsDir, { recursive: true }); - yield* fs.writeFile(imagePath, Buffer.from("hello")); - - const generated = yield* textGeneration - .generateBranchName({ - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - cwd: process.cwd(), - message: "Fix layout bug from screenshot.", - attachments: [ - { - type: "image", - id: attachmentId, - name: "bug.png", - mimeType: "image/png", - sizeBytes: 5, - }, - ], - }) - .pipe( - Effect.tap(() => - fs.stat(imagePath).pipe( - Effect.map((fileInfo) => { - expect(fileInfo.type).toBe("File"); - }), - ), - ), - Effect.ensuring(fs.remove(imagePath).pipe(Effect.catch(() => Effect.void))), - ); - - expect(generated.branch).toBe("fix/ui-regression"); - }), - ), - ); - - it.effect("ignores missing attachment ids for codex image inputs", () => - withFakeCodexEnv( - { - output: JSON.stringify({ - branch: "fix/ui-regression", - }), - requireImage: true, - }, - (textGeneration) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const { attachmentsDir } = yield* ServerConfig; - const missingAttachmentId = `thread-missing-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; - const missingPath = path.join(attachmentsDir, `${missingAttachmentId}.png`); - yield* fs.remove(missingPath).pipe(Effect.catch(() => Effect.void)); - - const result = yield* textGeneration - .generateBranchName({ - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - cwd: process.cwd(), - message: "Fix layout bug from screenshot.", - attachments: [ - { - type: "image", - id: missingAttachmentId, - name: "outside.png", - mimeType: "image/png", - sizeBytes: 5, - }, - ], - }) - .pipe(Effect.result); - - expect(Result.isFailure(result)).toBe(true); - if (Result.isFailure(result)) { - expect(result.failure).toBeInstanceOf(TextGenerationError); - expect(result.failure.message).toContain("missing --image input"); - } - }), - ), - ); - - it.effect( - "fails with typed TextGenerationError when codex returns wrong branch payload shape", - () => - withFakeCodexEnv( - { - output: JSON.stringify({ - title: "This is not a branch payload", - }), - }, - (textGeneration) => - Effect.gen(function* () { - const result = yield* textGeneration - .generateBranchName({ - cwd: process.cwd(), - message: "Fix websocket reconnect flake", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }) - .pipe(Effect.result); - - expect(Result.isFailure(result)).toBe(true); - if (Result.isFailure(result)) { - expect(result.failure).toBeInstanceOf(TextGenerationError); - expect(result.failure.message).toContain("Codex returned invalid structured output"); - } - }), - ), - ); - - it.effect("returns typed TextGenerationError when codex exits non-zero", () => - withFakeCodexEnv( - { - output: JSON.stringify({ subject: "ignored", body: "" }), - exitCode: 1, - stderr: "codex execution failed", - }, - (textGeneration) => - Effect.gen(function* () { - const result = yield* textGeneration - .generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/codex-error", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }) - .pipe(Effect.result); - - expect(Result.isFailure(result)).toBe(true); - if (Result.isFailure(result)) { - expect(result.failure).toBeInstanceOf(TextGenerationError); - expect(result.failure.message).toContain( - "Codex CLI command failed: codex execution failed", - ); - } - }), - ), - ); -}); diff --git a/apps/server/src/git/Layers/CodexTextGeneration.ts b/apps/server/src/git/Layers/CodexTextGeneration.ts deleted file mode 100644 index d4bc8f1632791e0b24123d34393151c663be110d..0000000000000000000000000000000000000000 --- a/apps/server/src/git/Layers/CodexTextGeneration.ts +++ /dev/null @@ -1,392 +0,0 @@ -import { Effect, FileSystem, Option, Path, Random, Schema, Scope, Stream } from "effect"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; - -import { type CodexSettings, type ModelSelection } from "@t3tools/contracts"; -import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; - -import { resolveAttachmentPath } from "../../attachmentStore.ts"; -import { ServerConfig } from "../../config.ts"; -import { expandHomePath } from "../../pathExpansion.ts"; -import { TextGenerationError } from "@t3tools/contracts"; -import { - type BranchNameGenerationInput, - type ThreadTitleGenerationResult, - type TextGenerationShape, -} from "../Services/TextGeneration.ts"; -import { - buildBranchNamePrompt, - buildCommitMessagePrompt, - buildPrContentPrompt, - buildThreadTitlePrompt, -} from "../Prompts.ts"; -import { - normalizeCliError, - sanitizeCommitSubject, - sanitizePrTitle, - sanitizeThreadTitle, - toJsonSchemaObject, -} from "../Utils.ts"; -import { - getModelSelectionBooleanOptionValue, - getModelSelectionStringOptionValue, -} from "@t3tools/shared/model"; - -const CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT = "low"; -const CODEX_TIMEOUT_MS = 180_000; -/** - * Build a Codex text-generation closure bound to a specific `CodexSettings` - * payload. See `makeCodexAdapter` for the overall per-instance rationale. - */ -export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(function* ( - codexConfig: CodexSettings, - environment: NodeJS.ProcessEnv = process.env, -) { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const serverConfig = yield* Effect.service(ServerConfig); - - type MaterializedImageAttachments = { - readonly imagePaths: ReadonlyArray; - }; - - const readStreamAsString = ( - operation: string, - stream: Stream.Stream, - ): Effect.Effect => - stream.pipe( - Stream.decodeText(), - Stream.runFold( - () => "", - (acc, chunk) => acc + chunk, - ), - Effect.mapError((cause) => - normalizeCliError("codex", operation, cause, "Failed to collect process output"), - ), - ); - - const writeTempFile = ( - operation: string, - prefix: string, - content: string, - ): Effect.Effect => { - return Effect.gen(function* () { - const tempFileId = yield* Random.nextUUIDv4; - return yield* fileSystem - .makeTempFileScoped({ - prefix: `t3code-${prefix}-${process.pid}-${tempFileId}.tmp`, - }) - .pipe(Effect.tap((filePath) => fileSystem.writeFileString(filePath, content))); - }).pipe( - Effect.mapError( - (cause) => - new TextGenerationError({ - operation, - detail: `Failed to write temp file`, - cause, - }), - ), - ); - }; - - const safeUnlink = (filePath: string): Effect.Effect => - fileSystem.remove(filePath).pipe(Effect.catch(() => Effect.void)); - - const materializeImageAttachments = Effect.fn("materializeImageAttachments")(function* ( - _operation: - | "generateCommitMessage" - | "generatePrContent" - | "generateBranchName" - | "generateThreadTitle", - attachments: BranchNameGenerationInput["attachments"], - ): Effect.fn.Return { - if (!attachments || attachments.length === 0) { - return { imagePaths: [] }; - } - - const imagePaths: string[] = []; - for (const attachment of attachments) { - if (attachment.type !== "image") { - continue; - } - - const resolvedPath = resolveAttachmentPath({ - attachmentsDir: serverConfig.attachmentsDir, - attachment, - }); - if (!resolvedPath || !path.isAbsolute(resolvedPath)) { - continue; - } - const fileInfo = yield* fileSystem - .stat(resolvedPath) - .pipe(Effect.catch(() => Effect.succeed(null))); - if (!fileInfo || fileInfo.type !== "File") { - continue; - } - imagePaths.push(resolvedPath); - } - return { imagePaths }; - }); - - const runCodexJson = Effect.fn("runCodexJson")(function* ({ - operation, - cwd, - prompt, - outputSchemaJson, - imagePaths = [], - cleanupPaths = [], - modelSelection, - }: { - operation: - | "generateCommitMessage" - | "generatePrContent" - | "generateBranchName" - | "generateThreadTitle"; - cwd: string; - prompt: string; - outputSchemaJson: S; - imagePaths?: ReadonlyArray; - cleanupPaths?: ReadonlyArray; - modelSelection: ModelSelection; - }): Effect.fn.Return { - const schemaPath = yield* writeTempFile( - operation, - "codex-schema", - JSON.stringify(toJsonSchemaObject(outputSchemaJson)), - ); - const outputPath = yield* writeTempFile(operation, "codex-output", ""); - - const runCodexCommand = Effect.fn("runCodexJson.runCodexCommand")(function* () { - const reasoningEffort = - getModelSelectionStringOptionValue(modelSelection, "reasoningEffort") ?? - CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT; - const command = ChildProcess.make( - codexConfig.binaryPath || "codex", - [ - "exec", - "--ephemeral", - "--skip-git-repo-check", - "-s", - "read-only", - "--model", - modelSelection.model, - "--config", - `model_reasoning_effort="${reasoningEffort}"`, - ...(getModelSelectionBooleanOptionValue(modelSelection, "fastMode") === true - ? ["--config", `service_tier="fast"`] - : []), - "--output-schema", - schemaPath, - "--output-last-message", - outputPath, - ...imagePaths.flatMap((imagePath) => ["--image", imagePath]), - "-", - ], - { - env: { - ...environment, - ...(codexConfig.homePath ? { CODEX_HOME: expandHomePath(codexConfig.homePath) } : {}), - }, - cwd, - shell: process.platform === "win32", - stdin: { - stream: Stream.encodeText(Stream.make(prompt)), - }, - }, - ); - - const child = yield* commandSpawner - .spawn(command) - .pipe( - Effect.mapError((cause) => - normalizeCliError("codex", operation, cause, "Failed to spawn Codex CLI process"), - ), - ); - - const [stdout, stderr, exitCode] = yield* Effect.all( - [ - readStreamAsString(operation, child.stdout), - readStreamAsString(operation, child.stderr), - child.exitCode.pipe( - Effect.mapError((cause) => - normalizeCliError("codex", operation, cause, "Failed to read Codex CLI exit code"), - ), - ), - ], - { concurrency: "unbounded" }, - ); - - if (exitCode !== 0) { - const stderrDetail = stderr.trim(); - const stdoutDetail = stdout.trim(); - const detail = stderrDetail.length > 0 ? stderrDetail : stdoutDetail; - return yield* new TextGenerationError({ - operation, - detail: - detail.length > 0 - ? `Codex CLI command failed: ${detail}` - : `Codex CLI command failed with code ${exitCode}.`, - }); - } - }); - - const cleanup = Effect.all( - [schemaPath, outputPath, ...cleanupPaths].map((filePath) => safeUnlink(filePath)), - { - concurrency: "unbounded", - }, - ).pipe(Effect.asVoid); - - return yield* Effect.gen(function* () { - yield* runCodexCommand().pipe( - Effect.scoped, - Effect.timeoutOption(CODEX_TIMEOUT_MS), - Effect.flatMap( - Option.match({ - onNone: () => - Effect.fail( - new TextGenerationError({ operation, detail: "Codex CLI request timed out." }), - ), - onSome: () => Effect.void, - }), - ), - ); - - return yield* fileSystem.readFileString(outputPath).pipe( - Effect.mapError( - (cause) => - new TextGenerationError({ - operation, - detail: "Failed to read Codex output file.", - cause, - }), - ), - Effect.flatMap(Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson))), - Effect.catchTag("SchemaError", (cause) => - Effect.fail( - new TextGenerationError({ - operation, - detail: "Codex returned invalid structured output.", - cause, - }), - ), - ), - ); - }).pipe(Effect.ensuring(cleanup)); - }); - - const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn( - "CodexTextGeneration.generateCommitMessage", - )(function* (input) { - const { prompt, outputSchema } = buildCommitMessagePrompt({ - branch: input.branch, - stagedSummary: input.stagedSummary, - stagedPatch: input.stagedPatch, - includeBranch: input.includeBranch === true, - }); - - const generated = yield* runCodexJson({ - operation: "generateCommitMessage", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - modelSelection: input.modelSelection, - }); - - return { - subject: sanitizeCommitSubject(generated.subject), - body: generated.body.trim(), - ...("branch" in generated && typeof generated.branch === "string" - ? { branch: sanitizeFeatureBranchName(generated.branch) } - : {}), - }; - }); - - const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn( - "CodexTextGeneration.generatePrContent", - )(function* (input) { - const { prompt, outputSchema } = buildPrContentPrompt({ - baseBranch: input.baseBranch, - headBranch: input.headBranch, - commitSummary: input.commitSummary, - diffSummary: input.diffSummary, - diffPatch: input.diffPatch, - }); - - const generated = yield* runCodexJson({ - operation: "generatePrContent", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - modelSelection: input.modelSelection, - }); - - return { - title: sanitizePrTitle(generated.title), - body: generated.body.trim(), - }; - }); - - const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn( - "CodexTextGeneration.generateBranchName", - )(function* (input) { - const { imagePaths } = yield* materializeImageAttachments( - "generateBranchName", - input.attachments, - ); - const { prompt, outputSchema } = buildBranchNamePrompt({ - message: input.message, - attachments: input.attachments, - }); - - const generated = yield* runCodexJson({ - operation: "generateBranchName", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - imagePaths, - modelSelection: input.modelSelection, - }); - - return { - branch: sanitizeBranchFragment(generated.branch), - }; - }); - - const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn( - "CodexTextGeneration.generateThreadTitle", - )(function* (input) { - const { imagePaths } = yield* materializeImageAttachments( - "generateThreadTitle", - input.attachments, - ); - const { prompt, outputSchema } = buildThreadTitlePrompt({ - message: input.message, - attachments: input.attachments, - }); - - const generated = yield* runCodexJson({ - operation: "generateThreadTitle", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - imagePaths, - modelSelection: input.modelSelection, - }); - - return { - title: sanitizeThreadTitle(generated.title), - } satisfies ThreadTitleGenerationResult; - }); - - return { - generateCommitMessage, - generatePrContent, - generateBranchName, - generateThreadTitle, - } satisfies TextGenerationShape; -}); - -// NOTE: `CodexTextGenerationLive` (the singleton Layer) has been removed. -// `makeCodexTextGeneration(codexConfig)` is now invoked directly by -// `CodexDriver.create()` for each configured instance. diff --git a/apps/server/src/git/Layers/CursorTextGeneration.test.ts b/apps/server/src/git/Layers/CursorTextGeneration.test.ts deleted file mode 100644 index 3718557664ca50e0658d8bb011d1ef5ca67be54e..0000000000000000000000000000000000000000 --- a/apps/server/src/git/Layers/CursorTextGeneration.test.ts +++ /dev/null @@ -1,264 +0,0 @@ -import * as path from "node:path"; -import * as os from "node:os"; -import { fileURLToPath } from "node:url"; -import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; - -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { it } from "@effect/vitest"; -import { Effect, Layer, Schema } from "effect"; -import { createModelSelection } from "@t3tools/shared/model"; -import { expect } from "vitest"; - -import { CursorSettings, ProviderInstanceId } from "@t3tools/contracts"; - -import { ServerConfig } from "../../config.ts"; -import { type TextGenerationShape } from "../Services/TextGeneration.ts"; -import { makeCursorTextGeneration } from "./CursorTextGeneration.ts"; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const mockAgentPath = path.join(__dirname, "../../../scripts/acp-mock-agent.ts"); - -function shellSingleQuote(value: string): string { - return `'${value.replaceAll("'", `'"'"'`)}'`; -} - -const CursorTextGenerationTestLayer = ServerConfig.layerTest(process.cwd(), { - prefix: "t3code-cursor-text-generation-test-", -}).pipe(Layer.provideMerge(NodeServices.layer)); - -function makeAcpAgentWrapper(dir: string, env: Record): string { - const binDir = path.join(dir, "bin"); - const agentPath = path.join(binDir, "agent"); - mkdirSync(binDir, { recursive: true }); - writeFileSync( - agentPath, - [ - "#!/bin/sh", - ...Object.entries(env).map(([key, value]) => `export ${key}=${shellSingleQuote(value)}`), - 'if [ "$1" != "acp" ]; then', - ' printf "%s\\n" "unexpected args: $*" >&2', - " exit 11", - "fi", - `exec bun ${JSON.stringify(mockAgentPath)}`, - "", - ].join("\n"), - "utf8", - ); - chmodSync(agentPath, 0o755); - return agentPath; -} - -function withFakeAcpAgent( - env: Record, - effectFn: (textGeneration: TextGenerationShape) => Effect.Effect, -) { - return Effect.gen(function* () { - const tempDir = mkdtempSync(path.join(os.tmpdir(), "t3code-cursor-text-acp-")); - yield* Effect.addFinalizer(() => - Effect.sync(() => { - rmSync(tempDir, { recursive: true, force: true }); - }), - ); - const agentPath = makeAcpAgentWrapper(tempDir, env); - const config = Schema.decodeSync(CursorSettings)({ binaryPath: agentPath }); - const textGeneration = yield* makeCursorTextGeneration(config); - return yield* effectFn(textGeneration); - }).pipe(Effect.scoped); -} - -function waitForFileContent(path: string): Effect.Effect { - return Effect.promise(async () => { - const deadline = Date.now() + 5_000; - for (;;) { - try { - return readFileSync(path, "utf8"); - } catch (error) { - if (Date.now() >= deadline) { - throw error instanceof Error ? error : new Error(String(error)); - } - } - await new Promise((resolve) => setTimeout(resolve, 25)); - } - }); -} - -it.layer(CursorTextGenerationTestLayer)("CursorTextGenerationLive", (it) => { - it.effect("uses ACP model config options instead of raw CLI model ids", () => { - const requestLogDir = mkdtempSync(path.join(os.tmpdir(), "t3code-cursor-text-log-")); - const requestLogPath = path.join(requestLogDir, "requests.ndjson"); - - return withFakeAcpAgent( - { - T3_ACP_REQUEST_LOG_PATH: requestLogPath, - T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({ - subject: "Add generated commit message", - body: "- verify cursor acp model config path", - }), - }, - (textGeneration) => - Effect.gen(function* () { - const generated = yield* textGeneration.generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/cursor-text-generation", - stagedSummary: "M apps/server/src/git/Layers/CursorTextGeneration.ts", - stagedPatch: - "diff --git a/apps/server/src/git/Layers/CursorTextGeneration.ts b/apps/server/src/git/Layers/CursorTextGeneration.ts", - modelSelection: { - ...createModelSelection(ProviderInstanceId.make("cursor"), "gpt-5.4", [ - { id: "reasoning", value: "xhigh" }, - { id: "fastMode", value: true }, - { id: "contextWindow", value: "1m" }, - ]), - }, - }); - - expect(generated.subject).toBe("Add generated commit message"); - expect(generated.body).toBe("- verify cursor acp model config path"); - - const requests = readFileSync(requestLogPath, "utf8") - .trim() - .split("\n") - .filter((line) => line.length > 0) - .map( - (line) => JSON.parse(line) as { method?: string; params?: Record }, - ); - - expect( - requests.find((request) => request.method === "initialize")?.params?.clientCapabilities, - ).toMatchObject({ - _meta: { - parameterizedModelPicker: true, - }, - }); - expect( - requests.some( - (request) => - request.method === "session/set_config_option" && - request.params?.configId === "model" && - request.params?.value === "gpt-5.4", - ), - ).toBe(true); - expect( - requests.some( - (request) => - request.method === "session/set_config_option" && - request.params?.configId === "reasoning" && - request.params?.value === "extra-high", - ), - ).toBe(true); - expect( - requests.some( - (request) => - request.method === "session/set_config_option" && - request.params?.configId === "context" && - request.params?.value === "1m", - ), - ).toBe(true); - expect( - requests.some( - (request) => - request.method === "session/set_config_option" && - request.params?.configId === "fast" && - request.params?.value === "true", - ), - ).toBe(true); - expect( - requests.find((request) => request.method === "session/prompt")?.params?.prompt, - ).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - type: "text", - text: expect.stringContaining("Staged patch:"), - }), - ]), - ); - - rmSync(requestLogDir, { recursive: true, force: true }); - }), - ); - }); - - it.effect("accepts json objects with extra assistant text around them", () => - withFakeAcpAgent( - { - T3_ACP_PROMPT_RESPONSE_TEXT: - 'Sure, here is the JSON:\n```json\n{\n "subject": "Update README dummy comment with attribution and date",\n "body": ""\n}\n```\nDone.', - }, - (textGeneration) => - Effect.gen(function* () { - const generated = yield* textGeneration.generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/cursor-noisy-json", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md", - modelSelection: { - instanceId: ProviderInstanceId.make("cursor"), - model: "composer-2", - }, - }); - - expect(generated.subject).toBe("Update README dummy comment with attribution and date"); - expect(generated.body).toBe(""); - }), - ), - ); - - it.effect("generates thread titles through Cursor ACP text generation", () => - withFakeAcpAgent( - { - T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({ - title: '"Trim reconnect spinner status after resume."', - }), - }, - (textGeneration) => - Effect.gen(function* () { - const generated = yield* textGeneration.generateThreadTitle({ - cwd: process.cwd(), - message: "Fix the reconnect spinner after a resumed session.", - modelSelection: { - instanceId: ProviderInstanceId.make("cursor"), - model: "composer-2", - }, - }); - - expect(generated.title).toBe("Trim reconnect spinner status after resume."); - }), - ), - ); - - it.effect("closes the ACP child process after text generation completes", () => { - const exitLogDir = mkdtempSync(path.join(os.tmpdir(), "t3code-cursor-text-exit-log-")); - const exitLogPath = path.join(exitLogDir, "exit.log"); - - return withFakeAcpAgent( - { - T3_ACP_EXIT_LOG_PATH: exitLogPath, - T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({ - subject: "Close runtime after generation", - body: "", - }), - }, - (textGeneration) => - Effect.gen(function* () { - const generated = yield* textGeneration.generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/cursor-runtime-close", - stagedSummary: "M apps/server/src/git/Layers/CursorTextGeneration.ts", - stagedPatch: - "diff --git a/apps/server/src/git/Layers/CursorTextGeneration.ts b/apps/server/src/git/Layers/CursorTextGeneration.ts", - modelSelection: { - instanceId: ProviderInstanceId.make("cursor"), - model: "composer-2", - }, - }); - - expect(generated.subject).toBe("Close runtime after generation"); - - const exitLog = yield* waitForFileContent(exitLogPath); - expect(exitLog).toContain("exit:0"); - - rmSync(exitLogDir, { recursive: true, force: true }); - }), - ); - }); -}); diff --git a/apps/server/src/git/Layers/CursorTextGeneration.ts b/apps/server/src/git/Layers/CursorTextGeneration.ts deleted file mode 100644 index c94c6dd180a49313ebbe9b3d1ab42815f36c9646..0000000000000000000000000000000000000000 --- a/apps/server/src/git/Layers/CursorTextGeneration.ts +++ /dev/null @@ -1,283 +0,0 @@ -import { Effect, Option, Ref, Schema } from "effect"; -import { ChildProcessSpawner } from "effect/unstable/process"; - -import { type CursorSettings, type ModelSelection } from "@t3tools/contracts"; -import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; - -import { TextGenerationError } from "@t3tools/contracts"; -import { - type ThreadTitleGenerationResult, - type TextGenerationShape, -} from "../Services/TextGeneration.ts"; -import { - buildBranchNamePrompt, - buildCommitMessagePrompt, - buildPrContentPrompt, - buildThreadTitlePrompt, -} from "../Prompts.ts"; -import { - extractJsonObject, - sanitizeCommitSubject, - sanitizePrTitle, - sanitizeThreadTitle, -} from "../Utils.ts"; -import { - applyCursorAcpModelSelection, - makeCursorAcpRuntime, -} from "../../provider/acp/CursorAcpSupport.ts"; - -const CURSOR_TIMEOUT_MS = 180_000; - -function mapCursorAcpError( - operation: - | "generateCommitMessage" - | "generatePrContent" - | "generateBranchName" - | "generateThreadTitle", - detail: string, - cause: unknown, -): TextGenerationError { - return new TextGenerationError({ - operation, - detail, - ...(cause !== undefined ? { cause } : {}), - }); -} - -function isTextGenerationError(error: unknown): error is TextGenerationError { - return ( - typeof error === "object" && - error !== null && - "_tag" in error && - error._tag === "TextGenerationError" - ); -} - -/** - * Build a Cursor text-generation closure bound to a specific `CursorSettings` - * payload. See `makeCodexAdapter` for the overall per-instance rationale. - */ -export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(function* ( - cursorSettings: CursorSettings, - environment: NodeJS.ProcessEnv = process.env, -) { - const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - - const runCursorJson = ({ - operation, - cwd, - prompt, - outputSchemaJson, - modelSelection, - }: { - operation: - | "generateCommitMessage" - | "generatePrContent" - | "generateBranchName" - | "generateThreadTitle"; - cwd: string; - prompt: string; - outputSchemaJson: S; - modelSelection: ModelSelection; - }): Effect.Effect => - Effect.gen(function* () { - const outputRef = yield* Ref.make(""); - const runtime = yield* makeCursorAcpRuntime({ - cursorSettings, - environment, - childProcessSpawner: commandSpawner, - cwd, - clientInfo: { name: "t3-code-git-text", version: "0.0.0" }, - }); - - yield* runtime.handleSessionUpdate((notification) => { - const update = notification.update; - if (update.sessionUpdate !== "agent_message_chunk") { - return Effect.void; - } - const content = update.content; - if (content.type !== "text") { - return Effect.void; - } - return Ref.update(outputRef, (current) => current + content.text); - }); - - const promptResult = yield* Effect.gen(function* () { - yield* runtime.start(); - yield* Effect.ignore(runtime.setMode("ask")); - yield* applyCursorAcpModelSelection({ - runtime, - model: modelSelection.model, - selections: modelSelection.options, - mapError: ({ cause, configId, step }) => - mapCursorAcpError( - operation, - step === "set-config-option" - ? `Failed to set Cursor ACP config option "${configId}" for text generation.` - : "Failed to set Cursor ACP base model for text generation.", - cause, - ), - }); - - return yield* runtime.prompt({ - prompt: [{ type: "text", text: prompt }], - }); - }).pipe( - Effect.timeoutOption(CURSOR_TIMEOUT_MS), - Effect.flatMap( - Option.match({ - onNone: () => - Effect.fail( - new TextGenerationError({ - operation, - detail: "Cursor Agent request timed out.", - }), - ), - onSome: (value) => Effect.succeed(value), - }), - ), - Effect.mapError((cause) => - isTextGenerationError(cause) - ? cause - : mapCursorAcpError(operation, "Cursor ACP request failed.", cause), - ), - ); - - const rawResult = (yield* Ref.get(outputRef)).trim(); - if (!rawResult) { - return yield* new TextGenerationError({ - operation, - detail: - promptResult.stopReason === "cancelled" - ? "Cursor ACP request was cancelled." - : "Cursor Agent returned empty output.", - }); - } - - return yield* Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson))( - extractJsonObject(rawResult), - ).pipe( - Effect.catchTag("SchemaError", (cause) => - Effect.fail( - new TextGenerationError({ - operation, - detail: "Cursor Agent returned invalid structured output.", - cause, - }), - ), - ), - ); - }).pipe( - Effect.mapError((cause) => - isTextGenerationError(cause) - ? cause - : mapCursorAcpError(operation, "Cursor ACP text generation failed.", cause), - ), - Effect.scoped, - ); - - const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn( - "CursorTextGeneration.generateCommitMessage", - )(function* (input) { - const { prompt, outputSchema } = buildCommitMessagePrompt({ - branch: input.branch, - stagedSummary: input.stagedSummary, - stagedPatch: input.stagedPatch, - includeBranch: input.includeBranch === true, - }); - - const generated = yield* runCursorJson({ - operation: "generateCommitMessage", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - modelSelection: input.modelSelection, - }); - - return { - subject: sanitizeCommitSubject(generated.subject), - body: generated.body.trim(), - ...("branch" in generated && typeof generated.branch === "string" - ? { branch: sanitizeFeatureBranchName(generated.branch) } - : {}), - }; - }); - - const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn( - "CursorTextGeneration.generatePrContent", - )(function* (input) { - const { prompt, outputSchema } = buildPrContentPrompt({ - baseBranch: input.baseBranch, - headBranch: input.headBranch, - commitSummary: input.commitSummary, - diffSummary: input.diffSummary, - diffPatch: input.diffPatch, - }); - - const generated = yield* runCursorJson({ - operation: "generatePrContent", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - modelSelection: input.modelSelection, - }); - - return { - title: sanitizePrTitle(generated.title), - body: generated.body.trim(), - }; - }); - - const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn( - "CursorTextGeneration.generateBranchName", - )(function* (input) { - const { prompt, outputSchema } = buildBranchNamePrompt({ - message: input.message, - attachments: input.attachments, - }); - - const generated = yield* runCursorJson({ - operation: "generateBranchName", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - modelSelection: input.modelSelection, - }); - - return { - branch: sanitizeBranchFragment(generated.branch), - }; - }); - - const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn( - "CursorTextGeneration.generateThreadTitle", - )(function* (input) { - const { prompt, outputSchema } = buildThreadTitlePrompt({ - message: input.message, - attachments: input.attachments, - }); - - const generated = yield* runCursorJson({ - operation: "generateThreadTitle", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - modelSelection: input.modelSelection, - }); - - return { - title: sanitizeThreadTitle(generated.title), - } satisfies ThreadTitleGenerationResult; - }); - - return { - generateCommitMessage, - generatePrContent, - generateBranchName, - generateThreadTitle, - } satisfies TextGenerationShape; -}); - -// NOTE: `CursorTextGenerationLive` (the singleton Layer) has been removed. -// `makeCursorTextGeneration(cursorConfig)` is now invoked directly by -// `CursorDriver.create()` so each provider instance owns its own closure. diff --git a/apps/server/src/git/Layers/GitCore.test.ts b/apps/server/src/git/Layers/GitCore.test.ts deleted file mode 100644 index 665c4b138f985390d11c5521d116004f986fe25c..0000000000000000000000000000000000000000 --- a/apps/server/src/git/Layers/GitCore.test.ts +++ /dev/null @@ -1,2333 +0,0 @@ -import { existsSync } from "node:fs"; -import path from "node:path"; - -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { it } from "@effect/vitest"; -import { Effect, FileSystem, Layer, PlatformError, Scope } from "effect"; -import { describe, expect, vi } from "vitest"; - -import { GitCoreLive, makeGitCore } from "./GitCore.ts"; -import { GitCore, type GitCoreShape } from "../Services/GitCore.ts"; -import { GitCommandError } from "@t3tools/contracts"; -import { type ProcessRunResult, runProcess } from "../../processRunner.ts"; -import { ServerConfig } from "../../config.ts"; - -// ── Helpers ── - -const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-core-test-" }); -const GitCoreTestLayer = GitCoreLive.pipe( - Layer.provide(ServerConfigLayer), - Layer.provide(NodeServices.layer), -); -const TestLayer = Layer.mergeAll(NodeServices.layer, GitCoreTestLayer); - -function makeTmpDir( - prefix = "git-test-", -): Effect.Effect { - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - return yield* fileSystem.makeTempDirectoryScoped({ prefix }); - }); -} - -function writeTextFile( - filePath: string, - contents: string, -): Effect.Effect { - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - yield* fileSystem.writeFileString(filePath, contents); - }); -} - -function removePath( - targetPath: string, -): Effect.Effect { - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - yield* fileSystem.remove(targetPath, { recursive: true, force: true }); - }); -} - -function makeDirectory( - dirPath: string, -): Effect.Effect { - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - yield* fileSystem.makeDirectory(dirPath, { recursive: true }); - }); -} - -/** Run a raw git command for test setup (not under test). */ -function git( - cwd: string, - args: ReadonlyArray, - env?: NodeJS.ProcessEnv, -): Effect.Effect { - return Effect.gen(function* () { - const gitCore = yield* GitCore; - const result = yield* gitCore.execute({ - operation: "GitCore.test.git", - cwd, - args, - ...(env ? { env } : {}), - timeoutMs: 10_000, - }); - return result.stdout.trim(); - }); -} - -function configureRemote( - cwd: string, - remoteName: string, - remotePath: string, - fetchNamespace: string, -): Effect.Effect { - return Effect.gen(function* () { - yield* git(cwd, ["config", `remote.${remoteName}.url`, remotePath]); - return yield* git(cwd, [ - "config", - "--replace-all", - `remote.${remoteName}.fetch`, - `+refs/heads/*:refs/remotes/${fetchNamespace}/*`, - ]); - }); -} - -function runShellCommand(input: { - command: string; - cwd: string; - timeoutMs?: number; - maxOutputBytes?: number; -}): Effect.Effect { - return Effect.promise(() => { - const shellPath = - process.platform === "win32" - ? (process.env.ComSpec ?? "cmd.exe") - : (process.env.SHELL ?? "/bin/sh"); - - const args = - process.platform === "win32" ? ["/d", "/s", "/c", input.command] : ["-lc", input.command]; - - return runProcess(shellPath, args, { - cwd: input.cwd, - timeoutMs: input.timeoutMs ?? 30_000, - allowNonZeroExit: true, - maxBufferBytes: input.maxOutputBytes ?? 1_000_000, - outputMode: "truncate", - }); - }); -} - -const makeIsolatedGitCore = (executeOverride: GitCoreShape["execute"]) => - makeGitCore({ executeOverride }).pipe( - Effect.provide(Layer.provideMerge(ServerConfigLayer, NodeServices.layer)), - ); - -/** Create a repo with an initial commit so branches work. */ -function initRepoWithCommit( - cwd: string, -): Effect.Effect< - { initialBranch: string }, - GitCommandError | PlatformError.PlatformError, - GitCore | FileSystem.FileSystem -> { - return Effect.gen(function* () { - const core = yield* GitCore; - yield* core.initRepo({ cwd }); - yield* git(cwd, ["config", "user.email", "test@test.com"]); - yield* git(cwd, ["config", "user.name", "Test"]); - yield* writeTextFile(path.join(cwd, "README.md"), "# test\n"); - yield* git(cwd, ["add", "."]); - yield* git(cwd, ["commit", "-m", "initial commit"]); - const initialBranch = yield* git(cwd, ["branch", "--show-current"]); - return { initialBranch }; - }); -} - -function commitWithDate( - cwd: string, - fileName: string, - fileContents: string, - dateIsoString: string, - message: string, -): Effect.Effect< - void, - GitCommandError | PlatformError.PlatformError, - GitCore | FileSystem.FileSystem -> { - return Effect.gen(function* () { - yield* writeTextFile(path.join(cwd, fileName), fileContents); - yield* git(cwd, ["add", fileName]); - yield* git(cwd, ["commit", "-m", message], { - ...process.env, - GIT_AUTHOR_DATE: dateIsoString, - GIT_COMMITTER_DATE: dateIsoString, - }); - }); -} - -function buildLargeText(lineCount = 20_000): string { - return Array.from({ length: lineCount }, (_, index) => `line ${String(index).padStart(5, "0")}`) - .join("\n") - .concat("\n"); -} - -function splitNullSeparatedPaths(input: string): string[] { - return input - .split("\0") - .map((value) => value.trim()) - .filter((value) => value.length > 0); -} - -// ── Tests ── - -it.layer(TestLayer)("git integration", (it) => { - describe("shell process execution", () => { - it.effect("caps captured output when maxOutputBytes is exceeded", () => - Effect.gen(function* () { - const result = yield* runShellCommand({ - command: `node -e "process.stdout.write('x'.repeat(2000))"`, - cwd: process.cwd(), - timeoutMs: 10_000, - maxOutputBytes: 128, - }); - - expect(result.code).toBe(0); - expect(result.stdout.length).toBeLessThanOrEqual(128); - expect(result.stdoutTruncated || result.stderrTruncated).toBe(true); - }), - ); - }); - - // ── initGitRepo ── - - describe("initGitRepo", () => { - it.effect("creates a valid git repo", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* (yield* GitCore).initRepo({ cwd: tmp }); - expect(existsSync(path.join(tmp, ".git"))).toBe(true); - }), - ); - - it.effect("listGitBranches reports isRepo: true after init + commit", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); - expect(result.isRepo).toBe(true); - expect(result.hasOriginRemote).toBe(false); - expect(result.branches.length).toBeGreaterThanOrEqual(1); - }), - ); - }); - - describe("workspace helpers", () => { - it.effect("filterIgnoredPaths chunks large path lists and preserves kept paths", () => - Effect.gen(function* () { - const cwd = "/virtual/repo"; - const relativePaths = Array.from({ length: 340 }, (_, index) => { - const prefix = index % 3 === 0 ? "ignored" : "kept"; - return `${prefix}/segment-${String(index).padStart(4, "0")}/${"x".repeat(900)}.ts`; - }); - const expectedPaths = relativePaths.filter( - (relativePath) => !relativePath.startsWith("ignored/"), - ); - - const seenChunks: string[][] = []; - const core = yield* makeIsolatedGitCore((input) => { - if ( - input.args.join(" ") !== - "-c core.fsmonitor=false -c core.untrackedCache=false check-ignore --no-index -z --stdin" - ) { - return Effect.fail( - new GitCommandError({ - operation: input.operation, - command: `git ${input.args.join(" ")}`, - cwd: input.cwd, - detail: "unexpected git command in chunking test", - }), - ); - } - - const chunkPaths = splitNullSeparatedPaths(input.stdin ?? ""); - seenChunks.push(chunkPaths); - const ignoredPaths = chunkPaths.filter((relativePath) => - relativePath.startsWith("ignored/"), - ); - - return Effect.succeed({ - code: ignoredPaths.length > 0 ? 0 : 1, - stdout: ignoredPaths.length > 0 ? `${ignoredPaths.join("\0")}\0` : "", - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, - }); - }); - - const result = yield* core.filterIgnoredPaths(cwd, relativePaths); - - expect(seenChunks.length).toBeGreaterThan(1); - expect(seenChunks.flat()).toEqual(relativePaths); - expect(result).toEqual(expectedPaths); - }), - ); - - it.effect("listWorkspaceFiles disables fsmonitor and untracked cache helpers", () => - Effect.gen(function* () { - const core = yield* makeIsolatedGitCore((input) => { - expect(input.args).toEqual([ - "-c", - "core.fsmonitor=false", - "-c", - "core.untrackedCache=false", - "ls-files", - "--cached", - "--others", - "--exclude-standard", - "-z", - ]); - return Effect.succeed({ - code: 0, - stdout: "src/index.ts\0README.md\0", - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, - }); - }); - - const result = yield* core.listWorkspaceFiles("/virtual/repo"); - expect(result.paths).toEqual(["src/index.ts", "README.md"]); - expect(result.truncated).toBe(false); - }), - ); - }); - - // ── listGitBranches ── - - describe("listGitBranches", () => { - it.effect("returns isRepo: false for non-git directory", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); - expect(result.isRepo).toBe(false); - expect(result.hasOriginRemote).toBe(false); - expect(result.branches).toEqual([]); - }), - ); - - it.effect("returns isRepo: false for deleted directories", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - const deletedDir = path.join(tmp, "deleted-repo"); - yield* makeDirectory(deletedDir); - yield* removePath(deletedDir); - - const result = yield* (yield* GitCore).listBranches({ cwd: deletedDir }); - - expect(result.isRepo).toBe(false); - expect(result.hasOriginRemote).toBe(false); - expect(result.branches).toEqual([]); - }), - ); - - it.effect("returns the current branch with current: true", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); - const current = result.branches.find((b) => b.current); - expect(current).toBeDefined(); - expect(current!.current).toBe(true); - }), - ); - - it.effect("does not include detached HEAD pseudo-refs as branches", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - yield* git(tmp, ["checkout", "--detach", "HEAD"]); - - const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); - expect(result.branches.some((branch) => branch.name.startsWith("("))).toBe(false); - expect(result.branches.some((branch) => branch.current)).toBe(false); - }), - ); - - it.effect("keeps current branch first and sorts the remaining branches by recency", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - const initialBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( - (branch) => branch.current, - )!.name; - - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "older-branch" }); - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "older-branch" }); - yield* commitWithDate( - tmp, - "older.txt", - "older branch change\n", - "Thu, 1 Jan 2037 00:00:00 +0000", - "older branch change", - ); - - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: initialBranch }); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "newer-branch" }); - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "newer-branch" }); - yield* commitWithDate( - tmp, - "newer.txt", - "newer branch change\n", - "Fri, 1 Jan 2038 00:00:00 +0000", - "newer branch change", - ); - - // Switch away to show current branch is pinned, then remaining branches are recency-sorted. - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "older-branch" }); - - const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); - expect(result.branches[0]!.name).toBe("older-branch"); - expect(result.branches[1]!.name).toBe("newer-branch"); - }), - ); - - it.effect("keeps default branch right after current branch", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - const remote = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( - (branch) => branch.current, - )!.name; - - yield* git(remote, ["init", "--bare"]); - yield* git(tmp, ["remote", "add", "origin", remote]); - yield* git(tmp, ["push", "-u", "origin", defaultBranch]); - yield* git(tmp, ["remote", "set-head", "origin", defaultBranch]); - - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "current-branch" }); - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "current-branch" }); - yield* commitWithDate( - tmp, - "current.txt", - "current change\n", - "Thu, 1 Jan 2037 00:00:00 +0000", - "current change", - ); - - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: defaultBranch }); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "newer-branch" }); - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "newer-branch" }); - yield* commitWithDate( - tmp, - "newer.txt", - "newer change\n", - "Fri, 1 Jan 2038 00:00:00 +0000", - "newer change", - ); - - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "current-branch" }); - - const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); - expect(result.branches[0]!.name).toBe("current-branch"); - expect(result.branches[1]!.name).toBe(defaultBranch); - expect(result.branches[2]!.name).toBe("newer-branch"); - }), - ); - - it.effect("lists multiple branches after creating them", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature-a" }); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature-b" }); - - const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); - const names = result.branches.map((b) => b.name); - expect(names).toContain("feature-a"); - expect(names).toContain("feature-b"); - }), - ); - - it.effect("paginates branch results and returns paging metadata", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - const { initialBranch } = yield* initRepoWithCommit(tmp); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature-a" }); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature-b" }); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature-c" }); - - const firstPage = yield* (yield* GitCore).listBranches({ cwd: tmp, limit: 2 }); - expect(firstPage.totalCount).toBe(4); - expect(firstPage.nextCursor).toBe(2); - expect(firstPage.branches.map((branch) => branch.name)).toEqual([ - initialBranch, - "feature-a", - ]); - - const secondPage = yield* (yield* GitCore).listBranches({ - cwd: tmp, - cursor: firstPage.nextCursor ?? 0, - limit: 2, - }); - expect(secondPage.totalCount).toBe(4); - expect(secondPage.nextCursor).toBeNull(); - expect(secondPage.branches.map((branch) => branch.name)).toEqual([ - "feature-b", - "feature-c", - ]); - }), - ); - - it.effect("parses separate branch names when column.ui is always enabled", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - const { initialBranch } = yield* initRepoWithCommit(tmp); - const createdBranchNames = [ - "go-bin", - "copilot/rewrite-cli-in-go", - "copilot/rewrite-cli-in-rust", - ] as const; - for (const branchName of createdBranchNames) { - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: branchName }); - } - yield* git(tmp, ["config", "column.ui", "always"]); - - const rawBranchOutput = yield* git(tmp, ["branch", "--no-color"], { - ...process.env, - COLUMNS: "120", - }); - expect( - rawBranchOutput - .split("\n") - .some( - (line) => - createdBranchNames.filter((branchName) => line.includes(branchName)).length >= 2, - ), - ).toBe(true); - - const realGitCore = yield* GitCore; - const core = yield* makeIsolatedGitCore((input) => - realGitCore.execute( - input.args[0] === "branch" - ? { - ...input, - env: { ...input.env, COLUMNS: "120" }, - } - : input, - ), - ); - - const result = yield* core.listBranches({ cwd: tmp }); - const localBranchNames = result.branches - .filter((branch) => !branch.isRemote) - .map((branch) => branch.name); - - expect(localBranchNames).toHaveLength(4); - expect(localBranchNames).toEqual( - expect.arrayContaining([initialBranch, ...createdBranchNames]), - ); - expect( - localBranchNames.some( - (branchName) => - createdBranchNames.filter((createdBranch) => branchName.includes(createdBranch)) - .length >= 2, - ), - ).toBe(false); - }), - ); - - it.effect("isDefault is false when no remote exists", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); - expect(result.branches.every((b) => b.isDefault === false)).toBe(true); - }), - ); - - it.effect("lists local branches first and remote branches last", () => - Effect.gen(function* () { - const remote = yield* makeTmpDir(); - const tmp = yield* makeTmpDir(); - - yield* git(remote, ["init", "--bare"]); - yield* initRepoWithCommit(tmp); - const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( - (branch) => branch.current, - )!.name; - - yield* git(tmp, ["remote", "add", "origin", remote]); - yield* git(tmp, ["push", "-u", "origin", defaultBranch]); - - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature/local-only" }); - - const remoteOnlyBranch = "feature/remote-only"; - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: defaultBranch }); - yield* git(tmp, ["checkout", "-b", remoteOnlyBranch]); - yield* git(tmp, ["push", "-u", "origin", remoteOnlyBranch]); - yield* git(tmp, ["checkout", defaultBranch]); - yield* git(tmp, ["branch", "-D", remoteOnlyBranch]); - - const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); - const firstRemoteIndex = result.branches.findIndex((branch) => branch.isRemote); - - expect(result.hasOriginRemote).toBe(true); - expect(firstRemoteIndex).toBeGreaterThan(0); - expect(result.branches.slice(0, firstRemoteIndex).every((branch) => !branch.isRemote)).toBe( - true, - ); - expect(result.branches.slice(firstRemoteIndex).every((branch) => branch.isRemote)).toBe( - true, - ); - expect( - result.branches.some( - (branch) => branch.name === "feature/local-only" && !branch.isRemote, - ), - ).toBe(true); - expect( - result.branches.some( - (branch) => branch.name === "origin/feature/remote-only" && branch.isRemote, - ), - ).toBe(true); - }), - ); - - it.effect("includes remoteName metadata for remotes with slash in the name", () => - Effect.gen(function* () { - const remote = yield* makeTmpDir(); - const tmp = yield* makeTmpDir(); - const remoteName = "my-org/upstream"; - - yield* git(remote, ["init", "--bare"]); - yield* initRepoWithCommit(tmp); - const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( - (branch) => branch.current, - )!.name; - - yield* git(tmp, ["remote", "add", remoteName, remote]); - yield* git(tmp, ["push", "-u", remoteName, defaultBranch]); - - const remoteOnlyBranch = "feature/remote-with-remote-name"; - yield* git(tmp, ["checkout", "-b", remoteOnlyBranch]); - yield* git(tmp, ["push", "-u", remoteName, remoteOnlyBranch]); - yield* git(tmp, ["checkout", defaultBranch]); - yield* git(tmp, ["branch", "-D", remoteOnlyBranch]); - - const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); - const remoteBranch = result.branches.find( - (branch) => branch.name === `${remoteName}/${remoteOnlyBranch}`, - ); - - expect(remoteBranch).toBeDefined(); - expect(remoteBranch?.isRemote).toBe(true); - expect(remoteBranch?.remoteName).toBe(remoteName); - }), - ); - - it.effect( - "filters branch queries before pagination and dedupes origin refs with local matches", - () => - Effect.gen(function* () { - const remote = yield* makeTmpDir(); - const tmp = yield* makeTmpDir(); - - yield* git(remote, ["init", "--bare"]); - const { initialBranch } = yield* initRepoWithCommit(tmp); - yield* git(tmp, ["remote", "add", "origin", remote]); - yield* git(tmp, ["push", "-u", "origin", initialBranch]); - - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature/demo" }); - yield* git(tmp, ["push", "-u", "origin", "feature/demo"]); - - yield* git(tmp, ["checkout", "-b", "feature/remote-only"]); - yield* git(tmp, ["push", "-u", "origin", "feature/remote-only"]); - yield* git(tmp, ["checkout", initialBranch]); - yield* git(tmp, ["branch", "-D", "feature/remote-only"]); - - const result = yield* (yield* GitCore).listBranches({ - cwd: tmp, - query: "feature/", - limit: 10, - }); - - expect(result.totalCount).toBe(2); - expect(result.nextCursor).toBeNull(); - expect(result.branches.map((branch) => branch.name)).toEqual([ - "feature/demo", - "origin/feature/remote-only", - ]); - }), - ); - }); - - // ── checkoutGitBranch ── - - describe("checkoutGitBranch", () => { - it.effect("checks out an existing branch", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature" }); - - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "feature" }); - - const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); - const current = result.branches.find((b) => b.current); - expect(current!.name).toBe("feature"); - }), - ); - - it.effect("refreshes upstream behind count after checkout when remote branch advanced", () => - Effect.gen(function* () { - const context = yield* Effect.context(); - const runPromise = Effect.runPromiseWith(context); - - const remote = yield* makeTmpDir(); - const source = yield* makeTmpDir(); - const clone = yield* makeTmpDir(); - yield* git(remote, ["init", "--bare"]); - - yield* initRepoWithCommit(source); - const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find( - (branch) => branch.current, - )!.name; - yield* git(source, ["remote", "add", "origin", remote]); - yield* git(source, ["push", "-u", "origin", defaultBranch]); - - const featureBranch = "feature-behind"; - yield* (yield* GitCore).createBranch({ cwd: source, branch: featureBranch }); - yield* (yield* GitCore).checkoutBranch({ cwd: source, branch: featureBranch }); - yield* writeTextFile(path.join(source, "feature.txt"), "feature base\n"); - yield* git(source, ["add", "feature.txt"]); - yield* git(source, ["commit", "-m", "feature base"]); - yield* git(source, ["push", "-u", "origin", featureBranch]); - yield* (yield* GitCore).checkoutBranch({ cwd: source, branch: defaultBranch }); - - yield* git(clone, ["clone", remote, "."]); - yield* git(clone, ["config", "user.email", "test@test.com"]); - yield* git(clone, ["config", "user.name", "Test"]); - yield* git(clone, ["checkout", "-b", featureBranch, "--track", `origin/${featureBranch}`]); - yield* writeTextFile(path.join(clone, "feature.txt"), "feature from remote\n"); - yield* git(clone, ["add", "feature.txt"]); - yield* git(clone, ["commit", "-m", "remote feature update"]); - yield* git(clone, ["push", "origin", featureBranch]); - - yield* (yield* GitCore).checkoutBranch({ cwd: source, branch: featureBranch }); - const core = yield* GitCore; - yield* Effect.promise(() => - vi.waitFor( - async () => { - const details = await runPromise(core.statusDetails(source)); - expect(details.branch).toBe(featureBranch); - expect(details.aheadCount).toBe(0); - expect(details.behindCount).toBe(1); - }, - { - timeout: 10_000, - interval: 100, - }, - ), - ); - }), - ); - - it.effect("statusDetails remains successful when upstream refresh fails after checkout", () => - Effect.gen(function* () { - const remote = yield* makeTmpDir(); - const source = yield* makeTmpDir(); - yield* git(remote, ["init", "--bare"]); - - yield* initRepoWithCommit(source); - const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find( - (branch) => branch.current, - )!.name; - yield* git(source, ["remote", "add", "origin", remote]); - yield* git(source, ["push", "-u", "origin", defaultBranch]); - - const featureBranch = "feature-refresh-failure"; - yield* git(source, ["branch", featureBranch]); - yield* git(source, ["checkout", featureBranch]); - yield* writeTextFile(path.join(source, "feature.txt"), "feature base\n"); - yield* git(source, ["add", "feature.txt"]); - yield* git(source, ["commit", "-m", "feature base"]); - yield* git(source, ["push", "-u", "origin", featureBranch]); - yield* git(source, ["checkout", defaultBranch]); - - const realGitCore = yield* GitCore; - let refreshFetchAttempts = 0; - const core = yield* makeIsolatedGitCore((input) => { - if (input.args[0] === "--git-dir" && input.args[2] === "fetch") { - refreshFetchAttempts += 1; - return Effect.fail( - new GitCommandError({ - operation: "git.test.refreshFailure", - command: `git ${input.args.join(" ")}`, - cwd: input.cwd, - detail: "simulated fetch timeout", - }), - ); - } - return realGitCore.execute(input); - }); - yield* core.checkoutBranch({ cwd: source, branch: featureBranch }); - const status = yield* core.statusDetails(source); - expect(refreshFetchAttempts).toBe(1); - expect(status.branch).toBe(featureBranch); - expect(status.upstreamRef).toBe(`origin/${featureBranch}`); - expect(yield* git(source, ["branch", "--show-current"])).toBe(featureBranch); - }), - ); - - it.effect("defers upstream refresh until statusDetails is requested", () => - Effect.gen(function* () { - const remote = yield* makeTmpDir(); - const source = yield* makeTmpDir(); - yield* git(remote, ["init", "--bare"]); - - yield* initRepoWithCommit(source); - const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find( - (branch) => branch.current, - )!.name; - yield* git(source, ["remote", "add", "origin", remote]); - yield* git(source, ["push", "-u", "origin", defaultBranch]); - - const featureBranch = "feature/scoped-fetch"; - yield* git(source, ["checkout", "-b", featureBranch]); - yield* writeTextFile(path.join(source, "feature.txt"), "feature base\n"); - yield* git(source, ["add", "feature.txt"]); - yield* git(source, ["commit", "-m", "feature base"]); - yield* git(source, ["push", "-u", "origin", featureBranch]); - yield* git(source, ["checkout", defaultBranch]); - - const realGitCore = yield* GitCore; - let refreshFetchAttempts = 0; - const core = yield* makeIsolatedGitCore((input) => { - if (input.args[0] === "--git-dir" && input.args[2] === "fetch") { - refreshFetchAttempts += 1; - return Effect.succeed({ - code: 0, - stdout: "", - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, - }); - } - return realGitCore.execute(input); - }); - yield* core.checkoutBranch({ cwd: source, branch: featureBranch }); - yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 50))); - expect(refreshFetchAttempts).toBe(0); - const status = yield* core.statusDetails(source); - expect(status.branch).toBe(featureBranch); - expect(refreshFetchAttempts).toBe(1); - }), - ); - - it.effect("coalesces upstream refreshes across sibling worktrees on the same remote", () => - Effect.gen(function* () { - const ok = (stdout = "") => - Effect.succeed({ - code: 0, - stdout, - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, - }); - - let fetchCount = 0; - const core = yield* makeIsolatedGitCore((input) => { - if ( - input.args[0] === "rev-parse" && - input.args[1] === "--abbrev-ref" && - input.args[2] === "--symbolic-full-name" && - input.args[3] === "@{upstream}" - ) { - return ok( - input.cwd === "/repo/worktrees/pr-123" ? "origin/feature/pr-123\n" : "origin/main\n", - ); - } - if (input.args[0] === "remote") { - return ok("origin\n"); - } - if (input.args[0] === "rev-parse" && input.args[1] === "--git-common-dir") { - return ok("/repo/.git\n"); - } - if (input.args[0] === "--git-dir" && input.args[2] === "fetch") { - fetchCount += 1; - expect(input.cwd).toBe("/repo"); - expect(input.args).toEqual([ - "--git-dir", - "/repo/.git", - "fetch", - "--quiet", - "--no-tags", - "origin", - ]); - return ok(); - } - if (input.operation === "GitCore.statusDetails.status") { - return ok( - input.cwd === "/repo/worktrees/pr-123" - ? "# branch.head feature/pr-123\n# branch.upstream origin/feature/pr-123\n# branch.ab +0 -0\n" - : "# branch.head main\n# branch.upstream origin/main\n# branch.ab +0 -0\n", - ); - } - if ( - input.operation === "GitCore.statusDetails.unstagedNumstat" || - input.operation === "GitCore.statusDetails.stagedNumstat" - ) { - return ok(); - } - if (input.operation === "GitCore.statusDetails.defaultRef") { - return ok("refs/remotes/origin/main\n"); - } - return Effect.fail( - new GitCommandError({ - operation: input.operation, - command: `git ${input.args.join(" ")}`, - cwd: input.cwd, - detail: "Unexpected git command in shared refresh cache test.", - }), - ); - }); - - yield* core.statusDetails("/repo/worktrees/main"); - yield* core.statusDetails("/repo/worktrees/pr-123"); - expect(fetchCount).toBe(1); - }), - ); - - it.effect( - "briefly backs off failed upstream refreshes across sibling worktrees on one remote", - () => - Effect.gen(function* () { - const ok = (stdout = "") => - Effect.succeed({ - code: 0, - stdout, - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, - }); - - let fetchCount = 0; - const core = yield* makeIsolatedGitCore((input) => { - if ( - input.args[0] === "rev-parse" && - input.args[1] === "--abbrev-ref" && - input.args[2] === "--symbolic-full-name" && - input.args[3] === "@{upstream}" - ) { - return ok( - input.cwd === "/repo/worktrees/pr-123" - ? "origin/feature/pr-123\n" - : "origin/main\n", - ); - } - if (input.args[0] === "remote") { - return ok("origin\n"); - } - if (input.args[0] === "rev-parse" && input.args[1] === "--git-common-dir") { - return ok("/repo/.git\n"); - } - if (input.args[0] === "--git-dir" && input.args[2] === "fetch") { - fetchCount += 1; - return Effect.fail( - new GitCommandError({ - operation: input.operation, - command: `git ${input.args.join(" ")}`, - cwd: input.cwd, - detail: "simulated fetch timeout", - }), - ); - } - if (input.operation === "GitCore.statusDetails.status") { - return ok( - input.cwd === "/repo/worktrees/pr-123" - ? "# branch.head feature/pr-123\n# branch.upstream origin/feature/pr-123\n# branch.ab +0 -0\n" - : "# branch.head main\n# branch.upstream origin/main\n# branch.ab +0 -0\n", - ); - } - if ( - input.operation === "GitCore.statusDetails.unstagedNumstat" || - input.operation === "GitCore.statusDetails.stagedNumstat" - ) { - return ok(); - } - if (input.operation === "GitCore.statusDetails.defaultRef") { - return ok("refs/remotes/origin/main\n"); - } - return Effect.fail( - new GitCommandError({ - operation: input.operation, - command: `git ${input.args.join(" ")}`, - cwd: input.cwd, - detail: "Unexpected git command in refresh failure cooldown test.", - }), - ); - }); - - yield* core.statusDetails("/repo/worktrees/main"); - yield* core.statusDetails("/repo/worktrees/pr-123"); - expect(fetchCount).toBe(1); - }), - ); - - it.effect("throws when branch does not exist", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - const result = yield* Effect.result( - (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "nonexistent" }), - ); - expect(result._tag).toBe("Failure"); - }), - ); - - it.effect("does not silently checkout a local branch when a remote ref no longer exists", () => - Effect.gen(function* () { - const remote = yield* makeTmpDir(); - const source = yield* makeTmpDir(); - yield* git(remote, ["init", "--bare"]); - - yield* initRepoWithCommit(source); - const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find( - (branch) => branch.current, - )!.name; - yield* git(source, ["remote", "add", "origin", remote]); - yield* git(source, ["push", "-u", "origin", defaultBranch]); - - yield* (yield* GitCore).createBranch({ cwd: source, branch: "feature" }); - - const checkoutResult = yield* Effect.result( - (yield* GitCore).checkoutBranch({ cwd: source, branch: "origin/feature" }), - ); - expect(checkoutResult._tag).toBe("Failure"); - expect(yield* git(source, ["branch", "--show-current"])).toBe(defaultBranch); - }), - ); - - it.effect("checks out a remote tracking branch when remote name contains slashes", () => - Effect.gen(function* () { - const remote = yield* makeTmpDir(); - const prefixRemote = yield* makeTmpDir(); - const source = yield* makeTmpDir(); - const prefixFetchNamespace = "prefix-my-org"; - const prefixRemoteName = "my-org"; - const remoteName = "my-org/upstream"; - const featureBranch = "feature"; - yield* git(remote, ["init", "--bare"]); - yield* git(prefixRemote, ["init", "--bare"]); - - yield* initRepoWithCommit(source); - const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find( - (branch) => branch.current, - )!.name; - yield* configureRemote(source, prefixRemoteName, prefixRemote, prefixFetchNamespace); - yield* configureRemote(source, remoteName, remote, remoteName); - yield* git(source, ["push", "-u", remoteName, defaultBranch]); - - yield* git(source, ["checkout", "-b", featureBranch]); - yield* writeTextFile(path.join(source, "feature.txt"), "feature content\n"); - yield* git(source, ["add", "feature.txt"]); - yield* git(source, ["commit", "-m", "feature commit"]); - yield* git(source, ["push", "-u", remoteName, featureBranch]); - yield* git(source, ["checkout", defaultBranch]); - yield* git(source, ["branch", "-D", featureBranch]); - - const checkoutResult = yield* (yield* GitCore).checkoutBranch({ - cwd: source, - branch: `${remoteName}/${featureBranch}`, - }); - - expect(checkoutResult.branch).toBe("upstream/feature"); - expect(yield* git(source, ["branch", "--show-current"])).toBe("upstream/feature"); - const realGitCore = yield* GitCore; - let fetchArgs: readonly string[] | null = null; - const core = yield* makeIsolatedGitCore((input) => { - if (input.args[0] === "--git-dir" && input.args[2] === "fetch") { - fetchArgs = [...input.args]; - return Effect.succeed({ - code: 0, - stdout: "", - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, - }); - } - return realGitCore.execute(input); - }); - - const status = yield* core.statusDetails(source); - expect(status.branch).toBe("upstream/feature"); - expect(status.upstreamRef).toBe(`${remoteName}/${featureBranch}`); - expect(fetchArgs).toEqual([ - "--git-dir", - path.join(source, ".git"), - "fetch", - "--quiet", - "--no-tags", - remoteName, - ]); - }), - ); - - it.effect( - "falls back to detached checkout when --track would conflict with an existing local branch", - () => - Effect.gen(function* () { - const remote = yield* makeTmpDir(); - const source = yield* makeTmpDir(); - yield* git(remote, ["init", "--bare"]); - - yield* initRepoWithCommit(source); - const defaultBranch = (yield* (yield* GitCore).listBranches({ - cwd: source, - })).branches.find((branch) => branch.current)!.name; - yield* git(source, ["remote", "add", "origin", remote]); - yield* git(source, ["push", "-u", "origin", defaultBranch]); - - // Keep local branch but remove tracking so `--track origin/` - // would attempt to create an already-existing local branch. - yield* git(source, ["branch", "--unset-upstream"]); - - yield* (yield* GitCore).checkoutBranch({ - cwd: source, - branch: `origin/${defaultBranch}`, - }); - - const core = yield* GitCore; - const status = yield* core.statusDetails(source); - expect(status.branch).toBeNull(); - }), - ); - - it.effect("throws when checkout would overwrite uncommitted changes", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "other" }); - - // Create a conflicting change: modify README on current branch - yield* writeTextFile(path.join(tmp, "README.md"), "modified\n"); - yield* git(tmp, ["add", "README.md"]); - - // First, checkout other branch cleanly - yield* git(tmp, ["stash"]); - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "other" }); - yield* writeTextFile(path.join(tmp, "README.md"), "other content\n"); - yield* git(tmp, ["add", "."]); - yield* git(tmp, ["commit", "-m", "other change"]); - - // Go back to default branch - const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( - (b) => !b.current, - )!.name; - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: defaultBranch }); - - // Make uncommitted changes to the same file - yield* writeTextFile(path.join(tmp, "README.md"), "conflicting local\n"); - - // Checkout should fail due to uncommitted changes - const result = yield* Effect.result( - (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "other" }), - ); - expect(result._tag).toBe("Failure"); - }), - ); - }); - - // ── createGitBranch ── - - describe("createGitBranch", () => { - it.effect("creates a new branch visible in listGitBranches", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "new-feature" }); - - const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); - expect(result.branches.some((b) => b.name === "new-feature")).toBe(true); - }), - ); - - it.effect("throws when branch already exists", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "dupe" }); - const result = yield* Effect.result( - (yield* GitCore).createBranch({ cwd: tmp, branch: "dupe" }), - ); - expect(result._tag).toBe("Failure"); - }), - ); - }); - - // ── renameGitBranch ── - - describe("renameGitBranch", () => { - it.effect("renames the current branch", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature/old-name" }); - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "feature/old-name" }); - - const renamed = yield* (yield* GitCore).renameBranch({ - cwd: tmp, - oldBranch: "feature/old-name", - newBranch: "feature/new-name", - }); - - expect(renamed.branch).toBe("feature/new-name"); - - const branches = yield* (yield* GitCore).listBranches({ cwd: tmp }); - expect(branches.branches.some((branch) => branch.name === "feature/old-name")).toBe(false); - const current = branches.branches.find((branch) => branch.current); - expect(current?.name).toBe("feature/new-name"); - }), - ); - - it.effect("returns success without git invocation when old/new names match", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - const current = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( - (b) => b.current, - )!; - - const renamed = yield* (yield* GitCore).renameBranch({ - cwd: tmp, - oldBranch: current.name, - newBranch: current.name, - }); - - expect(renamed.branch).toBe(current.name); - }), - ); - - it.effect("appends numeric suffix when target branch already exists", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "t3code/feat/session" }); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "t3code/tmp-working" }); - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "t3code/tmp-working" }); - - const renamed = yield* (yield* GitCore).renameBranch({ - cwd: tmp, - oldBranch: "t3code/tmp-working", - newBranch: "t3code/feat/session", - }); - - expect(renamed.branch).toBe("t3code/feat/session-1"); - const branches = yield* (yield* GitCore).listBranches({ cwd: tmp }); - expect(branches.branches.some((branch) => branch.name === "t3code/feat/session")).toBe( - true, - ); - expect(branches.branches.some((branch) => branch.name === "t3code/feat/session-1")).toBe( - true, - ); - const current = branches.branches.find((branch) => branch.current); - expect(current?.name).toBe("t3code/feat/session-1"); - }), - ); - - it.effect("increments suffix until it finds an available branch name", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "t3code/feat/session" }); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "t3code/feat/session-1" }); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "t3code/tmp-working" }); - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "t3code/tmp-working" }); - - const renamed = yield* (yield* GitCore).renameBranch({ - cwd: tmp, - oldBranch: "t3code/tmp-working", - newBranch: "t3code/feat/session", - }); - - expect(renamed.branch).toBe("t3code/feat/session-2"); - }), - ); - - it.effect("uses '--' separator for branch rename arguments", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature/old-name" }); - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "feature/old-name" }); - - const realGitCore = yield* GitCore; - let renameArgs: ReadonlyArray | null = null; - const core = yield* makeIsolatedGitCore((input) => { - if (input.args[0] === "branch" && input.args[1] === "-m") { - renameArgs = [...input.args]; - } - return realGitCore.execute(input); - }); - - const renamed = yield* core.renameBranch({ - cwd: tmp, - oldBranch: "feature/old-name", - newBranch: "feature/new-name", - }); - - expect(renamed.branch).toBe("feature/new-name"); - expect(renameArgs).toEqual(["branch", "-m", "--", "feature/old-name", "feature/new-name"]); - }), - ); - }); - - // ── createGitWorktree + removeGitWorktree ── - - describe("createGitWorktree", () => { - it.effect("creates a worktree with a new branch from the base branch", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - - const wtPath = path.join(tmp, "worktree-out"); - const currentBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( - (b) => b.current, - )!.name; - - const result = yield* (yield* GitCore).createWorktree({ - cwd: tmp, - branch: currentBranch, - newBranch: "wt-branch", - path: wtPath, - }); - - expect(result.worktree.path).toBe(wtPath); - expect(result.worktree.branch).toBe("wt-branch"); - expect(existsSync(wtPath)).toBe(true); - expect(existsSync(path.join(wtPath, "README.md"))).toBe(true); - - // Clean up worktree before tmp dir disposal - yield* (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath }); - }), - ); - - it.effect("worktree has the new branch checked out", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - - const wtPath = path.join(tmp, "wt-check-dir"); - const currentBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( - (b) => b.current, - )!.name; - - yield* (yield* GitCore).createWorktree({ - cwd: tmp, - branch: currentBranch, - newBranch: "wt-check", - path: wtPath, - }); - - // Verify the worktree is on the new branch - const branchOutput = yield* git(wtPath, ["branch", "--show-current"]); - expect(branchOutput).toBe("wt-check"); - - yield* (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath }); - }), - ); - - it.effect("creates a worktree for an existing branch when newBranch is omitted", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature/existing-worktree" }); - - const wtPath = path.join(tmp, "wt-existing"); - const result = yield* (yield* GitCore).createWorktree({ - cwd: tmp, - branch: "feature/existing-worktree", - path: wtPath, - }); - - expect(result.worktree.path).toBe(wtPath); - expect(result.worktree.branch).toBe("feature/existing-worktree"); - const branchOutput = yield* git(wtPath, ["branch", "--show-current"]); - expect(branchOutput).toBe("feature/existing-worktree"); - - yield* (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath }); - }), - ); - - it.effect("throws when new branch name already exists", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "existing" }); - - const wtPath = path.join(tmp, "wt-conflict"); - const currentBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( - (b) => b.current, - )!.name; - - const result = yield* Effect.result( - (yield* GitCore).createWorktree({ - cwd: tmp, - branch: currentBranch, - newBranch: "existing", - path: wtPath, - }), - ); - expect(result._tag).toBe("Failure"); - }), - ); - - it.effect("listGitBranches from worktree cwd reports worktree branch as current", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - - const wtPath = path.join(tmp, "wt-list-dir"); - const mainBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( - (b) => b.current, - )!.name; - - yield* (yield* GitCore).createWorktree({ - cwd: tmp, - branch: mainBranch, - newBranch: "wt-list", - path: wtPath, - }); - - // listGitBranches from the worktree should show wt-list as current - const wtBranches = yield* (yield* GitCore).listBranches({ cwd: wtPath }); - expect(wtBranches.isRepo).toBe(true); - const wtCurrent = wtBranches.branches.find((b) => b.current); - expect(wtCurrent!.name).toBe("wt-list"); - - // Main repo should still show the original branch as current - const mainBranches = yield* (yield* GitCore).listBranches({ cwd: tmp }); - const mainCurrent = mainBranches.branches.find((b) => b.current); - expect(mainCurrent!.name).toBe(mainBranch); - - yield* (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath }); - }), - ); - - it.effect("removeGitWorktree cleans up the worktree", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - - const wtPath = path.join(tmp, "wt-remove-dir"); - const currentBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( - (b) => b.current, - )!.name; - - yield* (yield* GitCore).createWorktree({ - cwd: tmp, - branch: currentBranch, - newBranch: "wt-remove", - path: wtPath, - }); - expect(existsSync(wtPath)).toBe(true); - - yield* (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath }); - expect(existsSync(wtPath)).toBe(false); - }), - ); - - it.effect("removeGitWorktree force removes a dirty worktree", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - - const wtPath = path.join(tmp, "wt-dirty-dir"); - const currentBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( - (b) => b.current, - )!.name; - - yield* (yield* GitCore).createWorktree({ - cwd: tmp, - branch: currentBranch, - newBranch: "wt-dirty", - path: wtPath, - }); - expect(existsSync(wtPath)).toBe(true); - - yield* writeTextFile(path.join(wtPath, "README.md"), "dirty change\n"); - - const failedRemove = yield* Effect.result( - (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath }), - ); - expect(failedRemove._tag).toBe("Failure"); - expect(existsSync(wtPath)).toBe(true); - - yield* (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath, force: true }); - expect(existsSync(wtPath)).toBe(false); - }), - ); - }); - - // ── Full flow: local branch checkout ── - - describe("full flow: local branch checkout", () => { - it.effect("init → commit → create branch → checkout → verify current", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature-login" }); - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "feature-login" }); - - const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); - const current = result.branches.find((b) => b.current); - expect(current!.name).toBe("feature-login"); - }), - ); - }); - - // ── Full flow: worktree creation from base branch ── - - describe("full flow: worktree creation", () => { - it.effect("creates worktree with new branch from current branch", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - - const currentBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( - (b) => b.current, - )!.name; - - const wtPath = path.join(tmp, "my-worktree"); - const result = yield* (yield* GitCore).createWorktree({ - cwd: tmp, - branch: currentBranch, - newBranch: "feature-wt", - path: wtPath, - }); - - // Worktree exists - expect(existsSync(result.worktree.path)).toBe(true); - - // Main repo still on original branch - const mainBranches = yield* (yield* GitCore).listBranches({ cwd: tmp }); - const mainCurrent = mainBranches.branches.find((b) => b.current); - expect(mainCurrent!.name).toBe(currentBranch); - - // Worktree is on the new branch - const wtBranch = yield* git(wtPath, ["branch", "--show-current"]); - expect(wtBranch).toBe("feature-wt"); - - yield* (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath }); - }), - ); - }); - - describe("fetchPullRequestBranch", () => { - it.effect("fetches a GitHub pull request ref into a local branch without checkout", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - const { initialBranch } = yield* initRepoWithCommit(tmp); - const remoteDir = yield* makeTmpDir("git-remote-"); - yield* git(remoteDir, ["init", "--bare"]); - yield* git(tmp, ["remote", "add", "origin", remoteDir]); - yield* git(tmp, ["push", "-u", "origin", initialBranch]); - yield* git(tmp, ["checkout", "-b", "feature/pr-fetch"]); - yield* writeTextFile(path.join(tmp, "pr-fetch.txt"), "fetch me\n"); - yield* git(tmp, ["add", "pr-fetch.txt"]); - yield* git(tmp, ["commit", "-m", "Add PR fetch branch"]); - yield* git(tmp, ["push", "-u", "origin", "feature/pr-fetch"]); - yield* git(tmp, ["push", "origin", "HEAD:refs/pull/55/head"]); - yield* git(tmp, ["checkout", initialBranch]); - - yield* (yield* GitCore).fetchPullRequestBranch({ - cwd: tmp, - prNumber: 55, - branch: "feature/pr-fetch", - }); - - const localBranches = yield* git(tmp, ["branch", "--list", "feature/pr-fetch"]); - expect(localBranches).toContain("feature/pr-fetch"); - const currentBranch = yield* git(tmp, ["branch", "--show-current"]); - expect(currentBranch).toBe(initialBranch); - }), - ); - }); - - // ── Full flow: thread switching simulation ── - - describe("full flow: thread switching (checkout toggling)", () => { - it.effect("checkout a → checkout b → checkout a → current matches", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "branch-a" }); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "branch-b" }); - - // Simulate switching to thread A's branch - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "branch-a" }); - let branches = yield* (yield* GitCore).listBranches({ cwd: tmp }); - expect(branches.branches.find((b) => b.current)!.name).toBe("branch-a"); - - // Simulate switching to thread B's branch - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "branch-b" }); - branches = yield* (yield* GitCore).listBranches({ cwd: tmp }); - expect(branches.branches.find((b) => b.current)!.name).toBe("branch-b"); - - // Switch back to thread A - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "branch-a" }); - branches = yield* (yield* GitCore).listBranches({ cwd: tmp }); - expect(branches.branches.find((b) => b.current)!.name).toBe("branch-a"); - }), - ); - }); - - // ── Full flow: checkout conflict ── - - describe("full flow: checkout conflict", () => { - it.effect("uncommitted changes prevent checkout to a diverged branch", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "diverged" }); - - // Make diverged branch have different file content - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "diverged" }); - yield* writeTextFile(path.join(tmp, "README.md"), "diverged content\n"); - yield* git(tmp, ["add", "."]); - yield* git(tmp, ["commit", "-m", "diverge"]); - - // Actually, let's just get back to the initial branch explicitly - const allBranches = yield* (yield* GitCore).listBranches({ cwd: tmp }); - const initialBranch = allBranches.branches.find((b) => b.name !== "diverged")!.name; - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: initialBranch }); - - // Make local uncommitted changes to the same file - yield* writeTextFile(path.join(tmp, "README.md"), "local uncommitted\n"); - - // Attempt checkout should fail - const failedCheckout = yield* Effect.result( - (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "diverged" }), - ); - expect(failedCheckout._tag).toBe("Failure"); - - // Current branch should still be the initial one - const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); - expect(result.branches.find((b) => b.current)!.name).toBe(initialBranch); - }), - ); - }); - - describe("GitCore", () => { - it.effect("supports branch lifecycle operations through the service API", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - const core = yield* GitCore; - - yield* core.initRepo({ cwd: tmp }); - yield* git(tmp, ["config", "user.email", "test@test.com"]); - yield* git(tmp, ["config", "user.name", "Test"]); - yield* writeTextFile(path.join(tmp, "README.md"), "# test\n"); - yield* git(tmp, ["add", "."]); - yield* git(tmp, ["commit", "-m", "initial commit"]); - - yield* core.createBranch({ cwd: tmp, branch: "feature/service-api" }); - yield* core.checkoutBranch({ cwd: tmp, branch: "feature/service-api" }); - const branches = yield* core.listBranches({ cwd: tmp }); - - expect(branches.isRepo).toBe(true); - expect( - branches.branches.find((branch: { current: boolean; name: string }) => branch.current) - ?.name, - ).toBe("feature/service-api"); - }), - ); - - it.effect( - "reuses an existing remote when the target URL only differs by a trailing slash after .git", - () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - const core = yield* GitCore; - - yield* git(tmp, ["remote", "add", "origin", "git@github.com:pingdotgg/t3code.git"]); - - const remoteName = yield* core.ensureRemote({ - cwd: tmp, - preferredName: "origin", - url: "git@github.com:pingdotgg/t3code.git/", - }); - - expect(remoteName).toBe("origin"); - expect((yield* git(tmp, ["remote"])).split("\n").filter(Boolean)).toEqual(["origin"]); - }), - ); - - it.effect("reports status details and dirty state", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - const core = yield* GitCore; - - const clean = yield* core.status({ cwd: tmp }); - expect(clean.hasWorkingTreeChanges).toBe(false); - expect(clean.branch).toBeTruthy(); - - yield* writeTextFile(path.join(tmp, "README.md"), "updated\n"); - const dirty = yield* core.statusDetails(tmp); - expect(dirty.hasWorkingTreeChanges).toBe(true); - }), - ); - - it.effect("returns a non-repo status for deleted directories", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - const deletedDir = path.join(tmp, "deleted-repo"); - yield* makeDirectory(deletedDir); - yield* removePath(deletedDir); - const core = yield* GitCore; - - const status = yield* core.statusDetails(deletedDir); - const localStatus = yield* core.statusDetailsLocal(deletedDir); - - expect(status).toEqual({ - isRepo: false, - hasOriginRemote: false, - isDefaultBranch: false, - branch: null, - upstreamRef: null, - hasWorkingTreeChanges: false, - workingTree: { - files: [], - insertions: 0, - deletions: 0, - }, - hasUpstream: false, - aheadCount: 0, - behindCount: 0, - }); - expect(localStatus).toEqual(status); - }), - ); - - it.effect("computes ahead count against base branch when no upstream is configured", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - const core = yield* GitCore; - - yield* core.createBranch({ cwd: tmp, branch: "feature/no-upstream-ahead" }); - yield* core.checkoutBranch({ cwd: tmp, branch: "feature/no-upstream-ahead" }); - yield* writeTextFile(path.join(tmp, "feature.txt"), "ahead of base\n"); - yield* git(tmp, ["add", "feature.txt"]); - yield* git(tmp, ["commit", "-m", "feature commit"]); - - const details = yield* core.statusDetails(tmp); - expect(details.branch).toBe("feature/no-upstream-ahead"); - expect(details.hasUpstream).toBe(false); - expect(details.aheadCount).toBe(1); - expect(details.behindCount).toBe(0); - }), - ); - - it.effect( - "computes ahead count against origin/default when local default branch is missing", - () => - Effect.gen(function* () { - const remote = yield* makeTmpDir(); - const source = yield* makeTmpDir(); - yield* git(remote, ["init", "--bare"]); - - yield* initRepoWithCommit(source); - const initialBranch = (yield* (yield* GitCore).listBranches({ - cwd: source, - })).branches.find((branch) => branch.current)!.name; - yield* git(source, ["remote", "add", "origin", remote]); - yield* git(source, ["push", "-u", "origin", initialBranch]); - yield* git(source, ["checkout", "-b", "feature/remote-base-only"]); - yield* writeTextFile( - path.join(source, "feature.txt"), - `ahead of origin/${initialBranch}\n`, - ); - yield* git(source, ["add", "feature.txt"]); - yield* git(source, ["commit", "-m", "feature commit"]); - yield* git(source, ["branch", "-D", initialBranch]); - - const core = yield* GitCore; - const details = yield* core.statusDetails(source); - expect(details.branch).toBe("feature/remote-base-only"); - expect(details.hasUpstream).toBe(false); - expect(details.aheadCount).toBe(1); - expect(details.behindCount).toBe(0); - }), - ); - - it.effect( - "computes ahead count against a non-origin remote-prefixed gh-merge-base candidate", - () => - Effect.gen(function* () { - const remote = yield* makeTmpDir(); - const source = yield* makeTmpDir(); - const remoteName = "fork-seed"; - yield* git(remote, ["init", "--bare"]); - - yield* initRepoWithCommit(source); - const initialBranch = (yield* (yield* GitCore).listBranches({ - cwd: source, - })).branches.find((branch) => branch.current)!.name; - yield* git(source, ["remote", "add", remoteName, remote]); - yield* git(source, ["push", "-u", remoteName, initialBranch]); - yield* git(source, ["checkout", "-b", "feature/non-origin-merge-base"]); - yield* git(source, [ - "config", - "branch.feature/non-origin-merge-base.gh-merge-base", - `${remoteName}/${initialBranch}`, - ]); - yield* writeTextFile( - path.join(source, "feature.txt"), - `ahead of ${remoteName}/${initialBranch}\n`, - ); - yield* git(source, ["add", "feature.txt"]); - yield* git(source, ["commit", "-m", "feature commit"]); - yield* git(source, ["branch", "-D", initialBranch]); - - const core = yield* GitCore; - const details = yield* core.statusDetails(source); - expect(details.branch).toBe("feature/non-origin-merge-base"); - expect(details.hasUpstream).toBe(false); - expect(details.aheadCount).toBe(1); - expect(details.behindCount).toBe(0); - }), - ); - - it.effect("skips push when no upstream is configured and branch is not ahead of base", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - const core = yield* GitCore; - - yield* core.createBranch({ cwd: tmp, branch: "feature/no-upstream-no-ahead" }); - yield* core.checkoutBranch({ cwd: tmp, branch: "feature/no-upstream-no-ahead" }); - - const pushed = yield* core.pushCurrentBranch(tmp, null); - expect(pushed.status).toBe("skipped_up_to_date"); - expect(pushed.branch).toBe("feature/no-upstream-no-ahead"); - expect(pushed.setUpstream).toBeUndefined(); - }), - ); - - it.effect("pushes with upstream setup when no comparable base branch exists", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - const remote = yield* makeTmpDir(); - yield* git(tmp, ["init", "--initial-branch=trunk"]); - yield* git(tmp, ["config", "user.email", "test@test.com"]); - yield* git(tmp, ["config", "user.name", "Test"]); - yield* writeTextFile(path.join(tmp, "README.md"), "hello\n"); - yield* git(tmp, ["add", "README.md"]); - yield* git(tmp, ["commit", "-m", "initial"]); - yield* git(remote, ["init", "--bare"]); - yield* git(tmp, ["remote", "add", "origin", remote]); - yield* git(tmp, ["checkout", "-b", "feature/no-base"]); - - const core = yield* GitCore; - const pushed = yield* core.pushCurrentBranch(tmp, null); - expect(pushed.status).toBe("pushed"); - expect(pushed.setUpstream).toBe(true); - expect(pushed.upstreamBranch).toBe("origin/feature/no-base"); - expect(yield* git(tmp, ["rev-parse", "--abbrev-ref", "@{upstream}"])).toBe( - "origin/feature/no-base", - ); - }), - ); - - it.effect("pushes with upstream setup to the only configured non-origin remote", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - const remote = yield* makeTmpDir(); - yield* git(tmp, ["init", "--initial-branch=main"]); - yield* git(tmp, ["config", "user.email", "test@test.com"]); - yield* git(tmp, ["config", "user.name", "Test"]); - yield* writeTextFile(path.join(tmp, "README.md"), "hello\n"); - yield* git(tmp, ["add", "README.md"]); - yield* git(tmp, ["commit", "-m", "initial"]); - yield* git(remote, ["init", "--bare"]); - yield* git(tmp, ["remote", "add", "fork", remote]); - yield* git(tmp, ["checkout", "-b", "feature/fork-only"]); - - const core = yield* GitCore; - const pushed = yield* core.pushCurrentBranch(tmp, null); - expect(pushed.status).toBe("pushed"); - expect(pushed.setUpstream).toBe(true); - expect(pushed.upstreamBranch).toBe("fork/feature/fork-only"); - expect(yield* git(tmp, ["rev-parse", "--abbrev-ref", "@{upstream}"])).toBe( - "fork/feature/fork-only", - ); - }), - ); - - it.effect( - "pushes with upstream setup when comparable base exists but remote branch is missing", - () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - const remote = yield* makeTmpDir(); - yield* git(remote, ["init", "--bare"]); - - yield* initRepoWithCommit(tmp); - const initialBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( - (branch) => branch.current, - )!.name; - yield* git(tmp, ["remote", "add", "origin", remote]); - yield* git(tmp, ["push", "-u", "origin", initialBranch]); - - yield* writeTextFile(path.join(tmp, "default-ahead.txt"), "ahead on default\n"); - yield* git(tmp, ["add", "default-ahead.txt"]); - yield* git(tmp, ["commit", "-m", "default ahead"]); - - const featureBranch = "feature/publish-no-upstream"; - yield* git(tmp, ["checkout", "-b", featureBranch]); - - const core = yield* GitCore; - const pushed = yield* core.pushCurrentBranch(tmp, null); - expect(pushed.status).toBe("pushed"); - expect(pushed.setUpstream).toBe(true); - expect(pushed.upstreamBranch).toBe(`origin/${featureBranch}`); - expect(yield* git(tmp, ["rev-parse", "--abbrev-ref", "@{upstream}"])).toBe( - `origin/${featureBranch}`, - ); - expect(yield* git(tmp, ["ls-remote", "--heads", "origin", featureBranch])).toContain( - featureBranch, - ); - }), - ); - - it.effect("prefers branch pushRemote over origin when setting upstream", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - const origin = yield* makeTmpDir(); - const fork = yield* makeTmpDir(); - yield* git(origin, ["init", "--bare"]); - yield* git(fork, ["init", "--bare"]); - - yield* initRepoWithCommit(tmp); - const initialBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( - (branch) => branch.current, - )!.name; - yield* git(tmp, ["remote", "add", "origin", origin]); - yield* git(tmp, ["remote", "add", "fork", fork]); - yield* git(tmp, ["push", "-u", "origin", initialBranch]); - - const featureBranch = "feature/push-remote"; - yield* git(tmp, ["checkout", "-b", featureBranch]); - yield* git(tmp, ["config", `branch.${featureBranch}.pushRemote`, "fork"]); - yield* writeTextFile(path.join(tmp, "feature.txt"), "push to fork\n"); - yield* git(tmp, ["add", "feature.txt"]); - yield* git(tmp, ["commit", "-m", "feature commit"]); - - const core = yield* GitCore; - const pushed = yield* core.pushCurrentBranch(tmp, null); - expect(pushed.status).toBe("pushed"); - expect(pushed.setUpstream).toBe(true); - expect(pushed.upstreamBranch).toBe(`fork/${featureBranch}`); - expect(yield* git(tmp, ["rev-parse", "--abbrev-ref", "@{upstream}"])).toBe( - `fork/${featureBranch}`, - ); - expect(yield* git(tmp, ["ls-remote", "--heads", "fork", featureBranch])).toContain( - featureBranch, - ); - }), - ); - - it.effect( - "pushes renamed PR worktree branches to their tracked upstream branch even when push.default is current", - () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - const fork = yield* makeTmpDir(); - yield* git(fork, ["init", "--bare"]); - - const { initialBranch } = yield* initRepoWithCommit(tmp); - yield* git(tmp, ["remote", "add", "jasonLaster", fork]); - yield* git(tmp, ["checkout", "-b", "statemachine"]); - yield* writeTextFile(path.join(tmp, "fork.txt"), "fork branch\n"); - yield* git(tmp, ["add", "fork.txt"]); - yield* git(tmp, ["commit", "-m", "fork branch"]); - yield* git(tmp, ["push", "-u", "jasonLaster", "statemachine"]); - yield* git(tmp, ["checkout", initialBranch]); - yield* git(tmp, ["branch", "-D", "statemachine"]); - yield* git(tmp, [ - "checkout", - "-b", - "t3code/pr-488/statemachine", - "--track", - "jasonLaster/statemachine", - ]); - yield* git(tmp, ["config", "push.default", "current"]); - yield* writeTextFile(path.join(tmp, "fork.txt"), "updated fork branch\n"); - yield* git(tmp, ["add", "fork.txt"]); - yield* git(tmp, ["commit", "-m", "update reviewed PR branch"]); - - const core = yield* GitCore; - const pushed = yield* core.pushCurrentBranch(tmp, null); - - expect(pushed.status).toBe("pushed"); - expect(pushed.setUpstream).toBe(false); - expect(pushed.upstreamBranch).toBe("jasonLaster/statemachine"); - expect(yield* git(tmp, ["rev-parse", "--abbrev-ref", "@{upstream}"])).toBe( - "jasonLaster/statemachine", - ); - expect( - yield* git(tmp, ["ls-remote", "--heads", "jasonLaster", "statemachine"]), - ).toContain("statemachine"); - expect( - yield* git(tmp, ["ls-remote", "--heads", "jasonLaster", "t3code/pr-488/statemachine"]), - ).toBe(""); - }), - ); - - it.effect("pushes to the tracked upstream when the remote name contains slashes", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - const remote = yield* makeTmpDir(); - const prefixRemote = yield* makeTmpDir(); - const prefixFetchNamespace = "prefix-my-org"; - const prefixRemoteName = "my-org"; - const remoteName = "my-org/upstream"; - const featureBranch = "feature/slash-remote-push"; - yield* git(remote, ["init", "--bare"]); - yield* git(prefixRemote, ["init", "--bare"]); - - const { initialBranch } = yield* initRepoWithCommit(tmp); - yield* configureRemote(tmp, prefixRemoteName, prefixRemote, prefixFetchNamespace); - yield* configureRemote(tmp, remoteName, remote, remoteName); - yield* git(tmp, ["push", "-u", remoteName, initialBranch]); - - yield* git(tmp, ["checkout", "-b", featureBranch]); - yield* writeTextFile(path.join(tmp, "feature.txt"), "first revision\n"); - yield* git(tmp, ["add", "feature.txt"]); - yield* git(tmp, ["commit", "-m", "feature base"]); - yield* git(tmp, ["push", "-u", remoteName, featureBranch]); - - yield* writeTextFile(path.join(tmp, "feature.txt"), "second revision\n"); - yield* git(tmp, ["add", "feature.txt"]); - yield* git(tmp, ["commit", "-m", "feature update"]); - - const core = yield* GitCore; - const pushed = yield* core.pushCurrentBranch(tmp, null); - expect(pushed.status).toBe("pushed"); - expect(pushed.setUpstream).toBe(false); - expect(pushed.upstreamBranch).toBe(`${remoteName}/${featureBranch}`); - expect(yield* git(tmp, ["rev-parse", "--abbrev-ref", "@{upstream}"])).toBe( - `${remoteName}/${featureBranch}`, - ); - expect(yield* git(tmp, ["ls-remote", "--heads", remoteName, featureBranch])).toContain( - featureBranch, - ); - }), - ); - - it.effect("includes command context when worktree removal fails", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - const core = yield* GitCore; - const missingWorktreePath = path.join(tmp, "missing-worktree"); - - const removeResult = yield* Effect.result( - core.removeWorktree({ cwd: tmp, path: missingWorktreePath }), - ); - expect(removeResult._tag).toBe("Failure"); - if (removeResult._tag !== "Failure") { - return; - } - const message = removeResult.failure.message; - expect(message).toContain("git worktree remove"); - expect(message).toContain(`cwd: ${tmp}`); - expect(message).toContain(missingWorktreePath); - }), - ); - - it.effect( - "refreshes upstream before statusDetails so behind count reflects remote updates", - () => - Effect.gen(function* () { - const remote = yield* makeTmpDir(); - const source = yield* makeTmpDir(); - const clone = yield* makeTmpDir(); - yield* git(remote, ["init", "--bare"]); - - yield* initRepoWithCommit(source); - const initialBranch = (yield* (yield* GitCore).listBranches({ - cwd: source, - })).branches.find((branch) => branch.current)!.name; - yield* git(source, ["remote", "add", "origin", remote]); - yield* git(source, ["push", "-u", "origin", initialBranch]); - - yield* git(clone, ["clone", remote, "."]); - yield* git(clone, ["config", "user.email", "test@test.com"]); - yield* git(clone, ["config", "user.name", "Test"]); - yield* git(clone, [ - "checkout", - "-B", - initialBranch, - "--track", - `origin/${initialBranch}`, - ]); - yield* writeTextFile(path.join(clone, "CHANGELOG.md"), "remote change\n"); - yield* git(clone, ["add", "CHANGELOG.md"]); - yield* git(clone, ["commit", "-m", "remote update"]); - yield* git(clone, ["push", "origin", initialBranch]); - - const core = yield* GitCore; - const details = yield* core.statusDetails(source); - expect(details.branch).toBe(initialBranch); - expect(details.aheadCount).toBe(0); - expect(details.behindCount).toBe(1); - }), - ); - - it.effect("prepares commit context by auto-staging and creates commit", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - const core = yield* GitCore; - - yield* writeTextFile(path.join(tmp, "README.md"), "new content\n"); - const context = yield* core.prepareCommitContext(tmp); - expect(context).not.toBeNull(); - expect(context!.stagedSummary.length).toBeGreaterThan(0); - expect(context!.stagedPatch.length).toBeGreaterThan(0); - - const created = yield* core.commit(tmp, "Add README update", "- include updated content"); - expect(created.commitSha.length).toBeGreaterThan(0); - expect(yield* git(tmp, ["log", "-1", "--pretty=%s"])).toBe("Add README update"); - }), - ); - - it.effect("prepareCommitContext stages only selected files when filePaths provided", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - const core = yield* GitCore; - - yield* writeTextFile(path.join(tmp, "a.txt"), "file a\n"); - yield* writeTextFile(path.join(tmp, "b.txt"), "file b\n"); - - const context = yield* core.prepareCommitContext(tmp, ["a.txt"]); - expect(context).not.toBeNull(); - expect(context!.stagedSummary).toContain("a.txt"); - expect(context!.stagedSummary).not.toContain("b.txt"); - - yield* core.commit(tmp, "Add only a.txt", ""); - - // b.txt should still be untracked after commit - const statusAfter = yield* git(tmp, ["status", "--porcelain"]); - expect(statusAfter).toContain("b.txt"); - expect(statusAfter).not.toContain("a.txt"); - }), - ); - - it.effect("prepareCommitContext stages everything when filePaths is undefined", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - const core = yield* GitCore; - - yield* writeTextFile(path.join(tmp, "a.txt"), "file a\n"); - yield* writeTextFile(path.join(tmp, "b.txt"), "file b\n"); - - const context = yield* core.prepareCommitContext(tmp); - expect(context).not.toBeNull(); - expect(context!.stagedSummary).toContain("a.txt"); - expect(context!.stagedSummary).toContain("b.txt"); - }), - ); - - it.effect("prepareCommitContext truncates oversized staged patches instead of failing", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - const core = yield* GitCore; - - yield* writeTextFile(path.join(tmp, "README.md"), buildLargeText()); - - const context = yield* core.prepareCommitContext(tmp); - expect(context).not.toBeNull(); - expect(context!.stagedSummary).toContain("README.md"); - expect(context!.stagedPatch).toContain("[truncated]"); - }), - ); - - it.effect("readRangeContext truncates oversized diff patches instead of failing", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - const { initialBranch } = yield* initRepoWithCommit(tmp); - const core = yield* GitCore; - - yield* core.createBranch({ cwd: tmp, branch: "feature/large-range-context" }); - yield* core.checkoutBranch({ cwd: tmp, branch: "feature/large-range-context" }); - yield* writeTextFile(path.join(tmp, "large.txt"), buildLargeText()); - yield* git(tmp, ["add", "large.txt"]); - yield* git(tmp, ["commit", "-m", "Add large range context"]); - - const rangeContext = yield* core.readRangeContext(tmp, initialBranch); - expect(rangeContext.commitSummary).toContain("Add large range context"); - expect(rangeContext.diffSummary).toContain("large.txt"); - expect(rangeContext.diffPatch).toContain("[truncated]"); - }), - ); - - it.effect("pushes with upstream setup and then skips when up to date", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - const remote = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - yield* git(remote, ["init", "--bare"]); - yield* git(tmp, ["remote", "add", "origin", remote]); - yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature/core-push" }); - yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "feature/core-push" }); - - yield* writeTextFile(path.join(tmp, "feature.txt"), "push me\n"); - const core = yield* GitCore; - const context = yield* core.prepareCommitContext(tmp); - expect(context).not.toBeNull(); - yield* core.commit(tmp, "Add feature file", ""); - - const pushed = yield* core.pushCurrentBranch(tmp, null); - expect(pushed.status).toBe("pushed"); - expect(pushed.setUpstream).toBe(true); - expect(yield* git(tmp, ["rev-parse", "--abbrev-ref", "@{upstream}"])).toBe( - "origin/feature/core-push", - ); - - const skipped = yield* core.pushCurrentBranch(tmp, null); - expect(skipped.status).toBe("skipped_up_to_date"); - }), - ); - - it.effect("pulls behind branch and then reports up-to-date", () => - Effect.gen(function* () { - const remote = yield* makeTmpDir(); - const source = yield* makeTmpDir(); - const clone = yield* makeTmpDir(); - yield* git(remote, ["init", "--bare"]); - - yield* initRepoWithCommit(source); - const initialBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find( - (branch) => branch.current, - )!.name; - yield* git(source, ["remote", "add", "origin", remote]); - yield* git(source, ["push", "-u", "origin", initialBranch]); - - yield* git(clone, ["clone", remote, "."]); - yield* git(clone, ["config", "user.email", "test@test.com"]); - yield* git(clone, ["config", "user.name", "Test"]); - yield* writeTextFile(path.join(clone, "CHANGELOG.md"), "remote change\n"); - yield* git(clone, ["add", "CHANGELOG.md"]); - yield* git(clone, ["commit", "-m", "remote update"]); - yield* git(clone, ["push", "origin", initialBranch]); - - const core = yield* GitCore; - const pulled = yield* core.pullCurrentBranch(source); - expect(pulled.status).toBe("pulled"); - expect((yield* core.statusDetails(source)).behindCount).toBe(0); - - const skipped = yield* core.pullCurrentBranch(source); - expect(skipped.status).toBe("skipped_up_to_date"); - }), - ); - - it.effect("top-level pullGitBranch rejects when no upstream exists", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - const result = yield* Effect.result((yield* GitCore).pullCurrentBranch(tmp)); - expect(result._tag).toBe("Failure"); - if (result._tag === "Failure") { - expect(result.failure.message.toLowerCase()).toContain("no upstream"); - } - }), - ); - - it.effect("lists branches when recency lookup fails", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - const realGitCore = yield* GitCore; - let didFailRecency = false; - const core = yield* makeIsolatedGitCore((input) => { - if (!didFailRecency && input.args[0] === "for-each-ref") { - didFailRecency = true; - return Effect.fail( - new GitCommandError({ - operation: "git.test.listBranchesRecency", - command: `git ${input.args.join(" ")}`, - cwd: input.cwd, - detail: "timeout", - }), - ); - } - return realGitCore.execute(input); - }); - - const result = yield* core.listBranches({ cwd: tmp }); - - expect(result.isRepo).toBe(true); - expect(result.branches.length).toBeGreaterThan(0); - expect(result.branches[0]?.current).toBe(true); - expect(didFailRecency).toBe(true); - }), - ); - - it.effect("falls back to empty remote branch data when remote lookups fail", () => - Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - const remote = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); - yield* git(remote, ["init", "--bare"]); - yield* git(tmp, ["remote", "add", "origin", remote]); - - const realGitCore = yield* GitCore; - let didFailRemoteBranches = false; - let didFailRemoteNames = false; - const core = yield* makeIsolatedGitCore((input) => { - if (input.args.join(" ") === "branch --no-color --no-column --remotes") { - didFailRemoteBranches = true; - return Effect.fail( - new GitCommandError({ - operation: "git.test.listBranchesRemoteBranches", - command: `git ${input.args.join(" ")}`, - cwd: input.cwd, - detail: "remote unavailable", - }), - ); - } - if (input.args.join(" ") === "remote") { - didFailRemoteNames = true; - return Effect.fail( - new GitCommandError({ - operation: "git.test.listBranchesRemoteNames", - command: `git ${input.args.join(" ")}`, - cwd: input.cwd, - detail: "remote unavailable", - }), - ); - } - return realGitCore.execute(input); - }); - - const result = yield* core.listBranches({ cwd: tmp }); - - expect(result.isRepo).toBe(true); - expect(result.branches.length).toBeGreaterThan(0); - expect(result.branches.every((branch) => !branch.isRemote)).toBe(true); - expect(didFailRemoteBranches).toBe(true); - expect(didFailRemoteNames).toBe(true); - }), - ); - }); -}); diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/git/Layers/GitCore.ts deleted file mode 100644 index 3e9df316f1e9e6dc1ef173a51ed50550f002441c..0000000000000000000000000000000000000000 --- a/apps/server/src/git/Layers/GitCore.ts +++ /dev/null @@ -1,2206 +0,0 @@ -import { - Cache, - Data, - Duration, - Effect, - Exit, - FileSystem, - Layer, - Option, - Path, - PlatformError, - Ref, - Result, - Schema, - Scope, - Semaphore, - Stream, -} from "effect"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; - -import { GitCommandError, type GitBranch } from "@t3tools/contracts"; -import { dedupeRemoteBranchesWithLocalMatches } from "@t3tools/shared/git"; -import { compactTraceAttributes } from "../../observability/Attributes.ts"; -import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../../observability/Metrics.ts"; -import { - GitCore, - type ExecuteGitProgress, - type GitCommitOptions, - type GitCoreShape, - type GitStatusDetails, - type ExecuteGitInput, - type ExecuteGitResult, -} from "../Services/GitCore.ts"; -import { - parseRemoteNames, - parseRemoteNamesInGitOrder, - parseRemoteRefWithRemoteNames, -} from "../remoteRefs.ts"; -import { ServerConfig } from "../../config.ts"; -import { decodeJsonResult } from "@t3tools/shared/schemaJson"; - -const DEFAULT_TIMEOUT_MS = 30_000; -const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; -const OUTPUT_TRUNCATED_MARKER = "\n\n[truncated]"; -const PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES = 49_000; -const RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES = 19_000; -const RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES = 19_000; -const RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES = 59_000; -const WORKSPACE_FILES_MAX_OUTPUT_BYTES = 16 * 1024 * 1024; -const GIT_CHECK_IGNORE_MAX_STDIN_BYTES = 256 * 1024; -const WORKSPACE_GIT_HARDENED_CONFIG_ARGS = [ - "-c", - "core.fsmonitor=false", - "-c", - "core.untrackedCache=false", -] as const; -const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15); -const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5); -const STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN = Duration.seconds(5); -const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048; -const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const; -const GIT_LIST_BRANCHES_DEFAULT_LIMIT = 100; -const NON_REPOSITORY_STATUS_DETAILS = Object.freeze({ - isRepo: false, - hasOriginRemote: false, - isDefaultBranch: false, - branch: null, - upstreamRef: null, - hasWorkingTreeChanges: false, - workingTree: { files: [], insertions: 0, deletions: 0 }, - hasUpstream: false, - aheadCount: 0, - behindCount: 0, -}); - -type TraceTailState = { - processedChars: number; - remainder: string; -}; - -class StatusRemoteRefreshCacheKey extends Data.Class<{ - gitCommonDir: string; - remoteName: string; -}> {} - -interface ExecuteGitOptions { - stdin?: string | undefined; - timeoutMs?: number | undefined; - allowNonZeroExit?: boolean | undefined; - fallbackErrorMessage?: string | undefined; - maxOutputBytes?: number | undefined; - truncateOutputAtMaxBytes?: boolean | undefined; - progress?: ExecuteGitProgress | undefined; -} - -function parseBranchAb(value: string): { ahead: number; behind: number } { - const match = value.match(/^\+(\d+)\s+-(\d+)$/); - if (!match) return { ahead: 0, behind: 0 }; - return { - ahead: Number(match[1] ?? "0"), - behind: Number(match[2] ?? "0"), - }; -} - -function parseNumstatEntries( - stdout: string, -): Array<{ path: string; insertions: number; deletions: number }> { - const entries: Array<{ path: string; insertions: number; deletions: number }> = []; - for (const line of stdout.split(/\r?\n/g)) { - if (line.trim().length === 0) continue; - const [addedRaw, deletedRaw, ...pathParts] = line.split("\t"); - const rawPath = - pathParts.length > 1 ? (pathParts.at(-1) ?? "").trim() : pathParts.join("\t").trim(); - if (rawPath.length === 0) continue; - const added = Number.parseInt(addedRaw ?? "0", 10); - const deleted = Number.parseInt(deletedRaw ?? "0", 10); - const renameArrowIndex = rawPath.indexOf(" => "); - const normalizedPath = - renameArrowIndex >= 0 ? rawPath.slice(renameArrowIndex + " => ".length).trim() : rawPath; - entries.push({ - path: normalizedPath.length > 0 ? normalizedPath : rawPath, - insertions: Number.isFinite(added) ? added : 0, - deletions: Number.isFinite(deleted) ? deleted : 0, - }); - } - return entries; -} - -function splitNullSeparatedPaths(input: string, truncated: boolean): string[] { - const parts = input.split("\0"); - if (parts.length === 0) return []; - - if (truncated && parts[parts.length - 1]?.length) { - parts.pop(); - } - - return parts.filter((value) => value.length > 0); -} - -function chunkPathsForGitCheckIgnore(relativePaths: readonly string[]): string[][] { - const chunks: string[][] = []; - let chunk: string[] = []; - let chunkBytes = 0; - - for (const relativePath of relativePaths) { - const relativePathBytes = Buffer.byteLength(relativePath) + 1; - if (chunk.length > 0 && chunkBytes + relativePathBytes > GIT_CHECK_IGNORE_MAX_STDIN_BYTES) { - chunks.push(chunk); - chunk = []; - chunkBytes = 0; - } - - chunk.push(relativePath); - chunkBytes += relativePathBytes; - - if (chunkBytes >= GIT_CHECK_IGNORE_MAX_STDIN_BYTES) { - chunks.push(chunk); - chunk = []; - chunkBytes = 0; - } - } - - if (chunk.length > 0) { - chunks.push(chunk); - } - - return chunks; -} - -function parsePorcelainPath(line: string): string | null { - if (line.startsWith("? ") || line.startsWith("! ")) { - const simple = line.slice(2).trim(); - return simple.length > 0 ? simple : null; - } - - if (!(line.startsWith("1 ") || line.startsWith("2 ") || line.startsWith("u "))) { - return null; - } - - const tabIndex = line.indexOf("\t"); - if (tabIndex >= 0) { - const fromTab = line.slice(tabIndex + 1); - const [filePath] = fromTab.split("\t"); - return filePath?.trim().length ? filePath.trim() : null; - } - - const parts = line.trim().split(/\s+/g); - const filePath = parts.at(-1) ?? ""; - return filePath.length > 0 ? filePath : null; -} - -function parseBranchLine(line: string): { name: string; current: boolean } | null { - const trimmed = line.trim(); - if (trimmed.length === 0) return null; - - const name = trimmed.replace(/^[*+]\s+/, ""); - // Exclude symbolic refs like: "origin/HEAD -> origin/main". - // Exclude detached HEAD pseudo-refs like: "(HEAD detached at origin/main)". - if (name.includes(" -> ") || name.startsWith("(")) return null; - - return { - name, - current: trimmed.startsWith("* "), - }; -} - -function filterBranchesForListQuery( - branches: ReadonlyArray, - query?: string, -): ReadonlyArray { - if (!query) { - return branches; - } - - const normalizedQuery = query.toLowerCase(); - return branches.filter((branch) => branch.name.toLowerCase().includes(normalizedQuery)); -} - -function paginateBranches(input: { - branches: ReadonlyArray; - cursor?: number | undefined; - limit?: number | undefined; -}): { - branches: ReadonlyArray; - nextCursor: number | null; - totalCount: number; -} { - const cursor = input.cursor ?? 0; - const limit = input.limit ?? GIT_LIST_BRANCHES_DEFAULT_LIMIT; - const totalCount = input.branches.length; - const branches = input.branches.slice(cursor, cursor + limit); - const nextCursor = cursor + branches.length < totalCount ? cursor + branches.length : null; - - return { - branches, - nextCursor, - totalCount, - }; -} - -function sanitizeRemoteName(value: string): string { - const sanitized = value - .trim() - .replace(/[^A-Za-z0-9._-]+/g, "-") - .replace(/^-+|-+$/g, ""); - return sanitized.length > 0 ? sanitized : "fork"; -} - -function normalizeRemoteUrl(value: string): string { - return value - .trim() - .replace(/\/+$/g, "") - .replace(/\.git$/i, "") - .toLowerCase(); -} - -function parseRemoteFetchUrls(stdout: string): Map { - const remotes = new Map(); - for (const line of stdout.split("\n")) { - const trimmed = line.trim(); - if (trimmed.length === 0) continue; - const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)$/.exec(trimmed); - if (!match) continue; - const [, remoteName = "", remoteUrl = "", direction = ""] = match; - if (direction !== "fetch" || remoteName.length === 0 || remoteUrl.length === 0) { - continue; - } - remotes.set(remoteName, remoteUrl); - } - return remotes; -} - -function parseUpstreamRefWithRemoteNames( - upstreamRef: string, - remoteNames: ReadonlyArray, -): { upstreamRef: string; remoteName: string; upstreamBranch: string } | null { - const parsed = parseRemoteRefWithRemoteNames(upstreamRef, remoteNames); - if (!parsed) { - return null; - } - - return { - upstreamRef, - remoteName: parsed.remoteName, - upstreamBranch: parsed.branchName, - }; -} - -function parseUpstreamRefByFirstSeparator( - upstreamRef: string, -): { upstreamRef: string; remoteName: string; upstreamBranch: string } | null { - const separatorIndex = upstreamRef.indexOf("/"); - if (separatorIndex <= 0 || separatorIndex === upstreamRef.length - 1) { - return null; - } - - const remoteName = upstreamRef.slice(0, separatorIndex).trim(); - const upstreamBranch = upstreamRef.slice(separatorIndex + 1).trim(); - if (remoteName.length === 0 || upstreamBranch.length === 0) { - return null; - } - - return { - upstreamRef, - remoteName, - upstreamBranch, - }; -} - -function parseTrackingBranchByUpstreamRef(stdout: string, upstreamRef: string): string | null { - for (const line of stdout.split("\n")) { - const trimmedLine = line.trim(); - if (trimmedLine.length === 0) { - continue; - } - const [branchNameRaw, upstreamBranchRaw = ""] = trimmedLine.split("\t"); - const branchName = branchNameRaw?.trim() ?? ""; - const upstreamBranch = upstreamBranchRaw.trim(); - if (branchName.length === 0 || upstreamBranch.length === 0) { - continue; - } - if (upstreamBranch === upstreamRef) { - return branchName; - } - } - - return null; -} - -function deriveLocalBranchNameFromRemoteRef(branchName: string): string | null { - const separatorIndex = branchName.indexOf("/"); - if (separatorIndex <= 0 || separatorIndex === branchName.length - 1) { - return null; - } - const localBranch = branchName.slice(separatorIndex + 1).trim(); - return localBranch.length > 0 ? localBranch : null; -} - -function commandLabel(args: readonly string[]): string { - return `git ${args.join(" ")}`; -} - -function parseDefaultBranchFromRemoteHeadRef(value: string, remoteName: string): string | null { - const trimmed = value.trim(); - const prefix = `refs/remotes/${remoteName}/`; - if (!trimmed.startsWith(prefix)) { - return null; - } - const branch = trimmed.slice(prefix.length).trim(); - return branch.length > 0 ? branch : null; -} - -function createGitCommandError( - operation: string, - cwd: string, - args: readonly string[], - detail: string, - cause?: unknown, -): GitCommandError { - return new GitCommandError({ - operation, - command: commandLabel(args), - cwd, - detail, - ...(cause !== undefined ? { cause } : {}), - }); -} - -function quoteGitCommand(args: ReadonlyArray): string { - return `git ${args.join(" ")}`; -} - -function isMissingGitCwdError(error: GitCommandError): boolean { - const normalized = `${error.detail}\n${error.message}`.toLowerCase(); - return ( - normalized.includes("no such file or directory") || - normalized.includes("notfound: filesystem.access") || - normalized.includes("enoent") || - normalized.includes("not a directory") - ); -} - -function toGitCommandError( - input: Pick, - detail: string, -) { - return (cause: unknown) => - Schema.is(GitCommandError)(cause) - ? cause - : new GitCommandError({ - operation: input.operation, - command: quoteGitCommand(input.args), - cwd: input.cwd, - detail: `${cause instanceof Error && cause.message.length > 0 ? cause.message : "Unknown error"} - ${detail}`, - ...(cause !== undefined ? { cause } : {}), - }); -} - -interface Trace2Monitor { - readonly env: NodeJS.ProcessEnv; - readonly flush: Effect.Effect; -} - -const nowUnixNano = (): bigint => BigInt(Date.now()) * 1_000_000n; - -const addCurrentSpanEvent = (name: string, attributes: Record) => - Effect.currentSpan.pipe( - Effect.tap((span) => - Effect.sync(() => { - span.event(name, nowUnixNano(), compactTraceAttributes(attributes)); - }), - ), - Effect.catch(() => Effect.void), - ); - -function trace2ChildKey(record: Record): string | null { - const childId = record.child_id; - if (typeof childId === "number" || typeof childId === "string") { - return String(childId); - } - const hookName = record.hook_name; - return typeof hookName === "string" && hookName.trim().length > 0 ? hookName.trim() : null; -} - -const Trace2Record = Schema.Record(Schema.String, Schema.Unknown); - -const createTrace2Monitor = Effect.fn("createTrace2Monitor")(function* ( - input: Pick, - progress: ExecuteGitProgress | undefined, -): Effect.fn.Return< - Trace2Monitor, - PlatformError.PlatformError, - Scope.Scope | FileSystem.FileSystem | Path.Path -> { - if (!progress?.onHookStarted && !progress?.onHookFinished) { - return { - env: {}, - flush: Effect.void, - }; - } - - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const traceFilePath = yield* fs.makeTempFileScoped({ - prefix: `t3code-git-trace2-${process.pid}-`, - suffix: ".json", - }); - const hookStartByChildKey = new Map(); - const traceTailState = yield* Ref.make({ - processedChars: 0, - remainder: "", - }); - - const handleTraceLine = Effect.fn("handleTraceLine")(function* (line: string) { - const trimmedLine = line.trim(); - if (trimmedLine.length === 0) { - return; - } - - const traceRecord = decodeJsonResult(Trace2Record)(trimmedLine); - if (Result.isFailure(traceRecord)) { - yield* Effect.logDebug( - `GitCore.trace2: failed to parse trace line for ${quoteGitCommand(input.args)} in ${input.cwd}`, - traceRecord.failure, - ); - return; - } - - if (traceRecord.success.child_class !== "hook") { - return; - } - - const event = traceRecord.success.event; - const childKey = trace2ChildKey(traceRecord.success); - if (childKey === null) { - return; - } - const started = hookStartByChildKey.get(childKey); - const hookNameFromEvent = - typeof traceRecord.success.hook_name === "string" ? traceRecord.success.hook_name.trim() : ""; - const hookName = hookNameFromEvent.length > 0 ? hookNameFromEvent : (started?.hookName ?? ""); - if (hookName.length === 0) { - return; - } - - if (event === "child_start") { - hookStartByChildKey.set(childKey, { hookName, startedAtMs: Date.now() }); - yield* addCurrentSpanEvent("git.hook.started", { - hookName, - }); - if (progress.onHookStarted) { - yield* progress.onHookStarted(hookName); - } - return; - } - - if (event === "child_exit") { - hookStartByChildKey.delete(childKey); - const code = traceRecord.success.code; - const exitCode = typeof code === "number" && Number.isInteger(code) ? code : null; - const durationMs = started ? Math.max(0, Date.now() - started.startedAtMs) : null; - yield* addCurrentSpanEvent("git.hook.finished", { - hookName: started?.hookName ?? hookName, - exitCode, - durationMs, - }); - if (progress.onHookFinished) { - yield* progress.onHookFinished({ - hookName: started?.hookName ?? hookName, - exitCode, - durationMs, - }); - } - } - }); - - const deltaMutex = yield* Semaphore.make(1); - const readTraceDelta = deltaMutex.withPermit( - fs.readFileString(traceFilePath).pipe( - Effect.flatMap((contents) => - Effect.uninterruptible( - Ref.modify(traceTailState, ({ processedChars, remainder }) => { - if (contents.length <= processedChars) { - return [[], { processedChars, remainder }]; - } - - const appended = contents.slice(processedChars); - const combined = remainder + appended; - const lines = combined.split("\n"); - const nextRemainder = lines.pop() ?? ""; - - return [ - lines.map((line) => line.replace(/\r$/, "")), - { - processedChars: contents.length, - remainder: nextRemainder, - }, - ]; - }).pipe( - Effect.flatMap((lines) => Effect.forEach(lines, handleTraceLine, { discard: true })), - ), - ), - ), - Effect.ignore({ log: true }), - ), - ); - const traceFileName = path.basename(traceFilePath); - yield* Stream.runForEach(fs.watch(traceFilePath), (event) => { - const eventPath = event.path; - const isTargetTraceEvent = - eventPath === traceFilePath || - eventPath === traceFileName || - path.basename(eventPath) === traceFileName; - if (!isTargetTraceEvent) return Effect.void; - return readTraceDelta; - }).pipe(Effect.ignoreCause({ log: true }), Effect.forkScoped); - - const finalizeTrace2Monitor = Effect.fn("finalizeTrace2Monitor")(function* () { - yield* readTraceDelta; - const finalLine = yield* Ref.modify(traceTailState, ({ processedChars, remainder }) => [ - remainder.trim(), - { - processedChars, - remainder: "", - }, - ]); - if (finalLine.length > 0) { - yield* handleTraceLine(finalLine); - } - }); - - yield* Effect.addFinalizer(finalizeTrace2Monitor); - - return { - env: { - GIT_TRACE2_EVENT: traceFilePath, - }, - flush: readTraceDelta, - }; -}); - -const collectOutput = Effect.fn("collectOutput")(function* ( - input: Pick, - stream: Stream.Stream, - maxOutputBytes: number, - truncateOutputAtMaxBytes: boolean, - onLine: ((line: string) => Effect.Effect) | undefined, -): Effect.fn.Return<{ readonly text: string; readonly truncated: boolean }, GitCommandError> { - const decoder = new TextDecoder(); - let bytes = 0; - let text = ""; - let lineBuffer = ""; - let truncated = false; - - const emitCompleteLines = Effect.fn("emitCompleteLines")(function* (flush: boolean) { - let newlineIndex = lineBuffer.indexOf("\n"); - while (newlineIndex >= 0) { - const line = lineBuffer.slice(0, newlineIndex).replace(/\r$/, ""); - lineBuffer = lineBuffer.slice(newlineIndex + 1); - if (line.length > 0 && onLine) { - yield* onLine(line); - } - newlineIndex = lineBuffer.indexOf("\n"); - } - - if (flush) { - const trailing = lineBuffer.replace(/\r$/, ""); - lineBuffer = ""; - if (trailing.length > 0 && onLine) { - yield* onLine(trailing); - } - } - }); - - const processChunk = Effect.fn("processChunk")(function* (chunk: Uint8Array) { - if (truncateOutputAtMaxBytes && truncated) { - return; - } - const nextBytes = bytes + chunk.byteLength; - if (!truncateOutputAtMaxBytes && nextBytes > maxOutputBytes) { - return yield* new GitCommandError({ - operation: input.operation, - command: quoteGitCommand(input.args), - cwd: input.cwd, - detail: `${quoteGitCommand(input.args)} output exceeded ${maxOutputBytes} bytes and was truncated.`, - }); - } - - const chunkToDecode = - truncateOutputAtMaxBytes && nextBytes > maxOutputBytes - ? chunk.subarray(0, Math.max(0, maxOutputBytes - bytes)) - : chunk; - bytes += chunkToDecode.byteLength; - truncated = truncateOutputAtMaxBytes && nextBytes > maxOutputBytes; - - const decoded = decoder.decode(chunkToDecode, { stream: !truncated }); - text += decoded; - lineBuffer += decoded; - yield* emitCompleteLines(false); - }); - - yield* Stream.runForEach(stream, processChunk).pipe( - Effect.mapError(toGitCommandError(input, "output stream failed.")), - ); - - const remainder = truncated ? "" : decoder.decode(); - text += remainder; - lineBuffer += remainder; - yield* emitCompleteLines(true); - return { - text, - truncated, - }; -}); - -export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { - executeOverride?: GitCoreShape["execute"]; -}) { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const { worktreesDir } = yield* ServerConfig; - - let executeRaw: GitCoreShape["execute"]; - - if (options?.executeOverride) { - executeRaw = options.executeOverride; - } else { - const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - executeRaw = Effect.fnUntraced(function* (input) { - const commandInput = { - ...input, - args: [...input.args], - } as const; - const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS; - const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; - const truncateOutputAtMaxBytes = input.truncateOutputAtMaxBytes ?? false; - - const runGitCommand = Effect.fn("runGitCommand")(function* () { - const trace2Monitor = yield* createTrace2Monitor(commandInput, input.progress).pipe( - Effect.provideService(Path.Path, path), - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.mapError(toGitCommandError(commandInput, "failed to create trace2 monitor.")), - ); - const child = yield* commandSpawner - .spawn( - ChildProcess.make("git", commandInput.args, { - cwd: commandInput.cwd, - env: { - ...process.env, - ...input.env, - ...trace2Monitor.env, - }, - }), - ) - .pipe(Effect.mapError(toGitCommandError(commandInput, "failed to spawn."))); - - const [stdout, stderr, exitCode] = yield* Effect.all( - [ - collectOutput( - commandInput, - child.stdout, - maxOutputBytes, - truncateOutputAtMaxBytes, - input.progress?.onStdoutLine, - ), - collectOutput( - commandInput, - child.stderr, - maxOutputBytes, - truncateOutputAtMaxBytes, - input.progress?.onStderrLine, - ), - child.exitCode.pipe( - Effect.map((value) => Number(value)), - Effect.mapError(toGitCommandError(commandInput, "failed to report exit code.")), - ), - input.stdin === undefined - ? Effect.void - : Stream.run(Stream.encodeText(Stream.make(input.stdin)), child.stdin).pipe( - Effect.mapError(toGitCommandError(commandInput, "failed to write stdin.")), - ), - ], - { concurrency: "unbounded" }, - ).pipe(Effect.map(([stdout, stderr, exitCode]) => [stdout, stderr, exitCode] as const)); - yield* trace2Monitor.flush; - - if (!input.allowNonZeroExit && exitCode !== 0) { - const trimmedStderr = stderr.text.trim(); - return yield* new GitCommandError({ - operation: commandInput.operation, - command: quoteGitCommand(commandInput.args), - cwd: commandInput.cwd, - detail: - trimmedStderr.length > 0 - ? `${quoteGitCommand(commandInput.args)} failed: ${trimmedStderr}` - : `${quoteGitCommand(commandInput.args)} failed with code ${exitCode}.`, - }); - } - - return { - code: exitCode, - stdout: stdout.text, - stderr: stderr.text, - stdoutTruncated: stdout.truncated, - stderrTruncated: stderr.truncated, - } satisfies ExecuteGitResult; - }); - - return yield* runGitCommand().pipe( - Effect.scoped, - Effect.timeoutOption(timeoutMs), - Effect.flatMap((result) => - Option.match(result, { - onNone: () => - Effect.fail( - new GitCommandError({ - operation: commandInput.operation, - command: quoteGitCommand(commandInput.args), - cwd: commandInput.cwd, - detail: `${quoteGitCommand(commandInput.args)} timed out.`, - }), - ), - onSome: Effect.succeed, - }), - ), - ); - }); - } - - const execute: GitCoreShape["execute"] = (input) => - executeRaw(input).pipe( - withMetrics({ - counter: gitCommandsTotal, - timer: gitCommandDuration, - attributes: { - operation: input.operation, - }, - }), - Effect.withSpan(input.operation, { - kind: "client", - attributes: { - "git.operation": input.operation, - "git.cwd": input.cwd, - "git.args_count": input.args.length, - }, - }), - ); - - const executeGit = ( - operation: string, - cwd: string, - args: readonly string[], - options: ExecuteGitOptions = {}, - ): Effect.Effect => - execute({ - operation, - cwd, - args, - ...(options.stdin !== undefined ? { stdin: options.stdin } : {}), - allowNonZeroExit: true, - ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), - ...(options.maxOutputBytes !== undefined ? { maxOutputBytes: options.maxOutputBytes } : {}), - ...(options.truncateOutputAtMaxBytes !== undefined - ? { truncateOutputAtMaxBytes: options.truncateOutputAtMaxBytes } - : {}), - ...(options.progress ? { progress: options.progress } : {}), - }).pipe( - Effect.flatMap((result) => { - if (options.allowNonZeroExit || result.code === 0) { - return Effect.succeed(result); - } - const stderr = result.stderr.trim(); - if (stderr.length > 0) { - return Effect.fail(createGitCommandError(operation, cwd, args, stderr)); - } - if (options.fallbackErrorMessage) { - return Effect.fail( - createGitCommandError(operation, cwd, args, options.fallbackErrorMessage), - ); - } - return Effect.fail( - createGitCommandError( - operation, - cwd, - args, - `${commandLabel(args)} failed: code=${result.code ?? "null"}`, - ), - ); - }), - ); - - const runGit = ( - operation: string, - cwd: string, - args: readonly string[], - allowNonZeroExit = false, - ): Effect.Effect => - executeGit(operation, cwd, args, { allowNonZeroExit }).pipe(Effect.asVoid); - - const runGitStdout = ( - operation: string, - cwd: string, - args: readonly string[], - allowNonZeroExit = false, - ): Effect.Effect => - executeGit(operation, cwd, args, { allowNonZeroExit }).pipe( - Effect.map((result) => result.stdout), - ); - - const runGitStdoutWithOptions = ( - operation: string, - cwd: string, - args: readonly string[], - options: ExecuteGitOptions = {}, - ): Effect.Effect => - executeGit(operation, cwd, args, options).pipe( - Effect.map((result) => - result.stdoutTruncated ? `${result.stdout}${OUTPUT_TRUNCATED_MARKER}` : result.stdout, - ), - ); - - const branchExists = (cwd: string, branch: string): Effect.Effect => - executeGit( - "GitCore.branchExists", - cwd, - ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], - { - allowNonZeroExit: true, - timeoutMs: 5_000, - }, - ).pipe(Effect.map((result) => result.code === 0)); - - const resolveAvailableBranchName = Effect.fn("resolveAvailableBranchName")(function* ( - cwd: string, - desiredBranch: string, - ) { - const isDesiredTaken = yield* branchExists(cwd, desiredBranch); - if (!isDesiredTaken) { - return desiredBranch; - } - - for (let suffix = 1; suffix <= 100; suffix += 1) { - const candidate = `${desiredBranch}-${suffix}`; - const isCandidateTaken = yield* branchExists(cwd, candidate); - if (!isCandidateTaken) { - return candidate; - } - } - - return yield* createGitCommandError( - "GitCore.renameBranch", - cwd, - ["branch", "-m", "--", desiredBranch], - `Could not find an available branch name for '${desiredBranch}'.`, - ); - }); - - const resolveCurrentUpstream = Effect.fn("resolveCurrentUpstream")(function* (cwd: string) { - const upstreamRef = yield* runGitStdout( - "GitCore.resolveCurrentUpstream", - cwd, - ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], - true, - ).pipe(Effect.map((stdout) => stdout.trim())); - - if (upstreamRef.length === 0 || upstreamRef === "@{upstream}") { - return null; - } - - const remoteNames = yield* runGitStdout("GitCore.listRemoteNames", cwd, ["remote"]).pipe( - Effect.map(parseRemoteNames), - Effect.catch(() => Effect.succeed>([])), - ); - return ( - parseUpstreamRefWithRemoteNames(upstreamRef, remoteNames) ?? - parseUpstreamRefByFirstSeparator(upstreamRef) - ); - }); - - const fetchRemoteForStatus = ( - gitCommonDir: string, - remoteName: string, - ): Effect.Effect => { - const fetchCwd = - path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir; - return executeGit( - "GitCore.fetchRemoteForStatus", - fetchCwd, - ["--git-dir", gitCommonDir, "fetch", "--quiet", "--no-tags", remoteName], - { - allowNonZeroExit: true, - timeoutMs: Duration.toMillis(STATUS_UPSTREAM_REFRESH_TIMEOUT), - }, - ).pipe(Effect.asVoid); - }; - - const resolveGitCommonDir = Effect.fn("resolveGitCommonDir")(function* (cwd: string) { - const gitCommonDir = yield* runGitStdout("GitCore.resolveGitCommonDir", cwd, [ - "rev-parse", - "--git-common-dir", - ]).pipe(Effect.map((stdout) => stdout.trim())); - return path.isAbsolute(gitCommonDir) ? gitCommonDir : path.resolve(cwd, gitCommonDir); - }); - - const refreshStatusRemoteCacheEntry = Effect.fn("refreshStatusRemoteCacheEntry")(function* ( - cacheKey: StatusRemoteRefreshCacheKey, - ) { - yield* fetchRemoteForStatus(cacheKey.gitCommonDir, cacheKey.remoteName); - return true as const; - }); - - const statusRemoteRefreshCache = yield* Cache.makeWith(refreshStatusRemoteCacheEntry, { - capacity: STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY, - // Keep successful refreshes warm and briefly back off failed refreshes to avoid retry storms. - timeToLive: (exit) => - Exit.isSuccess(exit) - ? STATUS_UPSTREAM_REFRESH_INTERVAL - : STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN, - }); - - const refreshStatusUpstreamIfStale = Effect.fn("refreshStatusUpstreamIfStale")(function* ( - cwd: string, - ) { - const upstream = yield* resolveCurrentUpstream(cwd); - if (!upstream) return; - const gitCommonDir = yield* resolveGitCommonDir(cwd); - yield* Cache.get( - statusRemoteRefreshCache, - new StatusRemoteRefreshCacheKey({ - gitCommonDir, - remoteName: upstream.remoteName, - }), - ); - }); - - const resolveDefaultBranchName = ( - cwd: string, - remoteName: string, - ): Effect.Effect => - executeGit( - "GitCore.resolveDefaultBranchName", - cwd, - ["symbolic-ref", `refs/remotes/${remoteName}/HEAD`], - { allowNonZeroExit: true }, - ).pipe( - Effect.map((result) => { - if (result.code !== 0) { - return null; - } - return parseDefaultBranchFromRemoteHeadRef(result.stdout, remoteName); - }), - ); - - const remoteBranchExists = ( - cwd: string, - remoteName: string, - branch: string, - ): Effect.Effect => - executeGit( - "GitCore.remoteBranchExists", - cwd, - ["show-ref", "--verify", "--quiet", `refs/remotes/${remoteName}/${branch}`], - { - allowNonZeroExit: true, - }, - ).pipe(Effect.map((result) => result.code === 0)); - - const originRemoteExists = (cwd: string): Effect.Effect => - executeGit("GitCore.originRemoteExists", cwd, ["remote", "get-url", "origin"], { - allowNonZeroExit: true, - }).pipe(Effect.map((result) => result.code === 0)); - - const listRemoteNames = (cwd: string): Effect.Effect, GitCommandError> => - runGitStdout("GitCore.listRemoteNames", cwd, ["remote"]).pipe( - Effect.map(parseRemoteNamesInGitOrder), - ); - - const resolvePrimaryRemoteName = Effect.fn("resolvePrimaryRemoteName")(function* (cwd: string) { - if (yield* originRemoteExists(cwd)) { - return "origin"; - } - const remotes = yield* listRemoteNames(cwd); - const [firstRemote] = remotes; - if (firstRemote) { - return firstRemote; - } - return yield* createGitCommandError( - "GitCore.resolvePrimaryRemoteName", - cwd, - ["remote"], - "No git remote is configured for this repository.", - ); - }); - - const resolvePushRemoteName = Effect.fn("resolvePushRemoteName")(function* ( - cwd: string, - branch: string, - ) { - const branchPushRemote = yield* runGitStdout( - "GitCore.resolvePushRemoteName.branchPushRemote", - cwd, - ["config", "--get", `branch.${branch}.pushRemote`], - true, - ).pipe(Effect.map((stdout) => stdout.trim())); - if (branchPushRemote.length > 0) { - return branchPushRemote; - } - - const pushDefaultRemote = yield* runGitStdout( - "GitCore.resolvePushRemoteName.remotePushDefault", - cwd, - ["config", "--get", "remote.pushDefault"], - true, - ).pipe(Effect.map((stdout) => stdout.trim())); - if (pushDefaultRemote.length > 0) { - return pushDefaultRemote; - } - - return yield* resolvePrimaryRemoteName(cwd).pipe(Effect.catch(() => Effect.succeed(null))); - }); - - const ensureRemote: GitCoreShape["ensureRemote"] = Effect.fn("ensureRemote")(function* (input) { - const preferredName = sanitizeRemoteName(input.preferredName); - const normalizedTargetUrl = normalizeRemoteUrl(input.url); - const remoteFetchUrls = yield* runGitStdout("GitCore.ensureRemote.listRemoteUrls", input.cwd, [ - "remote", - "-v", - ]).pipe(Effect.map((stdout) => parseRemoteFetchUrls(stdout))); - - for (const [remoteName, remoteUrl] of remoteFetchUrls.entries()) { - if (normalizeRemoteUrl(remoteUrl) === normalizedTargetUrl) { - return remoteName; - } - } - - let remoteName = preferredName; - let suffix = 1; - while (remoteFetchUrls.has(remoteName)) { - remoteName = `${preferredName}-${suffix}`; - suffix += 1; - } - - yield* runGit("GitCore.ensureRemote.add", input.cwd, ["remote", "add", remoteName, input.url]); - return remoteName; - }); - - const resolveBaseBranchForNoUpstream = Effect.fn("resolveBaseBranchForNoUpstream")(function* ( - cwd: string, - branch: string, - ) { - const configuredBaseBranch = yield* runGitStdout( - "GitCore.resolveBaseBranchForNoUpstream.config", - cwd, - ["config", "--get", `branch.${branch}.gh-merge-base`], - true, - ).pipe(Effect.map((stdout) => stdout.trim())); - - const primaryRemoteName = yield* resolvePrimaryRemoteName(cwd).pipe( - Effect.catch(() => Effect.succeed(null)), - ); - const defaultBranch = - primaryRemoteName === null ? null : yield* resolveDefaultBranchName(cwd, primaryRemoteName); - const candidates = [ - configuredBaseBranch.length > 0 ? configuredBaseBranch : null, - defaultBranch, - ...DEFAULT_BASE_BRANCH_CANDIDATES, - ]; - - for (const candidate of candidates) { - if (!candidate) { - continue; - } - - const remotePrefix = - primaryRemoteName && primaryRemoteName !== "origin" ? `${primaryRemoteName}/` : null; - const normalizedCandidate = candidate.startsWith("origin/") - ? candidate.slice("origin/".length) - : remotePrefix && candidate.startsWith(remotePrefix) - ? candidate.slice(remotePrefix.length) - : candidate; - if (normalizedCandidate.length === 0 || normalizedCandidate === branch) { - continue; - } - - if (yield* branchExists(cwd, normalizedCandidate)) { - return normalizedCandidate; - } - - if ( - primaryRemoteName && - (yield* remoteBranchExists(cwd, primaryRemoteName, normalizedCandidate)) - ) { - return `${primaryRemoteName}/${normalizedCandidate}`; - } - } - - return null; - }); - - const computeAheadCountAgainstBase = Effect.fn("computeAheadCountAgainstBase")(function* ( - cwd: string, - branch: string, - ) { - const baseBranch = yield* resolveBaseBranchForNoUpstream(cwd, branch); - if (!baseBranch) { - return 0; - } - - const result = yield* executeGit( - "GitCore.computeAheadCountAgainstBase", - cwd, - ["rev-list", "--count", `${baseBranch}..HEAD`], - { allowNonZeroExit: true }, - ); - if (result.code !== 0) { - return 0; - } - - const parsed = Number.parseInt(result.stdout.trim(), 10); - return Number.isFinite(parsed) ? Math.max(0, parsed) : 0; - }); - - const readBranchRecency = Effect.fn("readBranchRecency")(function* (cwd: string) { - const branchRecency = yield* executeGit( - "GitCore.readBranchRecency", - cwd, - [ - "for-each-ref", - "--format=%(refname:short)%09%(committerdate:unix)", - "refs/heads", - "refs/remotes", - ], - { - timeoutMs: 15_000, - allowNonZeroExit: true, - }, - ); - - const branchLastCommit = new Map(); - if (branchRecency.code !== 0) { - return branchLastCommit; - } - - for (const line of branchRecency.stdout.split("\n")) { - if (line.length === 0) { - continue; - } - const [name, lastCommitRaw] = line.split("\t"); - if (!name) { - continue; - } - const lastCommit = Number.parseInt(lastCommitRaw ?? "0", 10); - branchLastCommit.set(name, Number.isFinite(lastCommit) ? lastCommit : 0); - } - - return branchLastCommit; - }); - - const readStatusDetailsLocal = Effect.fn("readStatusDetailsLocal")(function* (cwd: string) { - const statusResult = yield* executeGit( - "GitCore.statusDetails.status", - cwd, - ["status", "--porcelain=2", "--branch"], - { - allowNonZeroExit: true, - }, - ).pipe(Effect.catchIf(isMissingGitCwdError, () => Effect.succeed(null))); - - if (statusResult === null) { - return NON_REPOSITORY_STATUS_DETAILS; - } - - if (statusResult.code !== 0) { - const stderr = statusResult.stderr.trim(); - return yield* createGitCommandError( - "GitCore.statusDetails.status", - cwd, - ["status", "--porcelain=2", "--branch"], - stderr || "git status failed", - ); - } - - const [unstagedNumstatStdout, stagedNumstatStdout, defaultRefResult, hasOriginRemote] = - yield* Effect.all( - [ - runGitStdout("GitCore.statusDetails.unstagedNumstat", cwd, ["diff", "--numstat"]), - runGitStdout("GitCore.statusDetails.stagedNumstat", cwd, [ - "diff", - "--cached", - "--numstat", - ]), - executeGit( - "GitCore.statusDetails.defaultRef", - cwd, - ["symbolic-ref", "refs/remotes/origin/HEAD"], - { - allowNonZeroExit: true, - }, - ), - originRemoteExists(cwd).pipe(Effect.catch(() => Effect.succeed(false))), - ], - { concurrency: "unbounded" }, - ); - const statusStdout = statusResult.stdout; - const defaultBranch = - defaultRefResult.code === 0 - ? defaultRefResult.stdout.trim().replace(/^refs\/remotes\/origin\//, "") - : null; - - let branch: string | null = null; - let upstreamRef: string | null = null; - let aheadCount = 0; - let behindCount = 0; - let hasWorkingTreeChanges = false; - const changedFilesWithoutNumstat = new Set(); - - for (const line of statusStdout.split(/\r?\n/g)) { - if (line.startsWith("# branch.head ")) { - const value = line.slice("# branch.head ".length).trim(); - branch = value.startsWith("(") ? null : value; - continue; - } - if (line.startsWith("# branch.upstream ")) { - const value = line.slice("# branch.upstream ".length).trim(); - upstreamRef = value.length > 0 ? value : null; - continue; - } - if (line.startsWith("# branch.ab ")) { - const value = line.slice("# branch.ab ".length).trim(); - const parsed = parseBranchAb(value); - aheadCount = parsed.ahead; - behindCount = parsed.behind; - continue; - } - if (line.trim().length > 0 && !line.startsWith("#")) { - hasWorkingTreeChanges = true; - const pathValue = parsePorcelainPath(line); - if (pathValue) changedFilesWithoutNumstat.add(pathValue); - } - } - - if (!upstreamRef && branch) { - aheadCount = yield* computeAheadCountAgainstBase(cwd, branch).pipe( - Effect.catch(() => Effect.succeed(0)), - ); - behindCount = 0; - } - - const stagedEntries = parseNumstatEntries(stagedNumstatStdout); - const unstagedEntries = parseNumstatEntries(unstagedNumstatStdout); - const fileStatMap = new Map(); - for (const entry of [...stagedEntries, ...unstagedEntries]) { - const existing = fileStatMap.get(entry.path) ?? { insertions: 0, deletions: 0 }; - existing.insertions += entry.insertions; - existing.deletions += entry.deletions; - fileStatMap.set(entry.path, existing); - } - - let insertions = 0; - let deletions = 0; - const files = Array.from(fileStatMap.entries()) - .map(([filePath, stat]) => { - insertions += stat.insertions; - deletions += stat.deletions; - return { path: filePath, insertions: stat.insertions, deletions: stat.deletions }; - }) - .toSorted((a, b) => a.path.localeCompare(b.path)); - - for (const filePath of changedFilesWithoutNumstat) { - if (fileStatMap.has(filePath)) continue; - files.push({ path: filePath, insertions: 0, deletions: 0 }); - } - files.sort((a, b) => a.path.localeCompare(b.path)); - - return { - isRepo: true, - hasOriginRemote, - isDefaultBranch: - branch !== null && - (branch === defaultBranch || - (defaultBranch === null && (branch === "main" || branch === "master"))), - branch, - upstreamRef, - hasWorkingTreeChanges, - workingTree: { - files, - insertions, - deletions, - }, - hasUpstream: upstreamRef !== null, - aheadCount, - behindCount, - }; - }); - - const statusDetailsLocal: GitCoreShape["statusDetailsLocal"] = Effect.fn("statusDetailsLocal")( - function* (cwd) { - return yield* readStatusDetailsLocal(cwd); - }, - ); - - const statusDetails: GitCoreShape["statusDetails"] = Effect.fn("statusDetails")(function* (cwd) { - yield* refreshStatusUpstreamIfStale(cwd).pipe( - Effect.catchIf(isMissingGitCwdError, () => Effect.void), - Effect.ignoreCause({ log: true }), - ); - return yield* readStatusDetailsLocal(cwd); - }); - - const status: GitCoreShape["status"] = (input) => - statusDetails(input.cwd).pipe( - Effect.map((details) => ({ - isRepo: details.isRepo, - hasOriginRemote: details.hasOriginRemote, - isDefaultBranch: details.isDefaultBranch, - branch: details.branch, - hasWorkingTreeChanges: details.hasWorkingTreeChanges, - workingTree: details.workingTree, - hasUpstream: details.hasUpstream, - aheadCount: details.aheadCount, - behindCount: details.behindCount, - pr: null, - })), - ); - - const prepareCommitContext: GitCoreShape["prepareCommitContext"] = Effect.fn( - "prepareCommitContext", - )(function* (cwd, filePaths) { - if (filePaths && filePaths.length > 0) { - yield* runGit("GitCore.prepareCommitContext.reset", cwd, ["reset"]).pipe( - Effect.catch(() => Effect.void), - ); - yield* runGit("GitCore.prepareCommitContext.addSelected", cwd, [ - "add", - "-A", - "--", - ...filePaths, - ]); - } else { - yield* runGit("GitCore.prepareCommitContext.addAll", cwd, ["add", "-A"]); - } - - const stagedSummary = yield* runGitStdout("GitCore.prepareCommitContext.stagedSummary", cwd, [ - "diff", - "--cached", - "--name-status", - ]).pipe(Effect.map((stdout) => stdout.trim())); - if (stagedSummary.length === 0) { - return null; - } - - const stagedPatch = yield* runGitStdoutWithOptions( - "GitCore.prepareCommitContext.stagedPatch", - cwd, - ["diff", "--cached", "--patch", "--minimal"], - { - maxOutputBytes: PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES, - truncateOutputAtMaxBytes: true, - }, - ); - - return { - stagedSummary, - stagedPatch, - }; - }); - - const commit: GitCoreShape["commit"] = Effect.fn("commit")(function* ( - cwd, - subject, - body, - options?: GitCommitOptions, - ) { - const args = ["commit", "-m", subject]; - const trimmedBody = body.trim(); - if (trimmedBody.length > 0) { - args.push("-m", trimmedBody); - } - const progress = - options?.progress?.onOutputLine === undefined - ? options?.progress - : { - ...options.progress, - onStdoutLine: (line: string) => - options.progress?.onOutputLine?.({ stream: "stdout", text: line }) ?? Effect.void, - onStderrLine: (line: string) => - options.progress?.onOutputLine?.({ stream: "stderr", text: line }) ?? Effect.void, - }; - yield* executeGit("GitCore.commit.commit", cwd, args, { - ...(options?.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), - ...(progress ? { progress } : {}), - }).pipe(Effect.asVoid); - const commitSha = yield* runGitStdout("GitCore.commit.revParseHead", cwd, [ - "rev-parse", - "HEAD", - ]).pipe(Effect.map((stdout) => stdout.trim())); - - return { commitSha }; - }); - - const pushCurrentBranch: GitCoreShape["pushCurrentBranch"] = Effect.fn("pushCurrentBranch")( - function* (cwd, fallbackBranch) { - const details = yield* statusDetails(cwd); - const branch = details.branch ?? fallbackBranch; - if (!branch) { - return yield* createGitCommandError( - "GitCore.pushCurrentBranch", - cwd, - ["push"], - "Cannot push from detached HEAD.", - ); - } - - const hasNoLocalDelta = details.aheadCount === 0 && details.behindCount === 0; - if (hasNoLocalDelta) { - if (details.hasUpstream) { - return { - status: "skipped_up_to_date" as const, - branch, - ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}), - }; - } - - const comparableBaseBranch = yield* resolveBaseBranchForNoUpstream(cwd, branch).pipe( - Effect.catch(() => Effect.succeed(null)), - ); - if (comparableBaseBranch) { - const publishRemoteName = yield* resolvePushRemoteName(cwd, branch).pipe( - Effect.catch(() => Effect.succeed(null)), - ); - if (!publishRemoteName) { - return { - status: "skipped_up_to_date" as const, - branch, - }; - } - - const hasRemoteBranch = yield* remoteBranchExists(cwd, publishRemoteName, branch).pipe( - Effect.catch(() => Effect.succeed(false)), - ); - if (hasRemoteBranch) { - return { - status: "skipped_up_to_date" as const, - branch, - }; - } - } - } - - if (!details.hasUpstream) { - const publishRemoteName = yield* resolvePushRemoteName(cwd, branch); - if (!publishRemoteName) { - return yield* createGitCommandError( - "GitCore.pushCurrentBranch", - cwd, - ["push"], - "Cannot push because no git remote is configured for this repository.", - ); - } - yield* runGit("GitCore.pushCurrentBranch.pushWithUpstream", cwd, [ - "push", - "-u", - publishRemoteName, - `HEAD:refs/heads/${branch}`, - ]); - return { - status: "pushed" as const, - branch, - upstreamBranch: `${publishRemoteName}/${branch}`, - setUpstream: true, - }; - } - - const currentUpstream = yield* resolveCurrentUpstream(cwd).pipe( - Effect.catch(() => Effect.succeed(null)), - ); - if (currentUpstream) { - yield* runGit("GitCore.pushCurrentBranch.pushUpstream", cwd, [ - "push", - currentUpstream.remoteName, - `HEAD:${currentUpstream.upstreamBranch}`, - ]); - return { - status: "pushed" as const, - branch, - upstreamBranch: currentUpstream.upstreamRef, - setUpstream: false, - }; - } - - yield* runGit("GitCore.pushCurrentBranch.push", cwd, ["push"]); - return { - status: "pushed" as const, - branch, - ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}), - setUpstream: false, - }; - }, - ); - - const pullCurrentBranch: GitCoreShape["pullCurrentBranch"] = Effect.fn("pullCurrentBranch")( - function* (cwd) { - const details = yield* statusDetails(cwd); - const branch = details.branch; - if (!branch) { - return yield* createGitCommandError( - "GitCore.pullCurrentBranch", - cwd, - ["pull", "--ff-only"], - "Cannot pull from detached HEAD.", - ); - } - if (!details.hasUpstream) { - return yield* createGitCommandError( - "GitCore.pullCurrentBranch", - cwd, - ["pull", "--ff-only"], - "Current branch has no upstream configured. Push with upstream first.", - ); - } - const beforeSha = yield* runGitStdout( - "GitCore.pullCurrentBranch.beforeSha", - cwd, - ["rev-parse", "HEAD"], - true, - ).pipe(Effect.map((stdout) => stdout.trim())); - yield* executeGit("GitCore.pullCurrentBranch.pull", cwd, ["pull", "--ff-only"], { - timeoutMs: 30_000, - fallbackErrorMessage: "git pull failed", - }); - const afterSha = yield* runGitStdout( - "GitCore.pullCurrentBranch.afterSha", - cwd, - ["rev-parse", "HEAD"], - true, - ).pipe(Effect.map((stdout) => stdout.trim())); - - const refreshed = yield* statusDetails(cwd); - return { - status: beforeSha.length > 0 && beforeSha === afterSha ? "skipped_up_to_date" : "pulled", - branch, - upstreamBranch: refreshed.upstreamRef, - }; - }, - ); - - const readRangeContext: GitCoreShape["readRangeContext"] = Effect.fn("readRangeContext")( - function* (cwd, baseBranch) { - const range = `${baseBranch}..HEAD`; - const [commitSummary, diffSummary, diffPatch] = yield* Effect.all( - [ - runGitStdoutWithOptions( - "GitCore.readRangeContext.log", - cwd, - ["log", "--oneline", range], - { - maxOutputBytes: RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES, - truncateOutputAtMaxBytes: true, - }, - ), - runGitStdoutWithOptions( - "GitCore.readRangeContext.diffStat", - cwd, - ["diff", "--stat", range], - { - maxOutputBytes: RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES, - truncateOutputAtMaxBytes: true, - }, - ), - runGitStdoutWithOptions( - "GitCore.readRangeContext.diffPatch", - cwd, - ["diff", "--patch", "--minimal", range], - { - maxOutputBytes: RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES, - truncateOutputAtMaxBytes: true, - }, - ), - ], - { concurrency: "unbounded" }, - ); - - return { - commitSummary, - diffSummary, - diffPatch, - }; - }, - ); - - const readConfigValue: GitCoreShape["readConfigValue"] = (cwd, key) => - runGitStdout("GitCore.readConfigValue", cwd, ["config", "--get", key], true).pipe( - Effect.map((stdout) => stdout.trim()), - Effect.map((trimmed) => (trimmed.length > 0 ? trimmed : null)), - ); - - const isInsideWorkTree: GitCoreShape["isInsideWorkTree"] = (cwd) => - executeGit("GitCore.isInsideWorkTree", cwd, ["rev-parse", "--is-inside-work-tree"], { - allowNonZeroExit: true, - timeoutMs: 5_000, - maxOutputBytes: 4_096, - }).pipe(Effect.map((result) => result.code === 0 && result.stdout.trim() === "true")); - - const listWorkspaceFiles: GitCoreShape["listWorkspaceFiles"] = (cwd) => - executeGit( - "GitCore.listWorkspaceFiles", - cwd, - [ - ...WORKSPACE_GIT_HARDENED_CONFIG_ARGS, - "ls-files", - "--cached", - "--others", - "--exclude-standard", - "-z", - ], - { - allowNonZeroExit: true, - timeoutMs: 20_000, - maxOutputBytes: WORKSPACE_FILES_MAX_OUTPUT_BYTES, - truncateOutputAtMaxBytes: true, - }, - ).pipe( - Effect.flatMap((result) => - result.code === 0 - ? Effect.succeed({ - paths: splitNullSeparatedPaths(result.stdout, result.stdoutTruncated), - truncated: result.stdoutTruncated, - }) - : Effect.fail( - createGitCommandError( - "GitCore.listWorkspaceFiles", - cwd, - [ - ...WORKSPACE_GIT_HARDENED_CONFIG_ARGS, - "ls-files", - "--cached", - "--others", - "--exclude-standard", - "-z", - ], - result.stderr.trim().length > 0 ? result.stderr.trim() : "git ls-files failed", - ), - ), - ), - ); - - const filterIgnoredPaths: GitCoreShape["filterIgnoredPaths"] = (cwd, relativePaths) => - Effect.gen(function* () { - if (relativePaths.length === 0) { - return relativePaths; - } - - const ignoredPaths = new Set(); - const chunks = chunkPathsForGitCheckIgnore(relativePaths); - - for (const chunk of chunks) { - const result = yield* executeGit( - "GitCore.filterIgnoredPaths", - cwd, - [...WORKSPACE_GIT_HARDENED_CONFIG_ARGS, "check-ignore", "--no-index", "-z", "--stdin"], - { - stdin: `${chunk.join("\0")}\0`, - allowNonZeroExit: true, - timeoutMs: 20_000, - maxOutputBytes: WORKSPACE_FILES_MAX_OUTPUT_BYTES, - truncateOutputAtMaxBytes: true, - }, - ); - - if (result.code !== 0 && result.code !== 1) { - return yield* createGitCommandError( - "GitCore.filterIgnoredPaths", - cwd, - [...WORKSPACE_GIT_HARDENED_CONFIG_ARGS, "check-ignore", "--no-index", "-z", "--stdin"], - result.stderr.trim().length > 0 ? result.stderr.trim() : "git check-ignore failed", - ); - } - - for (const ignoredPath of splitNullSeparatedPaths(result.stdout, result.stdoutTruncated)) { - ignoredPaths.add(ignoredPath); - } - } - - if (ignoredPaths.size === 0) { - return relativePaths; - } - - return relativePaths.filter((relativePath) => !ignoredPaths.has(relativePath)); - }); - - const listBranches: GitCoreShape["listBranches"] = Effect.fn("listBranches")(function* (input) { - const branchRecencyPromise = readBranchRecency(input.cwd).pipe( - Effect.catch(() => Effect.succeed(new Map())), - ); - const localBranchResult = yield* executeGit( - "GitCore.listBranches.branchNoColor", - input.cwd, - ["branch", "--no-color", "--no-column"], - { - timeoutMs: 10_000, - allowNonZeroExit: true, - }, - ).pipe( - Effect.catchIf(isMissingGitCwdError, () => - Effect.succeed({ - code: 128, - stdout: "", - stderr: "fatal: not a git repository", - stdoutTruncated: false, - stderrTruncated: false, - }), - ), - ); - - if (localBranchResult.code !== 0) { - const stderr = localBranchResult.stderr.trim(); - if (stderr.toLowerCase().includes("not a git repository")) { - return { - branches: [], - isRepo: false, - hasOriginRemote: false, - nextCursor: null, - totalCount: 0, - }; - } - return yield* createGitCommandError( - "GitCore.listBranches", - input.cwd, - ["branch", "--no-color", "--no-column"], - stderr || "git branch failed", - ); - } - - const remoteBranchResultEffect = executeGit( - "GitCore.listBranches.remoteBranches", - input.cwd, - ["branch", "--no-color", "--no-column", "--remotes"], - { - timeoutMs: 10_000, - allowNonZeroExit: true, - }, - ).pipe( - Effect.catch((error) => - Effect.logWarning( - `GitCore.listBranches: remote branch lookup failed for ${input.cwd}: ${error.message}. Falling back to an empty remote branch list.`, - ).pipe(Effect.as({ code: 1, stdout: "", stderr: "" })), - ), - ); - - const remoteNamesResultEffect = executeGit( - "GitCore.listBranches.remoteNames", - input.cwd, - ["remote"], - { - timeoutMs: 5_000, - allowNonZeroExit: true, - }, - ).pipe( - Effect.catch((error) => - Effect.logWarning( - `GitCore.listBranches: remote name lookup failed for ${input.cwd}: ${error.message}. Falling back to an empty remote name list.`, - ).pipe(Effect.as({ code: 1, stdout: "", stderr: "" })), - ), - ); - - const [defaultRef, worktreeList, remoteBranchResult, remoteNamesResult, branchLastCommit] = - yield* Effect.all( - [ - executeGit( - "GitCore.listBranches.defaultRef", - input.cwd, - ["symbolic-ref", "refs/remotes/origin/HEAD"], - { - timeoutMs: 5_000, - allowNonZeroExit: true, - }, - ), - executeGit( - "GitCore.listBranches.worktreeList", - input.cwd, - ["worktree", "list", "--porcelain"], - { - timeoutMs: 5_000, - allowNonZeroExit: true, - }, - ), - remoteBranchResultEffect, - remoteNamesResultEffect, - branchRecencyPromise, - ], - { concurrency: "unbounded" }, - ); - - const remoteNames = - remoteNamesResult.code === 0 ? parseRemoteNames(remoteNamesResult.stdout) : []; - if (remoteBranchResult.code !== 0 && remoteBranchResult.stderr.trim().length > 0) { - yield* Effect.logWarning( - `GitCore.listBranches: remote branch lookup returned code ${remoteBranchResult.code} for ${input.cwd}: ${remoteBranchResult.stderr.trim()}. Falling back to an empty remote branch list.`, - ); - } - if (remoteNamesResult.code !== 0 && remoteNamesResult.stderr.trim().length > 0) { - yield* Effect.logWarning( - `GitCore.listBranches: remote name lookup returned code ${remoteNamesResult.code} for ${input.cwd}: ${remoteNamesResult.stderr.trim()}. Falling back to an empty remote name list.`, - ); - } - - const defaultBranch = - defaultRef.code === 0 - ? defaultRef.stdout.trim().replace(/^refs\/remotes\/origin\//, "") - : null; - - const worktreeMap = new Map(); - if (worktreeList.code === 0) { - let currentPath: string | null = null; - for (const line of worktreeList.stdout.split("\n")) { - if (line.startsWith("worktree ")) { - const candidatePath = line.slice("worktree ".length); - const exists = yield* fileSystem.stat(candidatePath).pipe( - Effect.map(() => true), - Effect.catch(() => Effect.succeed(false)), - ); - currentPath = exists ? candidatePath : null; - } else if (line.startsWith("branch refs/heads/") && currentPath) { - worktreeMap.set(line.slice("branch refs/heads/".length), currentPath); - } else if (line === "") { - currentPath = null; - } - } - } - - const localBranches = localBranchResult.stdout - .split("\n") - .map(parseBranchLine) - .filter((branch): branch is { name: string; current: boolean } => branch !== null) - .map((branch) => ({ - name: branch.name, - current: branch.current, - isRemote: false, - isDefault: branch.name === defaultBranch, - worktreePath: worktreeMap.get(branch.name) ?? null, - })) - .toSorted((a, b) => { - const aPriority = a.current ? 0 : a.isDefault ? 1 : 2; - const bPriority = b.current ? 0 : b.isDefault ? 1 : 2; - if (aPriority !== bPriority) return aPriority - bPriority; - - const aLastCommit = branchLastCommit.get(a.name) ?? 0; - const bLastCommit = branchLastCommit.get(b.name) ?? 0; - if (aLastCommit !== bLastCommit) return bLastCommit - aLastCommit; - return a.name.localeCompare(b.name); - }); - - const remoteBranches = - remoteBranchResult.code === 0 - ? remoteBranchResult.stdout - .split("\n") - .map(parseBranchLine) - .filter((branch): branch is { name: string; current: boolean } => branch !== null) - .map((branch) => { - const parsedRemoteRef = parseRemoteRefWithRemoteNames(branch.name, remoteNames); - const remoteBranch: { - name: string; - current: boolean; - isRemote: boolean; - remoteName?: string; - isDefault: boolean; - worktreePath: string | null; - } = { - name: branch.name, - current: false, - isRemote: true, - isDefault: false, - worktreePath: null, - }; - if (parsedRemoteRef) { - remoteBranch.remoteName = parsedRemoteRef.remoteName; - } - return remoteBranch; - }) - .toSorted((a, b) => { - const aLastCommit = branchLastCommit.get(a.name) ?? 0; - const bLastCommit = branchLastCommit.get(b.name) ?? 0; - if (aLastCommit !== bLastCommit) return bLastCommit - aLastCommit; - return a.name.localeCompare(b.name); - }) - : []; - - const branches = paginateBranches({ - branches: filterBranchesForListQuery( - dedupeRemoteBranchesWithLocalMatches([...localBranches, ...remoteBranches]), - input.query, - ), - cursor: input.cursor, - limit: input.limit, - }); - - return { - branches: [...branches.branches], - isRepo: true, - hasOriginRemote: remoteNames.includes("origin"), - nextCursor: branches.nextCursor, - totalCount: branches.totalCount, - }; - }); - - const createWorktree: GitCoreShape["createWorktree"] = Effect.fn("createWorktree")( - function* (input) { - const targetBranch = input.newBranch ?? input.branch; - const sanitizedBranch = targetBranch.replace(/\//g, "-"); - const repoName = path.basename(input.cwd); - const worktreePath = input.path ?? path.join(worktreesDir, repoName, sanitizedBranch); - const args = input.newBranch - ? ["worktree", "add", "-b", input.newBranch, worktreePath, input.branch] - : ["worktree", "add", worktreePath, input.branch]; - - yield* executeGit("GitCore.createWorktree", input.cwd, args, { - fallbackErrorMessage: "git worktree add failed", - }); - - return { - worktree: { - path: worktreePath, - branch: targetBranch, - }, - }; - }, - ); - - const fetchPullRequestBranch: GitCoreShape["fetchPullRequestBranch"] = Effect.fn( - "fetchPullRequestBranch", - )(function* (input) { - const remoteName = yield* resolvePrimaryRemoteName(input.cwd); - yield* executeGit( - "GitCore.fetchPullRequestBranch", - input.cwd, - [ - "fetch", - "--quiet", - "--no-tags", - remoteName, - `+refs/pull/${input.prNumber}/head:refs/heads/${input.branch}`, - ], - { - fallbackErrorMessage: "git fetch pull request branch failed", - }, - ); - }); - - const fetchRemoteBranch: GitCoreShape["fetchRemoteBranch"] = Effect.fn("fetchRemoteBranch")( - function* (input) { - yield* runGit("GitCore.fetchRemoteBranch.fetch", input.cwd, [ - "fetch", - "--quiet", - "--no-tags", - input.remoteName, - `+refs/heads/${input.remoteBranch}:refs/remotes/${input.remoteName}/${input.remoteBranch}`, - ]); - - const localBranchAlreadyExists = yield* branchExists(input.cwd, input.localBranch); - const targetRef = `${input.remoteName}/${input.remoteBranch}`; - yield* runGit( - "GitCore.fetchRemoteBranch.materialize", - input.cwd, - localBranchAlreadyExists - ? ["branch", "--force", input.localBranch, targetRef] - : ["branch", input.localBranch, targetRef], - ); - }, - ); - - const setBranchUpstream: GitCoreShape["setBranchUpstream"] = (input) => - runGit("GitCore.setBranchUpstream", input.cwd, [ - "branch", - "--set-upstream-to", - `${input.remoteName}/${input.remoteBranch}`, - input.branch, - ]); - - const removeWorktree: GitCoreShape["removeWorktree"] = Effect.fn("removeWorktree")( - function* (input) { - const args = ["worktree", "remove"]; - if (input.force) { - args.push("--force"); - } - args.push(input.path); - yield* executeGit("GitCore.removeWorktree", input.cwd, args, { - timeoutMs: 15_000, - fallbackErrorMessage: "git worktree remove failed", - }).pipe( - Effect.mapError((error) => - createGitCommandError( - "GitCore.removeWorktree", - input.cwd, - args, - `${commandLabel(args)} failed (cwd: ${input.cwd}): ${error.message}`, - error, - ), - ), - ); - }, - ); - - const renameBranch: GitCoreShape["renameBranch"] = Effect.fn("renameBranch")(function* (input) { - if (input.oldBranch === input.newBranch) { - return { branch: input.newBranch }; - } - const targetBranch = yield* resolveAvailableBranchName(input.cwd, input.newBranch); - - yield* executeGit( - "GitCore.renameBranch", - input.cwd, - ["branch", "-m", "--", input.oldBranch, targetBranch], - { - timeoutMs: 10_000, - fallbackErrorMessage: "git branch rename failed", - }, - ); - - return { branch: targetBranch }; - }); - - const checkoutBranch: GitCoreShape["checkoutBranch"] = Effect.fn("checkoutBranch")( - function* (input) { - const [localInputExists, remoteExists] = yield* Effect.all( - [ - executeGit( - "GitCore.checkoutBranch.localInputExists", - input.cwd, - ["show-ref", "--verify", "--quiet", `refs/heads/${input.branch}`], - { - timeoutMs: 5_000, - allowNonZeroExit: true, - }, - ).pipe(Effect.map((result) => result.code === 0)), - executeGit( - "GitCore.checkoutBranch.remoteExists", - input.cwd, - ["show-ref", "--verify", "--quiet", `refs/remotes/${input.branch}`], - { - timeoutMs: 5_000, - allowNonZeroExit: true, - }, - ).pipe(Effect.map((result) => result.code === 0)), - ], - { concurrency: "unbounded" }, - ); - - const localTrackingBranch = remoteExists - ? yield* executeGit( - "GitCore.checkoutBranch.localTrackingBranch", - input.cwd, - ["for-each-ref", "--format=%(refname:short)\t%(upstream:short)", "refs/heads"], - { - timeoutMs: 5_000, - allowNonZeroExit: true, - }, - ).pipe( - Effect.map((result) => - result.code === 0 - ? parseTrackingBranchByUpstreamRef(result.stdout, input.branch) - : null, - ), - ) - : null; - - const localTrackedBranchCandidate = deriveLocalBranchNameFromRemoteRef(input.branch); - const localTrackedBranchTargetExists = - remoteExists && localTrackedBranchCandidate - ? yield* executeGit( - "GitCore.checkoutBranch.localTrackedBranchTargetExists", - input.cwd, - ["show-ref", "--verify", "--quiet", `refs/heads/${localTrackedBranchCandidate}`], - { - timeoutMs: 5_000, - allowNonZeroExit: true, - }, - ).pipe(Effect.map((result) => result.code === 0)) - : false; - - const checkoutArgs = localInputExists - ? ["checkout", input.branch] - : remoteExists && !localTrackingBranch && localTrackedBranchTargetExists - ? ["checkout", input.branch] - : remoteExists && !localTrackingBranch - ? ["checkout", "--track", input.branch] - : remoteExists && localTrackingBranch - ? ["checkout", localTrackingBranch] - : ["checkout", input.branch]; - - yield* executeGit("GitCore.checkoutBranch.checkout", input.cwd, checkoutArgs, { - timeoutMs: 10_000, - fallbackErrorMessage: "git checkout failed", - }); - - const branch = yield* runGitStdout("GitCore.checkoutBranch.currentBranch", input.cwd, [ - "branch", - "--show-current", - ]).pipe(Effect.map((stdout) => stdout.trim() || null)); - - return { branch }; - }, - ); - - const createBranch: GitCoreShape["createBranch"] = Effect.fn("createBranch")(function* (input) { - yield* executeGit("GitCore.createBranch", input.cwd, ["branch", input.branch], { - timeoutMs: 10_000, - fallbackErrorMessage: "git branch create failed", - }); - if (input.checkout) { - yield* checkoutBranch({ cwd: input.cwd, branch: input.branch }); - } - - return { branch: input.branch }; - }); - - const initRepo: GitCoreShape["initRepo"] = (input) => - executeGit("GitCore.initRepo", input.cwd, ["init"], { - timeoutMs: 10_000, - fallbackErrorMessage: "git init failed", - }).pipe(Effect.asVoid); - - const listLocalBranchNames: GitCoreShape["listLocalBranchNames"] = (cwd) => - runGitStdout("GitCore.listLocalBranchNames", cwd, [ - "branch", - "--list", - "--no-column", - "--format=%(refname:short)", - ]).pipe( - Effect.map((stdout) => - stdout - .split("\n") - .map((line) => line.trim()) - .filter((line) => line.length > 0), - ), - ); - - return { - execute, - status, - statusDetails, - statusDetailsLocal, - prepareCommitContext, - commit, - pushCurrentBranch, - pullCurrentBranch, - readRangeContext, - readConfigValue, - isInsideWorkTree, - listWorkspaceFiles, - filterIgnoredPaths, - listBranches, - createWorktree, - fetchPullRequestBranch, - ensureRemote, - fetchRemoteBranch, - setBranchUpstream, - removeWorktree, - renameBranch, - createBranch, - checkoutBranch, - initRepo, - listLocalBranchNames, - } satisfies GitCoreShape; -}); - -export const GitCoreLive = Layer.effect(GitCore, makeGitCore()); diff --git a/apps/server/src/git/Layers/GitHubCli.test.ts b/apps/server/src/git/Layers/GitHubCli.test.ts deleted file mode 100644 index 5a7b9cb8b1d09132598a751dda6a6fe0d7477a64..0000000000000000000000000000000000000000 --- a/apps/server/src/git/Layers/GitHubCli.test.ts +++ /dev/null @@ -1,227 +0,0 @@ -import { assert, it } from "@effect/vitest"; -import { Effect } from "effect"; -import { afterEach, expect, vi } from "vitest"; - -vi.mock("../../processRunner", () => ({ - runProcess: vi.fn(), -})); - -import { runProcess } from "../../processRunner.ts"; -import { GitHubCli } from "../Services/GitHubCli.ts"; -import { GitHubCliLive } from "./GitHubCli.ts"; - -const mockedRunProcess = vi.mocked(runProcess); -const layer = it.layer(GitHubCliLive); - -afterEach(() => { - mockedRunProcess.mockReset(); -}); - -layer("GitHubCliLive", (it) => { - it.effect("parses pull request view output", () => - Effect.gen(function* () { - mockedRunProcess.mockResolvedValueOnce({ - stdout: JSON.stringify({ - number: 42, - title: "Add PR thread creation", - url: "https://github.com/pingdotgg/codething-mvp/pull/42", - baseRefName: "main", - headRefName: "feature/pr-threads", - state: "OPEN", - mergedAt: null, - isCrossRepository: true, - headRepository: { - nameWithOwner: "octocat/codething-mvp", - }, - headRepositoryOwner: { - login: "octocat", - }, - }), - stderr: "", - code: 0, - signal: null, - timedOut: false, - }); - - const result = yield* Effect.gen(function* () { - const gh = yield* GitHubCli; - return yield* gh.getPullRequest({ - cwd: "/repo", - reference: "#42", - }); - }); - - assert.deepStrictEqual(result, { - number: 42, - title: "Add PR thread creation", - url: "https://github.com/pingdotgg/codething-mvp/pull/42", - baseRefName: "main", - headRefName: "feature/pr-threads", - state: "open", - isCrossRepository: true, - headRepositoryNameWithOwner: "octocat/codething-mvp", - headRepositoryOwnerLogin: "octocat", - }); - expect(mockedRunProcess).toHaveBeenCalledWith( - "gh", - [ - "pr", - "view", - "#42", - "--json", - "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", - ], - expect.objectContaining({ cwd: "/repo" }), - ); - }), - ); - - it.effect("trims pull request fields decoded from gh json", () => - Effect.gen(function* () { - mockedRunProcess.mockResolvedValueOnce({ - stdout: JSON.stringify({ - number: 42, - title: " Add PR thread creation \n", - url: " https://github.com/pingdotgg/codething-mvp/pull/42 ", - baseRefName: " main ", - headRefName: "\tfeature/pr-threads\t", - state: "OPEN", - mergedAt: null, - isCrossRepository: true, - headRepository: { - nameWithOwner: " octocat/codething-mvp ", - }, - headRepositoryOwner: { - login: " octocat ", - }, - }), - stderr: "", - code: 0, - signal: null, - timedOut: false, - }); - - const result = yield* Effect.gen(function* () { - const gh = yield* GitHubCli; - return yield* gh.getPullRequest({ - cwd: "/repo", - reference: "#42", - }); - }); - - assert.deepStrictEqual(result, { - number: 42, - title: "Add PR thread creation", - url: "https://github.com/pingdotgg/codething-mvp/pull/42", - baseRefName: "main", - headRefName: "feature/pr-threads", - state: "open", - isCrossRepository: true, - headRepositoryNameWithOwner: "octocat/codething-mvp", - headRepositoryOwnerLogin: "octocat", - }); - }), - ); - - it.effect("skips invalid entries when parsing pr lists", () => - Effect.gen(function* () { - mockedRunProcess.mockResolvedValueOnce({ - stdout: JSON.stringify([ - { - number: 0, - title: "invalid", - url: "https://github.com/pingdotgg/codething-mvp/pull/0", - baseRefName: "main", - headRefName: "feature/invalid", - }, - { - number: 43, - title: " Valid PR ", - url: " https://github.com/pingdotgg/codething-mvp/pull/43 ", - baseRefName: " main ", - headRefName: " feature/pr-list ", - headRepository: { - nameWithOwner: " ", - }, - headRepositoryOwner: { - login: " ", - }, - }, - ]), - stderr: "", - code: 0, - signal: null, - timedOut: false, - }); - - const result = yield* Effect.gen(function* () { - const gh = yield* GitHubCli; - return yield* gh.listOpenPullRequests({ - cwd: "/repo", - headSelector: "feature/pr-list", - }); - }); - - assert.deepStrictEqual(result, [ - { - number: 43, - title: "Valid PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/43", - baseRefName: "main", - headRefName: "feature/pr-list", - state: "open", - }, - ]); - }), - ); - - it.effect("reads repository clone URLs", () => - Effect.gen(function* () { - mockedRunProcess.mockResolvedValueOnce({ - stdout: JSON.stringify({ - nameWithOwner: "octocat/codething-mvp", - url: "https://github.com/octocat/codething-mvp", - sshUrl: "git@github.com:octocat/codething-mvp.git", - }), - stderr: "", - code: 0, - signal: null, - timedOut: false, - }); - - const result = yield* Effect.gen(function* () { - const gh = yield* GitHubCli; - return yield* gh.getRepositoryCloneUrls({ - cwd: "/repo", - repository: "octocat/codething-mvp", - }); - }); - - assert.deepStrictEqual(result, { - nameWithOwner: "octocat/codething-mvp", - url: "https://github.com/octocat/codething-mvp", - sshUrl: "git@github.com:octocat/codething-mvp.git", - }); - }), - ); - - it.effect("surfaces a friendly error when the pull request is not found", () => - Effect.gen(function* () { - mockedRunProcess.mockRejectedValueOnce( - new Error( - "GraphQL: Could not resolve to a PullRequest with the number of 4888. (repository.pullRequest)", - ), - ); - - const error = yield* Effect.gen(function* () { - const gh = yield* GitHubCli; - return yield* gh.getPullRequest({ - cwd: "/repo", - reference: "4888", - }); - }).pipe(Effect.flip); - - assert.equal(error.message.includes("Pull request not found"), true); - }), - ); -}); diff --git a/apps/server/src/git/Layers/GitHubCli.ts b/apps/server/src/git/Layers/GitHubCli.ts deleted file mode 100644 index dbacdf632262333d542088ce1a7babd74af6769b..0000000000000000000000000000000000000000 --- a/apps/server/src/git/Layers/GitHubCli.ts +++ /dev/null @@ -1,240 +0,0 @@ -import { Effect, Layer, Result, Schema, SchemaIssue } from "effect"; -import { TrimmedNonEmptyString } from "@t3tools/contracts"; - -import { runProcess } from "../../processRunner.ts"; -import { GitHubCliError } from "@t3tools/contracts"; -import { - GitHubCli, - type GitHubRepositoryCloneUrls, - type GitHubCliShape, -} from "../Services/GitHubCli.ts"; -import { - decodeGitHubPullRequestJson, - decodeGitHubPullRequestListJson, - formatGitHubJsonDecodeError, -} from "../githubPullRequests.ts"; - -const DEFAULT_TIMEOUT_MS = 30_000; - -function normalizeGitHubCliError(operation: "execute" | "stdout", error: unknown): GitHubCliError { - if (error instanceof Error) { - if (error.message.includes("Command not found: gh")) { - return new GitHubCliError({ - operation, - detail: "GitHub CLI (`gh`) is required but not available on PATH.", - cause: error, - }); - } - - const lower = error.message.toLowerCase(); - if ( - lower.includes("authentication failed") || - lower.includes("not logged in") || - lower.includes("gh auth login") || - lower.includes("no oauth token") - ) { - return new GitHubCliError({ - operation, - detail: "GitHub CLI is not authenticated. Run `gh auth login` and retry.", - cause: error, - }); - } - - if ( - lower.includes("could not resolve to a pullrequest") || - lower.includes("repository.pullrequest") || - lower.includes("no pull requests found for branch") || - lower.includes("pull request not found") - ) { - return new GitHubCliError({ - operation, - detail: "Pull request not found. Check the PR number or URL and try again.", - cause: error, - }); - } - - return new GitHubCliError({ - operation, - detail: `GitHub CLI command failed: ${error.message}`, - cause: error, - }); - } - - return new GitHubCliError({ - operation, - detail: "GitHub CLI command failed.", - cause: error, - }); -} - -const RawGitHubRepositoryCloneUrlsSchema = Schema.Struct({ - nameWithOwner: TrimmedNonEmptyString, - url: TrimmedNonEmptyString, - sshUrl: TrimmedNonEmptyString, -}); - -function normalizeRepositoryCloneUrls( - raw: Schema.Schema.Type, -): GitHubRepositoryCloneUrls { - return { - nameWithOwner: raw.nameWithOwner, - url: raw.url, - sshUrl: raw.sshUrl, - }; -} - -function decodeGitHubJson( - raw: string, - schema: S, - operation: "listOpenPullRequests" | "getPullRequest" | "getRepositoryCloneUrls", - invalidDetail: string, -): Effect.Effect { - return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( - Effect.mapError( - (error) => - new GitHubCliError({ - operation, - detail: `${invalidDetail}: ${SchemaIssue.makeFormatterDefault()(error.issue)}`, - cause: error, - }), - ), - ); -} - -const makeGitHubCli = Effect.sync(() => { - const execute: GitHubCliShape["execute"] = (input) => - Effect.tryPromise({ - try: () => - runProcess("gh", input.args, { - cwd: input.cwd, - timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, - }), - catch: (error) => normalizeGitHubCliError("execute", error), - }); - - const service = { - execute, - listOpenPullRequests: (input) => - execute({ - cwd: input.cwd, - args: [ - "pr", - "list", - "--head", - input.headSelector, - "--state", - "open", - "--limit", - String(input.limit ?? 1), - "--json", - "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", - ], - }).pipe( - Effect.map((result) => result.stdout.trim()), - Effect.flatMap((raw) => - raw.length === 0 - ? Effect.succeed([]) - : Effect.sync(() => decodeGitHubPullRequestListJson(raw)).pipe( - Effect.flatMap((decoded) => { - if (!Result.isSuccess(decoded)) { - return Effect.fail( - new GitHubCliError({ - operation: "listOpenPullRequests", - detail: `GitHub CLI returned invalid PR list JSON: ${formatGitHubJsonDecodeError(decoded.failure)}`, - cause: decoded.failure, - }), - ); - } - - return Effect.succeed( - decoded.success.map(({ updatedAt: _updatedAt, ...summary }) => summary), - ); - }), - ), - ), - ), - getPullRequest: (input) => - execute({ - cwd: input.cwd, - args: [ - "pr", - "view", - input.reference, - "--json", - "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", - ], - }).pipe( - Effect.map((result) => result.stdout.trim()), - Effect.flatMap((raw) => - Effect.sync(() => decodeGitHubPullRequestJson(raw)).pipe( - Effect.flatMap((decoded) => { - if (!Result.isSuccess(decoded)) { - return Effect.fail( - new GitHubCliError({ - operation: "getPullRequest", - detail: `GitHub CLI returned invalid pull request JSON: ${formatGitHubJsonDecodeError(decoded.failure)}`, - cause: decoded.failure, - }), - ); - } - - return Effect.succeed( - (({ updatedAt: _updatedAt, ...summary }) => summary)(decoded.success), - ); - }), - ), - ), - ), - getRepositoryCloneUrls: (input) => - execute({ - cwd: input.cwd, - args: ["repo", "view", input.repository, "--json", "nameWithOwner,url,sshUrl"], - }).pipe( - Effect.map((result) => result.stdout.trim()), - Effect.flatMap((raw) => - decodeGitHubJson( - raw, - RawGitHubRepositoryCloneUrlsSchema, - "getRepositoryCloneUrls", - "GitHub CLI returned invalid repository JSON.", - ), - ), - Effect.map(normalizeRepositoryCloneUrls), - ), - createPullRequest: (input) => - execute({ - cwd: input.cwd, - args: [ - "pr", - "create", - "--base", - input.baseBranch, - "--head", - input.headSelector, - "--title", - input.title, - "--body-file", - input.bodyFile, - ], - }).pipe(Effect.asVoid), - getDefaultBranch: (input) => - execute({ - cwd: input.cwd, - args: ["repo", "view", "--json", "defaultBranchRef", "--jq", ".defaultBranchRef.name"], - }).pipe( - Effect.map((value) => { - const trimmed = value.stdout.trim(); - return trimmed.length > 0 ? trimmed : null; - }), - ), - checkoutPullRequest: (input) => - execute({ - cwd: input.cwd, - args: ["pr", "checkout", input.reference, ...(input.force ? ["--force"] : [])], - }).pipe(Effect.asVoid), - } satisfies GitHubCliShape; - - return service; -}); - -export const GitHubCliLive = Layer.effect(GitHubCli, makeGitHubCli); diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts deleted file mode 100644 index b8eeb54189240de70d0f8273957e081b0c509007..0000000000000000000000000000000000000000 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ /dev/null @@ -1,3127 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import { spawnSync } from "node:child_process"; - -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { it } from "@effect/vitest"; -import { Effect, FileSystem, Layer, PlatformError, Scope } from "effect"; -import { expect } from "vitest"; -import type { - GitActionProgressEvent, - GitPreparePullRequestThreadInput, - ModelSelection, - ThreadId, -} from "@t3tools/contracts"; - -import { GitCommandError, GitHubCliError, TextGenerationError } from "@t3tools/contracts"; -import { type GitManagerShape } from "../Services/GitManager.ts"; -import { - type GitHubCliShape, - type GitHubPullRequestSummary, - GitHubCli, -} from "../Services/GitHubCli.ts"; -import { type TextGenerationShape, TextGeneration } from "../Services/TextGeneration.ts"; -import { GitCoreLive } from "./GitCore.ts"; -import { GitCore } from "../Services/GitCore.ts"; -import { makeGitManager } from "./GitManager.ts"; -import { ServerConfig } from "../../config.ts"; -import { ServerSettingsService } from "../../serverSettings.ts"; -import { - ProjectSetupScriptRunner, - type ProjectSetupScriptRunnerInput, - type ProjectSetupScriptRunnerShape, -} from "../../project/Services/ProjectSetupScriptRunner.ts"; - -interface FakeGhScenario { - prListSequence?: string[]; - prListByHeadSelector?: Record; - prListSequenceByHeadSelector?: Record; - createdPrUrl?: string; - defaultBranch?: string; - pullRequest?: { - number: number; - title: string; - url: string; - baseRefName: string; - headRefName: string; - state?: "open" | "closed" | "merged"; - isCrossRepository?: boolean; - headRepositoryNameWithOwner?: string | null; - headRepositoryOwnerLogin?: string | null; - }; - repositoryCloneUrls?: Record; - failWith?: GitHubCliError; -} - -interface FakeGitTextGeneration { - generateCommitMessage: (input: { - cwd: string; - branch: string | null; - stagedSummary: string; - stagedPatch: string; - includeBranch?: boolean; - modelSelection: ModelSelection; - }) => Effect.Effect< - { subject: string; body: string; branch?: string | undefined }, - TextGenerationError - >; - generatePrContent: (input: { - cwd: string; - baseBranch: string; - headBranch: string; - commitSummary: string; - diffSummary: string; - diffPatch: string; - modelSelection: ModelSelection; - }) => Effect.Effect<{ title: string; body: string }, TextGenerationError>; - generateBranchName: (input: { - cwd: string; - message: string; - modelSelection: ModelSelection; - }) => Effect.Effect<{ branch: string }, TextGenerationError>; - generateThreadTitle: (input: { - cwd: string; - message: string; - modelSelection: ModelSelection; - }) => Effect.Effect<{ title: string }, TextGenerationError>; -} - -type FakePullRequest = NonNullable; - -function normalizeFakePullRequestSummary(raw: unknown): GitHubPullRequestSummary | null { - if (!raw || typeof raw !== "object") { - return null; - } - - const record = raw as Record; - const number = record.number; - const title = record.title; - const url = record.url; - const baseRefName = record.baseRefName; - const headRefName = record.headRefName; - const headRepository = - typeof record.headRepository === "object" && record.headRepository !== null - ? (record.headRepository as Record) - : null; - const headRepositoryOwner = - typeof record.headRepositoryOwner === "object" && record.headRepositoryOwner !== null - ? (record.headRepositoryOwner as Record) - : null; - - if ( - typeof number !== "number" || - typeof title !== "string" || - typeof url !== "string" || - typeof baseRefName !== "string" || - typeof headRefName !== "string" - ) { - return null; - } - - const state = - typeof record.state === "string" - ? record.state === "OPEN" || record.state === "open" - ? "open" - : record.state === "CLOSED" || record.state === "closed" - ? "closed" - : "merged" - : undefined; - const isCrossRepository = - typeof record.isCrossRepository === "boolean" ? record.isCrossRepository : undefined; - const headRepositoryNameWithOwner = - typeof record.headRepositoryNameWithOwner === "string" - ? record.headRepositoryNameWithOwner - : typeof headRepository?.nameWithOwner === "string" - ? headRepository.nameWithOwner - : undefined; - const headRepositoryOwnerLogin = - typeof record.headRepositoryOwnerLogin === "string" - ? record.headRepositoryOwnerLogin - : typeof headRepositoryOwner?.login === "string" - ? headRepositoryOwner.login - : undefined; - - return { - number, - title, - url, - baseRefName, - headRefName, - ...(state ? { state } : {}), - ...(isCrossRepository !== undefined ? { isCrossRepository } : {}), - ...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}), - ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}), - }; -} - -function runGitSyncForFakeGh(cwd: string, args: readonly string[]): void { - const result = spawnSync("git", args, { - cwd, - encoding: "utf8", - }); - if (result.status === 0) { - return; - } - throw new GitHubCliError({ - operation: "execute", - detail: `Failed to simulate gh checkout with git ${args.join(" ")}: ${result.stderr?.trim() || "unknown error"}`, - }); -} - -function isGitHubCliError(error: unknown): error is GitHubCliError { - return ( - typeof error === "object" && - error !== null && - "_tag" in error && - (error as { _tag?: unknown })._tag === "GitHubCliError" - ); -} - -function makeTempDir( - prefix: string, -): Effect.Effect { - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - return yield* fileSystem.makeTempDirectoryScoped({ prefix }); - }); -} - -function removePath( - targetPath: string, -): Effect.Effect { - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - yield* fileSystem.remove(targetPath, { recursive: true, force: true }); - }); -} - -function makeDirectory( - dirPath: string, -): Effect.Effect { - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - yield* fileSystem.makeDirectory(dirPath, { recursive: true }); - }); -} - -function runGit( - cwd: string, - args: readonly string[], - allowNonZeroExit = false, -): Effect.Effect< - { readonly code: number; readonly stdout: string; readonly stderr: string }, - GitCommandError, - GitCore -> { - return Effect.gen(function* () { - const gitCore = yield* GitCore; - return yield* gitCore.execute({ - operation: "GitManager.test.runGit", - cwd, - args, - allowNonZeroExit, - }); - }); -} - -function initRepo( - cwd: string, -): Effect.Effect< - void, - PlatformError.PlatformError | GitCommandError, - FileSystem.FileSystem | Scope.Scope | GitCore -> { - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - yield* runGit(cwd, ["init", "--initial-branch=main"]); - yield* runGit(cwd, ["config", "user.email", "test@example.com"]); - yield* runGit(cwd, ["config", "user.name", "Test User"]); - yield* fs.writeFileString(path.join(cwd, "README.md"), "hello\n"); - yield* runGit(cwd, ["add", "README.md"]); - yield* runGit(cwd, ["commit", "-m", "Initial commit"]); - }); -} - -function createBareRemote(): Effect.Effect< - string, - PlatformError.PlatformError | GitCommandError, - FileSystem.FileSystem | Scope.Scope | GitCore -> { - return Effect.gen(function* () { - const remoteDir = yield* makeTempDir("t3code-git-remote-"); - yield* runGit(remoteDir, ["init", "--bare"]); - return remoteDir; - }); -} - -function configureRemote( - cwd: string, - remoteName: string, - remotePath: string, - fetchNamespace: string, -): Effect.Effect { - return Effect.gen(function* () { - yield* runGit(cwd, ["config", `remote.${remoteName}.url`, remotePath]); - yield* runGit(cwd, [ - "config", - "--replace-all", - `remote.${remoteName}.fetch`, - `+refs/heads/*:refs/remotes/${fetchNamespace}/*`, - ]); - }); -} - -function createTextGeneration(overrides: Partial = {}): TextGenerationShape { - const implementation: FakeGitTextGeneration = { - generateCommitMessage: (input) => - Effect.succeed({ - subject: "Implement stacked git actions", - body: "", - ...(input.includeBranch ? { branch: "feature/implement-stacked-git-actions" } : {}), - }), - generatePrContent: () => - Effect.succeed({ - title: "Add stacked git actions", - body: "## Summary\n- Add stacked git workflow\n\n## Testing\n- Not run", - }), - generateBranchName: () => - Effect.succeed({ - branch: "update-workflow", - }), - generateThreadTitle: () => - Effect.succeed({ - title: "Update workflow", - }), - ...overrides, - }; - - return { - generateCommitMessage: (input) => - implementation.generateCommitMessage(input).pipe( - Effect.mapError( - (cause) => - new TextGenerationError({ - operation: "generateCommitMessage", - detail: "fake text generation failed", - ...(cause !== undefined ? { cause } : {}), - }), - ), - ), - generatePrContent: (input) => - implementation.generatePrContent(input).pipe( - Effect.mapError( - (cause) => - new TextGenerationError({ - operation: "generatePrContent", - detail: "fake text generation failed", - ...(cause !== undefined ? { cause } : {}), - }), - ), - ), - generateBranchName: (input) => - implementation.generateBranchName(input).pipe( - Effect.mapError( - (cause) => - new TextGenerationError({ - operation: "generateBranchName", - detail: "fake text generation failed", - ...(cause !== undefined ? { cause } : {}), - }), - ), - ), - generateThreadTitle: (input) => - implementation.generateThreadTitle(input).pipe( - Effect.mapError( - (cause) => - new TextGenerationError({ - operation: "generateThreadTitle", - detail: "fake text generation failed", - ...(cause !== undefined ? { cause } : {}), - }), - ), - ), - }; -} - -function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { - service: GitHubCliShape; - ghCalls: string[]; -} { - const prListQueue = [...(scenario.prListSequence ?? [])]; - const prListQueueByHeadSelector = new Map( - Object.entries(scenario.prListSequenceByHeadSelector ?? {}).map(([headSelector, values]) => [ - headSelector, - [...values], - ]), - ); - const ghCalls: string[] = []; - - const execute: GitHubCliShape["execute"] = (input) => { - const args = [...input.args]; - ghCalls.push(args.join(" ")); - - if (scenario.failWith) { - return Effect.fail(scenario.failWith); - } - - if (args[0] === "pr" && args[1] === "list") { - const headSelectorIndex = args.findIndex((value) => value === "--head"); - const headSelector = - headSelectorIndex >= 0 && headSelectorIndex < args.length - 1 - ? args[headSelectorIndex + 1] - : undefined; - const mappedQueue = - typeof headSelector === "string" - ? prListQueueByHeadSelector.get(headSelector)?.shift() - : undefined; - const mappedStdout = - typeof headSelector === "string" - ? scenario.prListByHeadSelector?.[headSelector] - : undefined; - const stdout = (mappedQueue ?? mappedStdout ?? prListQueue.shift() ?? "[]") + "\n"; - return Effect.succeed({ - stdout, - stderr: "", - code: 0, - signal: null, - timedOut: false, - }); - } - - if (args[0] === "pr" && args[1] === "create") { - return Effect.succeed({ - stdout: - (scenario.createdPrUrl ?? "https://github.com/pingdotgg/codething-mvp/pull/101") + "\n", - stderr: "", - code: 0, - signal: null, - timedOut: false, - }); - } - - if (args[0] === "pr" && args[1] === "view") { - const pullRequest: FakePullRequest = scenario.pullRequest ?? { - number: 101, - title: "Pull request", - url: "https://github.com/pingdotgg/codething-mvp/pull/101", - baseRefName: "main", - headRefName: "feature/pull-request", - state: "open", - }; - return Effect.succeed({ - stdout: - JSON.stringify({ - ...pullRequest, - ...(pullRequest.headRepositoryNameWithOwner - ? { - headRepository: { - nameWithOwner: pullRequest.headRepositoryNameWithOwner, - }, - } - : {}), - ...(pullRequest.headRepositoryOwnerLogin - ? { - headRepositoryOwner: { - login: pullRequest.headRepositoryOwnerLogin, - }, - } - : {}), - }) + "\n", - stderr: "", - code: 0, - signal: null, - timedOut: false, - }); - } - - if (args[0] === "pr" && args[1] === "checkout") { - return Effect.try({ - try: () => { - const headBranch = scenario.pullRequest?.headRefName; - if (headBranch) { - const existingBranch = spawnSync( - "git", - ["show-ref", "--verify", "--quiet", `refs/heads/${headBranch}`], - { - cwd: input.cwd, - encoding: "utf8", - }, - ); - if (existingBranch.status === 0) { - runGitSyncForFakeGh(input.cwd, ["checkout", headBranch]); - } else { - runGitSyncForFakeGh(input.cwd, ["checkout", "-b", headBranch]); - } - } - return { - stdout: "", - stderr: "", - code: 0, - signal: null, - timedOut: false, - }; - }, - catch: (error) => - isGitHubCliError(error) - ? error - : new GitHubCliError({ - operation: "execute", - detail: - error instanceof Error - ? `Failed to simulate gh checkout: ${error.message}` - : "Failed to simulate gh checkout.", - }), - }); - } - - if (args[0] === "repo" && args[1] === "view") { - const repository = args[2]; - if (typeof repository === "string" && args.includes("nameWithOwner,url,sshUrl")) { - const cloneUrls = scenario.repositoryCloneUrls?.[repository]; - if (!cloneUrls) { - return Effect.fail( - new GitHubCliError({ - operation: "execute", - detail: `Unexpected repository lookup: ${repository}`, - }), - ); - } - return Effect.succeed({ - stdout: - JSON.stringify({ - nameWithOwner: repository, - url: cloneUrls.url, - sshUrl: cloneUrls.sshUrl, - }) + "\n", - stderr: "", - code: 0, - signal: null, - timedOut: false, - }); - } - return Effect.succeed({ - stdout: `${scenario.defaultBranch ?? "main"}\n`, - stderr: "", - code: 0, - signal: null, - timedOut: false, - }); - } - - return Effect.fail( - new GitHubCliError({ - operation: "execute", - detail: `Unexpected gh command: ${args.join(" ")}`, - }), - ); - }; - - return { - service: { - execute, - listOpenPullRequests: (input) => - execute({ - cwd: input.cwd, - args: [ - "pr", - "list", - "--head", - input.headSelector, - "--state", - "open", - "--limit", - String(input.limit ?? 1), - "--json", - "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", - ], - }).pipe( - Effect.map((result) => JSON.parse(result.stdout) as unknown[]), - Effect.map((raw) => - raw - .map((entry) => normalizeFakePullRequestSummary(entry)) - .filter((entry): entry is GitHubPullRequestSummary => entry !== null), - ), - ), - createPullRequest: (input) => - execute({ - cwd: input.cwd, - args: [ - "pr", - "create", - "--base", - input.baseBranch, - "--head", - input.headSelector, - "--title", - input.title, - "--body-file", - input.bodyFile, - ], - }).pipe(Effect.asVoid), - getDefaultBranch: (input) => - execute({ - cwd: input.cwd, - args: ["repo", "view", "--json", "defaultBranchRef", "--jq", ".defaultBranchRef.name"], - }).pipe( - Effect.map((result) => { - const value = result.stdout.trim(); - return value.length > 0 ? value : null; - }), - ), - getPullRequest: (input) => - execute({ - cwd: input.cwd, - args: [ - "pr", - "view", - input.reference, - "--json", - "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", - ], - }).pipe(Effect.map((result) => JSON.parse(result.stdout) as GitHubPullRequestSummary)), - getRepositoryCloneUrls: (input) => - execute({ - cwd: input.cwd, - args: ["repo", "view", input.repository, "--json", "nameWithOwner,url,sshUrl"], - }).pipe(Effect.map((result) => JSON.parse(result.stdout))), - checkoutPullRequest: (input) => - execute({ - cwd: input.cwd, - args: ["pr", "checkout", input.reference, ...(input.force ? ["--force"] : [])], - }).pipe(Effect.asVoid), - }, - ghCalls, - }; -} - -function runStackedAction( - manager: GitManagerShape, - input: { - cwd: string; - action: "commit" | "push" | "create_pr" | "commit_push" | "commit_push_pr"; - actionId?: string; - commitMessage?: string; - featureBranch?: boolean; - filePaths?: readonly string[]; - }, - options?: Parameters[1], -) { - return manager.runStackedAction( - { - ...input, - actionId: input.actionId ?? "test-action-id", - }, - options, - ); -} - -function resolvePullRequest(manager: GitManagerShape, input: { cwd: string; reference: string }) { - return manager.resolvePullRequest(input); -} - -function preparePullRequestThread( - manager: GitManagerShape, - input: GitPreparePullRequestThreadInput, -) { - return manager.preparePullRequestThread(input); -} - -function makeManager(input?: { - ghScenario?: FakeGhScenario; - textGeneration?: Partial; - setupScriptRunner?: ProjectSetupScriptRunnerShape; -}) { - const { service: gitHubCli, ghCalls } = createGitHubCliWithFakeGh(input?.ghScenario); - const textGeneration = createTextGeneration(input?.textGeneration); - const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { - prefix: "t3-git-manager-test-", - }); - - const serverSettingsLayer = ServerSettingsService.layerTest(); - - const gitCoreLayer = GitCoreLive.pipe( - Layer.provideMerge(NodeServices.layer), - Layer.provideMerge(ServerConfigLayer), - ); - - const managerLayer = Layer.mergeAll( - Layer.succeed(GitHubCli, gitHubCli), - Layer.succeed(TextGeneration, textGeneration), - Layer.succeed( - ProjectSetupScriptRunner, - input?.setupScriptRunner ?? { - runForThread: () => Effect.succeed({ status: "no-script" as const }), - }, - ), - gitCoreLayer, - serverSettingsLayer, - ).pipe(Layer.provideMerge(NodeServices.layer)); - - return makeGitManager().pipe( - Effect.provide(managerLayer), - Effect.map((manager) => ({ manager, ghCalls })), - ); -} - -const asThreadId = (threadId: string) => threadId as ThreadId; - -const GitManagerTestLayer = GitCoreLive.pipe( - Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-manager-test-" })), - Layer.provideMerge(NodeServices.layer), -); - -it.layer(GitManagerTestLayer)("GitManager", (it) => { - it.effect("status includes PR metadata when branch already has an open PR", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "feature/status-open-pr"]); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-open-pr"]); - - const { manager } = yield* makeManager({ - ghScenario: { - prListSequence: [ - JSON.stringify([ - { - number: 13, - title: "Existing PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/13", - baseRefName: "main", - headRefName: "feature/status-open-pr", - }, - ]), - ], - }, - }); - - const status = yield* manager.status({ cwd: repoDir }); - expect(status.isRepo).toBe(true); - expect(status.hasOriginRemote).toBe(true); - expect(status.isDefaultBranch).toBe(false); - expect(status.branch).toBe("feature/status-open-pr"); - expect(status.pr).toEqual({ - number: 13, - title: "Existing PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/13", - baseBranch: "main", - headBranch: "feature/status-open-pr", - state: "open", - }); - }), - ); - - it.effect("status trims PR metadata returned by gh before publishing it", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "feature/status-trimmed-pr"]); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-trimmed-pr"]); - - const { manager } = yield* makeManager({ - ghScenario: { - prListSequence: [ - JSON.stringify([ - { - number: 14, - title: " Existing PR title \n", - url: " https://github.com/pingdotgg/codething-mvp/pull/14 ", - baseRefName: " main ", - headRefName: "\tfeature/status-trimmed-pr\t", - }, - ]), - ], - }, - }); - - const status = yield* manager.status({ cwd: repoDir }); - - expect(status.pr).toEqual({ - number: 14, - title: "Existing PR title", - url: "https://github.com/pingdotgg/codething-mvp/pull/14", - baseBranch: "main", - headBranch: "feature/status-trimmed-pr", - state: "open", - }); - }), - ); - - it.effect("status ignores invalid gh pr list entries and keeps valid ones", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "feature/status-valid-pr-entry"]); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-valid-pr-entry"]); - - const { manager } = yield* makeManager({ - ghScenario: { - prListSequence: [ - JSON.stringify([ - { - number: 0, - title: "invalid", - url: "https://github.com/pingdotgg/codething-mvp/pull/0", - baseRefName: "main", - headRefName: "feature/invalid", - }, - { - number: 15, - title: " Valid PR title ", - url: " https://github.com/pingdotgg/codething-mvp/pull/15 ", - baseRefName: " main ", - headRefName: "\tfeature/status-valid-pr-entry\t", - headRepository: { - nameWithOwner: " ", - }, - headRepositoryOwner: { - login: " ", - }, - }, - ]), - ], - }, - }); - - const status = yield* manager.status({ cwd: repoDir }); - - expect(status.pr).toEqual({ - number: 15, - title: "Valid PR title", - url: "https://github.com/pingdotgg/codething-mvp/pull/15", - baseBranch: "main", - headBranch: "feature/status-valid-pr-entry", - state: "open", - }); - }), - ); - - it.effect("status preserves lowercase merged and closed PR states from gh json", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "feature/status-lowercase-state"]); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-lowercase-state"]); - - const { manager } = yield* makeManager({ - ghScenario: { - prListSequence: [ - JSON.stringify([ - { - number: 16, - title: "Closed PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/16", - baseRefName: "main", - headRefName: "feature/status-lowercase-state", - state: "closed", - updatedAt: "2026-01-01T00:00:00.000Z", - }, - { - number: 17, - title: "Merged PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/17", - baseRefName: "main", - headRefName: "feature/status-lowercase-state", - state: "merged", - updatedAt: "2026-01-02T00:00:00.000Z", - }, - ]), - ], - }, - }); - - const status = yield* manager.status({ cwd: repoDir }); - - expect(status.pr).toEqual({ - number: 17, - title: "Merged PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/17", - baseBranch: "main", - headBranch: "feature/status-lowercase-state", - state: "merged", - }); - }), - ); - - it.effect("status returns an explicit non-repo result for non-git directories", () => - Effect.gen(function* () { - const cwd = yield* makeTempDir("t3code-git-manager-non-repo-"); - const { manager } = yield* makeManager(); - - const status = yield* manager.status({ cwd }); - - expect(status).toEqual({ - isRepo: false, - hasOriginRemote: false, - isDefaultBranch: false, - branch: null, - hasWorkingTreeChanges: false, - workingTree: { - files: [], - insertions: 0, - deletions: 0, - }, - hasUpstream: false, - aheadCount: 0, - behindCount: 0, - pr: null, - }); - }), - ); - - it.effect("status returns an explicit non-repo result for deleted directories", () => - Effect.gen(function* () { - const rootDir = yield* makeTempDir("t3code-git-manager-missing-dir-"); - const cwd = path.join(rootDir, "deleted-repo"); - yield* makeDirectory(cwd); - yield* removePath(cwd); - const { manager } = yield* makeManager(); - - const status = yield* manager.status({ cwd }); - - expect(status).toEqual({ - isRepo: false, - hasOriginRemote: false, - isDefaultBranch: false, - branch: null, - hasWorkingTreeChanges: false, - workingTree: { - files: [], - insertions: 0, - deletions: 0, - }, - hasUpstream: false, - aheadCount: 0, - behindCount: 0, - pr: null, - }); - }), - ); - - it.effect("status briefly caches repeated lookups for the same cwd", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "feature/status-cache"]); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-cache"]); - - const existingPr = { - number: 113, - title: "Cached PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/113", - baseRefName: "main", - headRefName: "feature/status-cache", - }; - const { manager, ghCalls } = yield* makeManager({ - ghScenario: { - prListSequence: [JSON.stringify([existingPr]), JSON.stringify([existingPr])], - }, - }); - - const first = yield* manager.status({ cwd: repoDir }); - const second = yield* manager.status({ cwd: repoDir }); - - expect(first.pr?.number).toBe(113); - expect(second.pr?.number).toBe(113); - expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(1); - }), - ); - - it.effect( - "status ignores unrelated fork PRs when the current branch tracks the same repository", - () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - yield* runGit(repoDir, ["push", "-u", "origin", "main"]); - - const { manager } = yield* makeManager({ - ghScenario: { - prListSequence: [ - JSON.stringify([ - { - number: 1661, - title: "Fork PR from main", - url: "https://github.com/pingdotgg/t3code/pull/1661", - baseRefName: "main", - headRefName: "main", - state: "OPEN", - updatedAt: "2026-04-01T15:00:00Z", - isCrossRepository: true, - headRepository: { - nameWithOwner: "lnieuwenhuis/t3code", - }, - headRepositoryOwner: { - login: "lnieuwenhuis", - }, - }, - ]), - ], - }, - }); - - const status = yield* manager.status({ cwd: repoDir }); - expect(status.branch).toBe("main"); - expect(status.pr).toBeNull(); - }), - ); - - it.effect( - "status detects cross-repo PRs from the upstream remote URL owner", - () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - const forkDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); - yield* runGit(repoDir, ["checkout", "-b", "statemachine"]); - fs.writeFileSync(path.join(repoDir, "fork-pr.txt"), "fork pr\n"); - yield* runGit(repoDir, ["add", "fork-pr.txt"]); - yield* runGit(repoDir, ["commit", "-m", "Fork PR branch"]); - yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]); - yield* runGit(repoDir, ["checkout", "-b", "t3code/pr-488/statemachine"]); - yield* runGit(repoDir, ["branch", "--set-upstream-to", "fork-seed/statemachine"]); - yield* runGit(repoDir, [ - "config", - "remote.fork-seed.url", - "git@github.com:jasonLaster/codething-mvp.git", - ]); - - const { manager, ghCalls } = yield* makeManager({ - ghScenario: { - prListSequence: [ - JSON.stringify([]), - JSON.stringify([]), - JSON.stringify([ - { - number: 488, - title: "Rebase this PR on latest main", - url: "https://github.com/pingdotgg/codething-mvp/pull/488", - baseRefName: "main", - headRefName: "statemachine", - state: "OPEN", - updatedAt: "2026-03-10T07:00:00Z", - isCrossRepository: true, - headRepository: { - nameWithOwner: "jasonLaster/codething-mvp", - }, - headRepositoryOwner: { - login: "jasonLaster", - }, - }, - ]), - ], - }, - }); - - const status = yield* manager.status({ cwd: repoDir }); - expect(status.branch).toBe("t3code/pr-488/statemachine"); - expect(status.pr).toEqual({ - number: 488, - title: "Rebase this PR on latest main", - url: "https://github.com/pingdotgg/codething-mvp/pull/488", - baseBranch: "main", - headBranch: "statemachine", - state: "open", - }); - expect(ghCalls).toContain( - "pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", - ); - }), - 20_000, - ); - - it.effect( - "status ignores synthetic local branch aliases when the upstream remote name contains slashes", - () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - const originDir = yield* createBareRemote(); - const upstreamDir = yield* createBareRemote(); - yield* configureRemote(repoDir, "origin", originDir, "origin"); - yield* configureRemote(repoDir, "my-org/upstream", upstreamDir, "my-org/upstream"); - - yield* runGit(repoDir, ["checkout", "-b", "effect-atom"]); - yield* runGit(repoDir, ["push", "-u", "origin", "effect-atom"]); - yield* runGit(repoDir, ["push", "-u", "my-org/upstream", "effect-atom"]); - yield* runGit(repoDir, [ - "config", - "remote.origin.url", - "git@github.com:pingdotgg/codething-mvp.git", - ]); - yield* runGit(repoDir, ["config", "remote.origin.pushurl", originDir]); - yield* runGit(repoDir, [ - "config", - "remote.my-org/upstream.url", - "git@github.com:pingdotgg/codething-mvp.git", - ]); - yield* runGit(repoDir, ["config", "remote.my-org/upstream.pushurl", upstreamDir]); - yield* runGit(repoDir, ["checkout", "main"]); - yield* runGit(repoDir, ["branch", "-D", "effect-atom"]); - yield* runGit(repoDir, ["checkout", "--track", "my-org/upstream/effect-atom"]); - - const { manager, ghCalls } = yield* makeManager({ - ghScenario: { - prListByHeadSelector: { - "effect-atom": JSON.stringify([ - { - number: 1618, - title: "Correct PR", - url: "https://github.com/pingdotgg/t3code/pull/1618", - baseRefName: "main", - headRefName: "effect-atom", - state: "OPEN", - updatedAt: "2026-03-01T10:00:00Z", - }, - ]), - "upstream/effect-atom": JSON.stringify([ - { - number: 1518, - title: "Wrong PR", - url: "https://github.com/pingdotgg/t3code/pull/1518", - baseRefName: "main", - headRefName: "upstream/effect-atom", - state: "OPEN", - updatedAt: "2026-04-01T10:00:00Z", - }, - ]), - "pingdotgg:effect-atom": JSON.stringify([]), - "my-org/upstream:effect-atom": JSON.stringify([]), - "pingdotgg:upstream/effect-atom": JSON.stringify([ - { - number: 1518, - title: "Wrong PR", - url: "https://github.com/pingdotgg/t3code/pull/1518", - baseRefName: "main", - headRefName: "upstream/effect-atom", - state: "OPEN", - updatedAt: "2026-04-01T10:00:00Z", - }, - ]), - "my-org/upstream:upstream/effect-atom": JSON.stringify([ - { - number: 1518, - title: "Wrong PR", - url: "https://github.com/pingdotgg/t3code/pull/1518", - baseRefName: "main", - headRefName: "upstream/effect-atom", - state: "OPEN", - updatedAt: "2026-04-01T10:00:00Z", - }, - ]), - }, - }, - }); - - const status = yield* manager.status({ cwd: repoDir }); - expect(status.branch).toBe("upstream/effect-atom"); - expect(status.pr).toEqual({ - number: 1618, - title: "Correct PR", - url: "https://github.com/pingdotgg/t3code/pull/1618", - baseBranch: "main", - headBranch: "effect-atom", - state: "open", - }); - expect(ghCalls.some((call) => call.includes("pr list --head upstream/effect-atom "))).toBe( - false, - ); - expect( - ghCalls.some((call) => call.includes("pr list --head pingdotgg:upstream/effect-atom ")), - ).toBe(false); - expect( - ghCalls.some((call) => - call.includes("pr list --head my-org/upstream:upstream/effect-atom "), - ), - ).toBe(false); - }), - 20_000, - ); - - it.effect("status returns merged PR state when latest PR was merged", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "feature/status-merged-pr"]); - - const { manager } = yield* makeManager({ - ghScenario: { - prListSequence: [ - JSON.stringify([ - { - number: 22, - title: "Merged PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/22", - baseRefName: "main", - headRefName: "feature/status-merged-pr", - state: "MERGED", - mergedAt: "2026-01-30T10:00:00Z", - updatedAt: "2026-01-30T10:00:00Z", - }, - ]), - ], - }, - }); - - const status = yield* manager.status({ cwd: repoDir }); - expect(status.branch).toBe("feature/status-merged-pr"); - expect(status.pr).toEqual({ - number: 22, - title: "Merged PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/22", - baseBranch: "main", - headBranch: "feature/status-merged-pr", - state: "merged", - }); - }), - ); - - it.effect("status hides merged PRs on the default branch", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - - const { manager } = yield* makeManager({ - ghScenario: { - prListSequence: [ - JSON.stringify([ - { - number: 23, - title: "Merged PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/23", - baseRefName: "feature/status-default-branch-target", - headRefName: "main", - state: "MERGED", - mergedAt: "2026-01-30T10:00:00Z", - updatedAt: "2026-01-30T10:00:00Z", - }, - ]), - ], - }, - }); - - const status = yield* manager.status({ cwd: repoDir }); - expect(status.branch).toBe("main"); - expect(status.pr).toBeNull(); - }), - ); - - it.effect("status prefers open PR when merged PR has newer updatedAt", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "feature/status-open-over-merged"]); - - const { manager } = yield* makeManager({ - ghScenario: { - prListSequence: [ - JSON.stringify([ - { - number: 45, - title: "Merged PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/45", - baseRefName: "main", - headRefName: "feature/status-open-over-merged", - state: "MERGED", - mergedAt: "2026-01-31T10:00:00Z", - updatedAt: "2026-02-01T10:00:00Z", - }, - { - number: 46, - title: "Open PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/46", - baseRefName: "main", - headRefName: "feature/status-open-over-merged", - state: "OPEN", - updatedAt: "2026-01-30T10:00:00Z", - }, - ]), - ], - }, - }); - - const status = yield* manager.status({ cwd: repoDir }); - expect(status.branch).toBe("feature/status-open-over-merged"); - expect(status.pr).toEqual({ - number: 46, - title: "Open PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/46", - baseBranch: "main", - headBranch: "feature/status-open-over-merged", - state: "open", - }); - }), - ); - - it.effect("status is resilient to gh lookup failures and returns pr null", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "feature/status-no-gh"]); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-no-gh"]); - - const { manager } = yield* makeManager({ - ghScenario: { - failWith: new GitHubCliError({ - operation: "execute", - detail: "GitHub CLI (`gh`) is required but not available on PATH.", - }), - }, - }); - - const status = yield* manager.status({ cwd: repoDir }); - expect(status.branch).toBe("feature/status-no-gh"); - expect(status.pr).toBeNull(); - }), - ); - - it.effect("creates a commit when working tree is dirty", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - fs.writeFileSync(path.join(repoDir, "README.md"), "hello\nworld\n"); - - const { manager } = yield* makeManager(); - const result = yield* runStackedAction(manager, { - cwd: repoDir, - action: "commit", - }); - - expect(result.branch.status).toBe("skipped_not_requested"); - expect(result.commit.status).toBe("created"); - expect(result.push.status).toBe("skipped_not_requested"); - expect(result.pr.status).toBe("skipped_not_requested"); - expect(result.toast).toMatchObject({ - description: "Implement stacked git actions", - cta: { - kind: "run_action", - label: "Push", - action: { - kind: "push", - }, - }, - }); - expect(result.toast.title).toMatch(/^Committed [0-9a-f]{7}$/); - expect( - yield* runGit(repoDir, ["log", "-1", "--pretty=%s"]).pipe( - Effect.map((result) => result.stdout.trim()), - ), - ).toBe("Implement stacked git actions"); - }), - ); - - it.effect("uses custom commit message when provided", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - fs.writeFileSync(path.join(repoDir, "README.md"), "hello\ncustom\n"); - let generatedCount = 0; - - const { manager } = yield* makeManager({ - textGeneration: { - generateCommitMessage: (input) => - Effect.sync(() => { - generatedCount += 1; - return { - subject: "this should not be used", - body: "", - ...(input.includeBranch ? { branch: "feature/unused" } : {}), - }; - }), - }, - }); - const result = yield* runStackedAction(manager, { - cwd: repoDir, - action: "commit", - commitMessage: "feat: custom summary line\n\n- details from user", - }); - - expect(result.branch.status).toBe("skipped_not_requested"); - expect(result.commit.status).toBe("created"); - expect(result.commit.subject).toBe("feat: custom summary line"); - expect(generatedCount).toBe(0); - expect( - yield* runGit(repoDir, ["log", "-1", "--pretty=%s"]).pipe( - Effect.map((result) => result.stdout.trim()), - ), - ).toBe("feat: custom summary line"); - expect( - yield* runGit(repoDir, ["log", "-1", "--pretty=%b"]).pipe( - Effect.map((result) => result.stdout.trim()), - ), - ).toContain("- details from user"); - }), - ); - - it.effect("commits only selected files when filePaths is provided", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - fs.writeFileSync(path.join(repoDir, "a.txt"), "file a\n"); - fs.writeFileSync(path.join(repoDir, "b.txt"), "file b\n"); - - const { manager } = yield* makeManager(); - const result = yield* runStackedAction(manager, { - cwd: repoDir, - action: "commit", - filePaths: ["a.txt"], - }); - - expect(result.commit.status).toBe("created"); - - // b.txt should remain in the working tree - const statusStdout = yield* runGit(repoDir, ["status", "--porcelain"]).pipe( - Effect.map((r) => r.stdout), - ); - expect(statusStdout).toContain("b.txt"); - expect(statusStdout).not.toContain("a.txt"); - }), - ); - - it.effect("creates feature branch, commits, and pushes with featureBranch option", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - yield* runGit(repoDir, ["push", "-u", "origin", "main"]); - fs.writeFileSync(path.join(repoDir, "README.md"), "hello\nfeature-branch\n"); - let generatedCount = 0; - - const { manager } = yield* makeManager({ - textGeneration: { - generateCommitMessage: (input) => - Effect.sync(() => { - generatedCount += 1; - return { - subject: "Implement stacked git actions", - body: "", - ...(input.includeBranch ? { branch: "feature/implement-stacked-git-actions" } : {}), - }; - }), - }, - }); - const result = yield* runStackedAction(manager, { - cwd: repoDir, - action: "commit_push", - featureBranch: true, - }); - - expect(result.branch.status).toBe("created"); - expect(result.branch.name).toBe("feature/implement-stacked-git-actions"); - expect(result.commit.status).toBe("created"); - expect(result.push.status).toBe("pushed"); - expect(result.toast).toMatchObject({ - description: "Implement stacked git actions", - cta: { - kind: "run_action", - label: "Create PR", - action: { - kind: "create_pr", - }, - }, - }); - expect(result.toast.title).toMatch( - /^Pushed [0-9a-f]{7} to origin\/feature\/implement-stacked-git-actions$/, - ); - expect( - yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "HEAD"]).pipe( - Effect.map((result) => result.stdout.trim()), - ), - ).toBe("feature/implement-stacked-git-actions"); - - const mainSha = yield* runGit(repoDir, ["rev-parse", "main"]).pipe( - Effect.map((r) => r.stdout.trim()), - ); - const mergeBase = yield* runGit(repoDir, ["merge-base", "main", "HEAD"]).pipe( - Effect.map((r) => r.stdout.trim()), - ); - expect(mergeBase).toBe(mainSha); - expect(generatedCount).toBe(1); - }), - ); - - it.effect("featureBranch uses custom commit message and derives branch name", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - fs.writeFileSync(path.join(repoDir, "README.md"), "hello\ncustom-feature\n"); - let generatedCount = 0; - - const { manager } = yield* makeManager({ - textGeneration: { - generateCommitMessage: (input) => - Effect.sync(() => { - generatedCount += 1; - return { - subject: "unused", - body: "", - ...(input.includeBranch ? { branch: "feature/unused" } : {}), - }; - }), - }, - }); - const result = yield* runStackedAction(manager, { - cwd: repoDir, - action: "commit", - featureBranch: true, - commitMessage: "feat: custom summary line\n\n- details from user", - }); - - expect(result.branch.status).toBe("created"); - expect(result.branch.name).toBe("feature/feat-custom-summary-line"); - expect(result.commit.status).toBe("created"); - expect(result.commit.subject).toBe("feat: custom summary line"); - expect(generatedCount).toBe(0); - - const mainSha = yield* runGit(repoDir, ["rev-parse", "main"]).pipe( - Effect.map((r) => r.stdout.trim()), - ); - const mergeBase = yield* runGit(repoDir, ["merge-base", "main", result.branch.name!]).pipe( - Effect.map((r) => r.stdout.trim()), - ); - expect(mergeBase).toBe(mainSha); - }), - ); - - it.effect("skips commit when there are no uncommitted changes", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - - const { manager } = yield* makeManager(); - const result = yield* runStackedAction(manager, { - cwd: repoDir, - action: "commit", - }); - - expect(result.branch.status).toBe("skipped_not_requested"); - expect(result.commit.status).toBe("skipped_no_changes"); - expect(result.push.status).toBe("skipped_not_requested"); - expect(result.pr.status).toBe("skipped_not_requested"); - }), - ); - - it.effect("featureBranch returns error when worktree is clean", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - - const { manager } = yield* makeManager(); - const errorMessage = yield* runStackedAction(manager, { - cwd: repoDir, - action: "commit", - featureBranch: true, - }).pipe( - Effect.flip, - Effect.map((error) => error.message), - ); - - expect(errorMessage).toContain("no changes to commit"); - }), - ); - - it.effect("commits and pushes with upstream auto-setup when needed", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "feature/stacked-flow"]); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - fs.writeFileSync(path.join(repoDir, "feature.txt"), "feature\n"); - - const { manager } = yield* makeManager(); - const result = yield* runStackedAction(manager, { - cwd: repoDir, - action: "commit_push", - }); - - expect(result.branch.status).toBe("skipped_not_requested"); - expect(result.commit.status).toBe("created"); - expect(result.push.status).toBe("pushed"); - expect(result.push.setUpstream).toBe(true); - expect( - yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"]).pipe( - Effect.map((result) => result.stdout.trim()), - ), - ).toBe("origin/feature/stacked-flow"); - }), - ); - - it.effect( - "pushes and creates PR from a no-upstream branch when local commits are ahead of base", - () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "feature/no-upstream-pr"]); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - fs.writeFileSync(path.join(repoDir, "feature.txt"), "feature\n"); - - const { manager, ghCalls } = yield* makeManager({ - ghScenario: { - prListSequence: [ - "[]", - JSON.stringify([ - { - number: 77, - title: "Add no-upstream PR flow", - url: "https://github.com/pingdotgg/codething-mvp/pull/77", - baseRefName: "main", - headRefName: "feature/no-upstream-pr", - }, - ]), - ], - }, - }); - - const result = yield* runStackedAction(manager, { - cwd: repoDir, - action: "commit_push_pr", - }); - - expect(result.branch.status).toBe("skipped_not_requested"); - expect(result.commit.status).toBe("created"); - expect(result.push.status).toBe("pushed"); - expect(result.push.setUpstream).toBe(true); - expect(result.pr.status).toBe("created"); - expect( - yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"]).pipe( - Effect.map((result) => result.stdout.trim()), - ), - ).toBe("origin/feature/no-upstream-pr"); - expect( - ghCalls.some((call) => - call.includes("pr create --base main --head feature/no-upstream-pr"), - ), - ).toBe(true); - }), - ); - - it.effect("skips push when branch is already up to date", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "feature/up-to-date"]); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - yield* runGit(repoDir, ["push", "-u", "origin", "feature/up-to-date"]); - - const { manager } = yield* makeManager(); - const result = yield* runStackedAction(manager, { - cwd: repoDir, - action: "commit_push", - }); - - expect(result.branch.status).toBe("skipped_not_requested"); - expect(result.commit.status).toBe("skipped_no_changes"); - expect(result.push.status).toBe("skipped_up_to_date"); - }), - ); - - it.effect("pushes existing clean commits without rerunning commit logic", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "feature/push-only"]); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - fs.writeFileSync(path.join(repoDir, "push-only.txt"), "push only\n"); - yield* runGit(repoDir, ["add", "push-only.txt"]); - yield* runGit(repoDir, ["commit", "-m", "Push only branch"]); - - const { manager } = yield* makeManager(); - const result = yield* runStackedAction(manager, { - cwd: repoDir, - action: "push", - }); - - expect(result.commit.status).toBe("skipped_not_requested"); - expect(result.push.status).toBe("pushed"); - expect(result.pr.status).toBe("skipped_not_requested"); - expect( - yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"]).pipe( - Effect.map((output) => output.stdout.trim()), - ), - ).toBe("origin/feature/push-only"); - }), - ); - - it.effect("create_pr pushes a clean branch before creating the PR when needed", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "feature/create-pr-only"]); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - fs.writeFileSync(path.join(repoDir, "create-pr-only.txt"), "create pr\n"); - yield* runGit(repoDir, ["add", "create-pr-only.txt"]); - yield* runGit(repoDir, ["commit", "-m", "Create PR only branch"]); - - const { manager, ghCalls } = yield* makeManager({ - ghScenario: { - prListSequence: [ - "[]", - JSON.stringify([ - { - number: 303, - title: "Create PR only branch", - url: "https://github.com/pingdotgg/codething-mvp/pull/303", - baseRefName: "main", - headRefName: "feature/create-pr-only", - }, - ]), - ], - }, - }); - - const result = yield* runStackedAction(manager, { - cwd: repoDir, - action: "create_pr", - }); - - expect(result.commit.status).toBe("skipped_not_requested"); - expect(result.push.status).toBe("pushed"); - expect(result.push.setUpstream).toBe(true); - expect(result.pr.status).toBe("created"); - expect(result.pr.number).toBe(303); - expect( - ghCalls.some((call) => - call.includes("pr create --base main --head feature/create-pr-only"), - ), - ).toBe(true); - }), - ); - - it.effect("returns existing PR metadata for commit/push/pr action", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "feature/existing-pr"]); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - yield* runGit(repoDir, ["push", "-u", "origin", "feature/existing-pr"]); - - const { manager, ghCalls } = yield* makeManager({ - ghScenario: { - prListSequence: [ - JSON.stringify([ - { - number: 42, - title: "Existing PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/42", - baseRefName: "main", - headRefName: "feature/existing-pr", - }, - ]), - ], - }, - }); - const result = yield* runStackedAction(manager, { - cwd: repoDir, - action: "commit_push_pr", - }); - - expect(result.branch.status).toBe("skipped_not_requested"); - expect(result.pr.status).toBe("opened_existing"); - expect(result.pr.number).toBe(42); - expect(result.toast).toEqual({ - title: "Opened PR #42", - description: "Existing PR", - cta: { - kind: "open_pr", - label: "View PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/42", - }, - }); - expect(ghCalls.some((call) => call.startsWith("pr view "))).toBe(false); - }), - ); - - it.effect( - "returns existing cross-repo PR metadata using the fork owner selector", - () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "statemachine"]); - const forkDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); - yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]); - yield* runGit(repoDir, [ - "config", - "remote.fork-seed.url", - "git@github.com:octocat/codething-mvp.git", - ]); - - const { manager, ghCalls } = yield* makeManager({ - ghScenario: { - prListSequence: [ - JSON.stringify([]), - JSON.stringify([ - { - number: 142, - title: "Existing fork PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/142", - baseRefName: "main", - headRefName: "statemachine", - state: "OPEN", - isCrossRepository: true, - headRepository: { - nameWithOwner: "octocat/codething-mvp", - }, - headRepositoryOwner: { - login: "octocat", - }, - }, - ]), - ], - }, - }); - - const result = yield* runStackedAction(manager, { - cwd: repoDir, - action: "commit_push_pr", - }); - - expect(result.pr.status).toBe("opened_existing"); - expect(result.pr.number).toBe(142); - expect( - ghCalls.some((call) => - call.includes("pr list --head octocat:statemachine --state open --limit 1"), - ), - ).toBe(true); - expect(ghCalls.some((call) => call.startsWith("pr create "))).toBe(false); - }), - 12_000, - ); - - it.effect( - "returns the correct existing PR when a slash remote checks out to a synthetic local alias", - () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - const originDir = yield* createBareRemote(); - const upstreamDir = yield* createBareRemote(); - yield* configureRemote(repoDir, "origin", originDir, "origin"); - yield* configureRemote(repoDir, "my-org/upstream", upstreamDir, "my-org/upstream"); - - yield* runGit(repoDir, ["checkout", "-b", "effect-atom"]); - yield* runGit(repoDir, ["push", "-u", "origin", "effect-atom"]); - yield* runGit(repoDir, ["push", "-u", "my-org/upstream", "effect-atom"]); - yield* runGit(repoDir, [ - "config", - "remote.origin.url", - "git@github.com:pingdotgg/codething-mvp.git", - ]); - yield* runGit(repoDir, ["config", "remote.origin.pushurl", originDir]); - yield* runGit(repoDir, [ - "config", - "remote.my-org/upstream.url", - "git@github.com:pingdotgg/codething-mvp.git", - ]); - yield* runGit(repoDir, ["config", "remote.my-org/upstream.pushurl", upstreamDir]); - yield* runGit(repoDir, ["checkout", "main"]); - yield* runGit(repoDir, ["branch", "-D", "effect-atom"]); - yield* runGit(repoDir, ["checkout", "--track", "my-org/upstream/effect-atom"]); - fs.writeFileSync(path.join(repoDir, "changes.txt"), "change\n"); - yield* runGit(repoDir, ["add", "changes.txt"]); - yield* runGit(repoDir, ["commit", "-m", "Feature commit"]); - - const { manager, ghCalls } = yield* makeManager({ - ghScenario: { - prListByHeadSelector: { - "effect-atom": JSON.stringify([ - { - number: 1618, - title: "Correct PR", - url: "https://github.com/pingdotgg/t3code/pull/1618", - baseRefName: "main", - headRefName: "effect-atom", - }, - ]), - "upstream/effect-atom": JSON.stringify([ - { - number: 1518, - title: "Wrong PR", - url: "https://github.com/pingdotgg/t3code/pull/1518", - baseRefName: "main", - headRefName: "upstream/effect-atom", - }, - ]), - "pingdotgg:effect-atom": JSON.stringify([]), - "my-org/upstream:effect-atom": JSON.stringify([]), - "pingdotgg:upstream/effect-atom": JSON.stringify([ - { - number: 1518, - title: "Wrong PR", - url: "https://github.com/pingdotgg/t3code/pull/1518", - baseRefName: "main", - headRefName: "upstream/effect-atom", - }, - ]), - "my-org/upstream:upstream/effect-atom": JSON.stringify([ - { - number: 1518, - title: "Wrong PR", - url: "https://github.com/pingdotgg/t3code/pull/1518", - baseRefName: "main", - headRefName: "upstream/effect-atom", - }, - ]), - }, - }, - }); - - const result = yield* runStackedAction(manager, { - cwd: repoDir, - action: "commit_push_pr", - }); - - expect(result.pr.status).toBe("opened_existing"); - expect(result.pr.number).toBe(1618); - expect(ghCalls.some((call) => call.includes("pr list --head upstream/effect-atom "))).toBe( - false, - ); - }), - 20_000, - ); - - it.effect( - "prefers owner-qualified selectors before bare branch names for cross-repo PRs", - () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "statemachine"]); - const forkDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); - yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]); - yield* runGit(repoDir, ["checkout", "-b", "t3code/pr-142/statemachine"]); - yield* runGit(repoDir, ["branch", "--set-upstream-to", "fork-seed/statemachine"]); - yield* runGit(repoDir, [ - "config", - "remote.fork-seed.url", - "git@github.com:octocat/codething-mvp.git", - ]); - - const { manager, ghCalls } = yield* makeManager({ - ghScenario: { - prListByHeadSelector: { - "t3code/pr-142/statemachine": JSON.stringify([]), - statemachine: JSON.stringify([ - { - number: 41, - title: "Unrelated same-repo PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/41", - baseRefName: "main", - headRefName: "statemachine", - }, - ]), - "octocat:statemachine": JSON.stringify([ - { - number: 142, - title: "Existing fork PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/142", - baseRefName: "main", - headRefName: "statemachine", - state: "OPEN", - isCrossRepository: true, - headRepository: { - nameWithOwner: "octocat/codething-mvp", - }, - headRepositoryOwner: { - login: "octocat", - }, - }, - ]), - "fork-seed:statemachine": JSON.stringify([]), - }, - }, - }); - - const result = yield* runStackedAction(manager, { - cwd: repoDir, - action: "commit_push_pr", - }); - - expect(result.pr.status).toBe("opened_existing"); - expect(result.pr.number).toBe(142); - - const ownerSelectorCallIndex = ghCalls.findIndex((call) => - call.includes("pr list --head octocat:statemachine --state open --limit 1"), - ); - expect(ownerSelectorCallIndex).toBeGreaterThanOrEqual(0); - expect(ghCalls.some((call) => call.startsWith("pr create "))).toBe(false); - }), - 12_000, - ); - - it.effect( - "stops probing head selectors after finding an existing PR", - () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "statemachine"]); - const forkDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); - yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]); - yield* runGit(repoDir, ["checkout", "-b", "t3code/pr-142/statemachine"]); - yield* runGit(repoDir, ["branch", "--set-upstream-to", "fork-seed/statemachine"]); - yield* runGit(repoDir, [ - "config", - "remote.fork-seed.url", - "git@github.com:octocat/codething-mvp.git", - ]); - - const { manager, ghCalls } = yield* makeManager({ - ghScenario: { - prListByHeadSelector: { - "octocat:statemachine": JSON.stringify([ - { - number: 142, - title: "Existing fork PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/142", - baseRefName: "main", - headRefName: "statemachine", - state: "OPEN", - isCrossRepository: true, - headRepository: { - nameWithOwner: "octocat/codething-mvp", - }, - headRepositoryOwner: { - login: "octocat", - }, - }, - ]), - "fork-seed:statemachine": JSON.stringify([]), - "t3code/pr-142/statemachine": JSON.stringify([]), - statemachine: JSON.stringify([]), - }, - }, - }); - - const result = yield* runStackedAction(manager, { - cwd: repoDir, - action: "commit_push_pr", - }); - - expect(result.pr.status).toBe("opened_existing"); - expect(result.pr.number).toBe(142); - - const openLookupCalls = ghCalls.filter((call) => call.includes("--state open --limit 1")); - expect(openLookupCalls).toHaveLength(1); - expect(openLookupCalls[0]).toContain( - "pr list --head octocat:statemachine --state open --limit 1", - ); - }), - 12_000, - ); - - it.effect("creates PR when one does not already exist", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "feature-create-pr"]); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - fs.writeFileSync(path.join(repoDir, "changes.txt"), "change\n"); - yield* runGit(repoDir, ["add", "changes.txt"]); - yield* runGit(repoDir, ["commit", "-m", "Feature commit"]); - yield* runGit(repoDir, ["push", "-u", "origin", "feature-create-pr"]); - yield* runGit(repoDir, ["config", "branch.feature-create-pr.gh-merge-base", "main"]); - - const { manager, ghCalls } = yield* makeManager({ - ghScenario: { - prListSequence: [ - "[]", - JSON.stringify([ - { - number: 88, - title: "Add stacked git actions", - url: "https://github.com/pingdotgg/codething-mvp/pull/88", - baseRefName: "main", - headRefName: "feature-create-pr", - }, - ]), - ], - }, - }); - const result = yield* runStackedAction(manager, { - cwd: repoDir, - action: "commit_push_pr", - }); - - expect(result.branch.status).toBe("skipped_not_requested"); - expect(result.pr.status).toBe("created"); - expect(result.pr.number).toBe(88); - expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(2); - expect( - ghCalls.some((call) => call.includes("pr create --base main --head feature-create-pr")), - ).toBe(true); - expect(ghCalls.some((call) => call.startsWith("pr view "))).toBe(false); - }), - ); - - it.effect( - "creates a new PR instead of reusing an unrelated fork PR with the same head branch", - () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "feature/no-fork-match"]); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - fs.writeFileSync(path.join(repoDir, "changes.txt"), "change\n"); - yield* runGit(repoDir, ["add", "changes.txt"]); - yield* runGit(repoDir, ["commit", "-m", "Feature commit"]); - yield* runGit(repoDir, ["push", "-u", "origin", "feature/no-fork-match"]); - - const { manager, ghCalls } = yield* makeManager({ - ghScenario: { - prListSequence: [ - JSON.stringify([ - { - number: 1661, - title: "Fork PR with same branch name", - url: "https://github.com/pingdotgg/t3code/pull/1661", - baseRefName: "main", - headRefName: "feature/no-fork-match", - state: "OPEN", - isCrossRepository: true, - headRepository: { - nameWithOwner: "lnieuwenhuis/t3code", - }, - headRepositoryOwner: { - login: "lnieuwenhuis", - }, - }, - ]), - JSON.stringify([ - { - number: 188, - title: "Add stacked git actions", - url: "https://github.com/pingdotgg/codething-mvp/pull/188", - baseRefName: "main", - headRefName: "feature/no-fork-match", - state: "OPEN", - isCrossRepository: false, - }, - ]), - ], - }, - }); - const result = yield* runStackedAction(manager, { - cwd: repoDir, - action: "commit_push_pr", - }); - - expect(result.pr.status).toBe("created"); - expect(result.pr.number).toBe(188); - expect(result.toast).toEqual({ - title: "Created PR #188", - description: "Add stacked git actions", - cta: { - kind: "open_pr", - label: "View PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/188", - }, - }); - expect( - ghCalls.some((call) => - call.includes("pr create --base main --head feature/no-fork-match"), - ), - ).toBe(true); - }), - ); - - it.effect("creates cross-repo PRs with the fork owner selector and default base branch", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - const forkDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); - yield* runGit(repoDir, ["checkout", "-b", "statemachine"]); - fs.writeFileSync(path.join(repoDir, "changes.txt"), "change\n"); - yield* runGit(repoDir, ["add", "changes.txt"]); - yield* runGit(repoDir, ["commit", "-m", "Feature commit"]); - yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]); - yield* runGit(repoDir, ["checkout", "-b", "t3code/pr-91/statemachine"]); - yield* runGit(repoDir, ["branch", "--set-upstream-to", "fork-seed/statemachine"]); - yield* runGit(repoDir, [ - "config", - "remote.fork-seed.url", - "git@github.com:octocat/codething-mvp.git", - ]); - - const { manager, ghCalls } = yield* makeManager({ - ghScenario: { - prListSequenceByHeadSelector: { - "octocat:statemachine": [ - JSON.stringify([]), - JSON.stringify([ - { - number: 188, - title: "Add stacked git actions", - url: "https://github.com/pingdotgg/codething-mvp/pull/188", - baseRefName: "main", - headRefName: "statemachine", - state: "OPEN", - isCrossRepository: true, - headRepository: { - nameWithOwner: "octocat/codething-mvp", - }, - headRepositoryOwner: { - login: "octocat", - }, - }, - ]), - ], - "fork-seed:statemachine": [JSON.stringify([])], - statemachine: [JSON.stringify([])], - }, - }, - }); - - const result = yield* runStackedAction(manager, { - cwd: repoDir, - action: "commit_push_pr", - }); - - expect(result.pr.status).toBe("created"); - expect(result.pr.number).toBe(188); - expect( - ghCalls.some((call) => call.includes("pr create --base main --head octocat:statemachine")), - ).toBe(true); - expect( - ghCalls.some((call) => - call.includes("pr create --base statemachine --head octocat:statemachine"), - ), - ).toBe(false); - }), - ); - - it.effect("rejects push/pr actions from detached HEAD", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "--detach", "HEAD"]); - - const { manager } = yield* makeManager(); - const errorMessage = yield* runStackedAction(manager, { - cwd: repoDir, - action: "commit_push", - }).pipe( - Effect.flip, - Effect.map((error) => error.message), - ); - expect(errorMessage).toContain("detached HEAD"); - }), - ); - - it.effect("surfaces missing gh binary errors", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "feature/gh-missing"]); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - yield* runGit(repoDir, ["push", "-u", "origin", "feature/gh-missing"]); - - const { manager } = yield* makeManager({ - ghScenario: { - failWith: new GitHubCliError({ - operation: "execute", - detail: "GitHub CLI (`gh`) is required but not available on PATH.", - }), - }, - }); - - const errorMessage = yield* runStackedAction(manager, { - cwd: repoDir, - action: "commit_push_pr", - }).pipe( - Effect.flip, - Effect.map((error) => error.message), - ); - expect(errorMessage).toContain("GitHub CLI (`gh`) is required"); - }), - ); - - it.effect("surfaces gh auth errors with guidance", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "feature/gh-auth"]); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - yield* runGit(repoDir, ["push", "-u", "origin", "feature/gh-auth"]); - - const { manager } = yield* makeManager({ - ghScenario: { - failWith: new GitHubCliError({ - operation: "execute", - detail: "GitHub CLI is not authenticated. Run `gh auth login` and retry.", - }), - }, - }); - - const errorMessage = yield* runStackedAction(manager, { - cwd: repoDir, - action: "commit_push_pr", - }).pipe( - Effect.flip, - Effect.map((error) => error.message), - ); - expect(errorMessage).toContain("gh auth login"); - }), - ); - - it.effect("resolves pull requests from #number references", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - - const { manager, ghCalls } = yield* makeManager({ - ghScenario: { - pullRequest: { - number: 42, - title: "Resolve PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/42", - baseRefName: "main", - headRefName: "feature/resolve-pr", - state: "open", - }, - }, - }); - - const result = yield* resolvePullRequest(manager, { - cwd: repoDir, - reference: "#42", - }); - - expect(result.pullRequest).toEqual({ - number: 42, - title: "Resolve PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/42", - baseBranch: "main", - headBranch: "feature/resolve-pr", - state: "open", - }); - expect(ghCalls.some((call) => call.startsWith("pr view 42 "))).toBe(true); - }), - ); - - it.effect("prepares pull request threads in local mode by checking out the PR branch", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "feature/pr-local"]); - fs.writeFileSync(path.join(repoDir, "local.txt"), "local\n"); - yield* runGit(repoDir, ["add", "local.txt"]); - yield* runGit(repoDir, ["commit", "-m", "Local PR branch"]); - - const { manager, ghCalls } = yield* makeManager({ - ghScenario: { - pullRequest: { - number: 64, - title: "Local PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/64", - baseRefName: "main", - headRefName: "feature/pr-local", - state: "open", - }, - }, - }); - - const result = yield* preparePullRequestThread(manager, { - cwd: repoDir, - reference: "#64", - mode: "local", - }); - - expect(result.branch).toBe("feature/pr-local"); - expect(result.worktreePath).toBeNull(); - const branch = (yield* runGit(repoDir, ["branch", "--show-current"])).stdout.trim(); - expect(branch).toBe("feature/pr-local"); - expect(ghCalls).toContain("pr checkout 64 --force"); - }), - ); - - it.effect("prepares pull request threads in worktree mode on the PR head branch", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - yield* runGit(repoDir, ["push", "-u", "origin", "main"]); - yield* runGit(repoDir, ["checkout", "-b", "feature/pr-worktree"]); - fs.writeFileSync(path.join(repoDir, "worktree.txt"), "worktree\n"); - yield* runGit(repoDir, ["add", "worktree.txt"]); - yield* runGit(repoDir, ["commit", "-m", "PR worktree branch"]); - yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-worktree"]); - yield* runGit(repoDir, ["push", "origin", "HEAD:refs/pull/77/head"]); - yield* runGit(repoDir, ["checkout", "main"]); - - const { manager } = yield* makeManager({ - ghScenario: { - pullRequest: { - number: 77, - title: "Worktree PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/77", - baseRefName: "main", - headRefName: "feature/pr-worktree", - state: "open", - }, - }, - }); - - const result = yield* preparePullRequestThread(manager, { - cwd: repoDir, - reference: "77", - mode: "worktree", - }); - - expect(result.branch).toBe("feature/pr-worktree"); - expect(result.worktreePath).not.toBeNull(); - expect(fs.existsSync(result.worktreePath as string)).toBe(true); - const worktreeBranch = (yield* runGit(result.worktreePath as string, [ - "branch", - "--show-current", - ])).stdout.trim(); - expect(worktreeBranch).toBe("feature/pr-worktree"); - }), - ); - - it.effect("launches setup only when creating a new PR worktree", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - yield* runGit(repoDir, ["push", "-u", "origin", "main"]); - yield* runGit(repoDir, ["checkout", "-b", "feature/pr-worktree-setup"]); - fs.writeFileSync(path.join(repoDir, "setup.txt"), "setup\n"); - yield* runGit(repoDir, ["add", "setup.txt"]); - yield* runGit(repoDir, ["commit", "-m", "PR worktree setup branch"]); - yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-worktree-setup"]); - yield* runGit(repoDir, ["push", "origin", "HEAD:refs/pull/177/head"]); - yield* runGit(repoDir, ["checkout", "main"]); - - const setupCalls: ProjectSetupScriptRunnerInput[] = []; - const { manager } = yield* makeManager({ - ghScenario: { - pullRequest: { - number: 177, - title: "Worktree setup PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/177", - baseRefName: "main", - headRefName: "feature/pr-worktree-setup", - state: "open", - }, - }, - setupScriptRunner: { - runForThread: (setupInput) => - Effect.sync(() => { - setupCalls.push(setupInput); - return { status: "no-script" as const }; - }), - }, - }); - - const result = yield* preparePullRequestThread(manager, { - cwd: repoDir, - reference: "177", - mode: "worktree", - threadId: asThreadId("thread-pr-setup"), - }); - - expect(result.worktreePath).not.toBeNull(); - expect(setupCalls).toHaveLength(1); - expect(setupCalls[0]).toEqual({ - threadId: "thread-pr-setup", - projectCwd: repoDir, - worktreePath: result.worktreePath as string, - }); - }), - ); - - it.effect("preserves fork upstream tracking when preparing a worktree PR thread", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - const originDir = yield* createBareRemote(); - const forkDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", originDir]); - yield* runGit(repoDir, ["push", "-u", "origin", "main"]); - yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); - yield* runGit(repoDir, ["checkout", "-b", "feature/pr-fork"]); - fs.writeFileSync(path.join(repoDir, "fork.txt"), "fork\n"); - yield* runGit(repoDir, ["add", "fork.txt"]); - yield* runGit(repoDir, ["commit", "-m", "Fork PR branch"]); - yield* runGit(repoDir, ["push", "-u", "fork-seed", "feature/pr-fork"]); - yield* runGit(repoDir, ["checkout", "main"]); - - const { manager } = yield* makeManager({ - ghScenario: { - pullRequest: { - number: 81, - title: "Fork PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/81", - baseRefName: "main", - headRefName: "feature/pr-fork", - state: "open", - isCrossRepository: true, - headRepositoryNameWithOwner: "octocat/codething-mvp", - headRepositoryOwnerLogin: "octocat", - }, - repositoryCloneUrls: { - "octocat/codething-mvp": { - url: forkDir, - sshUrl: forkDir, - }, - }, - }, - }); - - const result = yield* preparePullRequestThread(manager, { - cwd: repoDir, - reference: "81", - mode: "worktree", - }); - - expect(result.worktreePath).not.toBeNull(); - const upstreamRef = (yield* runGit(result.worktreePath as string, [ - "rev-parse", - "--abbrev-ref", - "@{upstream}", - ])).stdout.trim(); - expect(upstreamRef).toBe("fork-seed/feature/pr-fork"); - expect(upstreamRef.startsWith("origin/")).toBe(false); - expect( - (yield* runGit(result.worktreePath as string, [ - "config", - "--get", - "remote.fork-seed.url", - ])).stdout.trim(), - ).toBe(forkDir); - }), - ); - - it.effect("preserves fork upstream tracking when preparing a local PR thread", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - const originDir = yield* createBareRemote(); - const forkDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", originDir]); - yield* runGit(repoDir, ["push", "-u", "origin", "main"]); - yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); - yield* runGit(repoDir, ["checkout", "-b", "feature/pr-local-fork"]); - fs.writeFileSync(path.join(repoDir, "local-fork.txt"), "local fork\n"); - yield* runGit(repoDir, ["add", "local-fork.txt"]); - yield* runGit(repoDir, ["commit", "-m", "Local fork PR branch"]); - yield* runGit(repoDir, ["push", "-u", "fork-seed", "feature/pr-local-fork"]); - yield* runGit(repoDir, ["checkout", "main"]); - yield* runGit(repoDir, ["branch", "-D", "feature/pr-local-fork"]); - - const { manager } = yield* makeManager({ - ghScenario: { - pullRequest: { - number: 82, - title: "Local Fork PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/82", - baseRefName: "main", - headRefName: "feature/pr-local-fork", - state: "open", - isCrossRepository: true, - headRepositoryNameWithOwner: "octocat/codething-mvp", - headRepositoryOwnerLogin: "octocat", - }, - repositoryCloneUrls: { - "octocat/codething-mvp": { - url: forkDir, - sshUrl: forkDir, - }, - }, - }, - }); - - const result = yield* preparePullRequestThread(manager, { - cwd: repoDir, - reference: "82", - mode: "local", - }); - - expect(result.worktreePath).toBeNull(); - expect(result.branch).toBe("feature/pr-local-fork"); - expect( - (yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"])).stdout.trim(), - ).toBe("fork-seed/feature/pr-local-fork"); - }), - ); - - it.effect("derives fork repository identity from PR URL when GitHub omits nameWithOwner", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - const originDir = yield* createBareRemote(); - const forkDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", originDir]); - yield* runGit(repoDir, ["push", "-u", "origin", "main"]); - yield* runGit(repoDir, ["remote", "add", "binbandit-seed", forkDir]); - yield* runGit(repoDir, ["checkout", "-b", "fix/git-action-default-without-origin"]); - fs.writeFileSync(path.join(repoDir, "derived-fork.txt"), "derived fork\n"); - yield* runGit(repoDir, ["add", "derived-fork.txt"]); - yield* runGit(repoDir, ["commit", "-m", "Derived fork PR branch"]); - yield* runGit(repoDir, [ - "push", - "-u", - "binbandit-seed", - "fix/git-action-default-without-origin", - ]); - yield* runGit(repoDir, ["checkout", "main"]); - yield* runGit(repoDir, ["branch", "-D", "fix/git-action-default-without-origin"]); - - const { manager } = yield* makeManager({ - ghScenario: { - pullRequest: { - number: 642, - title: "fix: use commit as the default git action without origin", - url: "https://github.com/pingdotgg/t3code/pull/642", - baseRefName: "main", - headRefName: "fix/git-action-default-without-origin", - state: "open", - isCrossRepository: true, - headRepositoryOwnerLogin: "binbandit", - }, - repositoryCloneUrls: { - "binbandit/t3code": { - url: forkDir, - sshUrl: forkDir, - }, - }, - }, - }); - - const result = yield* preparePullRequestThread(manager, { - cwd: repoDir, - reference: "642", - mode: "local", - }); - - expect(result.branch).toBe("fix/git-action-default-without-origin"); - expect(result.worktreePath).toBeNull(); - expect( - (yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"])).stdout.trim(), - ).toBe("binbandit-seed/fix/git-action-default-without-origin"); - }), - ); - - it.effect("reuses an existing dedicated worktree for the PR head branch", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "feature/pr-existing-worktree"]); - fs.writeFileSync(path.join(repoDir, "existing.txt"), "existing\n"); - yield* runGit(repoDir, ["add", "existing.txt"]); - yield* runGit(repoDir, ["commit", "-m", "Existing worktree branch"]); - yield* runGit(repoDir, ["checkout", "main"]); - const worktreePath = path.join(repoDir, "..", `pr-existing-${Date.now()}`); - yield* runGit(repoDir, ["worktree", "add", worktreePath, "feature/pr-existing-worktree"]); - - const setupCalls: ProjectSetupScriptRunnerInput[] = []; - const { manager } = yield* makeManager({ - ghScenario: { - pullRequest: { - number: 78, - title: "Existing worktree PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/78", - baseRefName: "main", - headRefName: "feature/pr-existing-worktree", - state: "open", - }, - }, - setupScriptRunner: { - runForThread: (setupInput) => - Effect.sync(() => { - setupCalls.push(setupInput); - return { status: "no-script" as const }; - }), - }, - }); - - const result = yield* preparePullRequestThread(manager, { - cwd: repoDir, - reference: "78", - mode: "worktree", - threadId: asThreadId("thread-pr-existing-worktree"), - }); - - expect(result.worktreePath && fs.realpathSync.native(result.worktreePath)).toBe( - fs.realpathSync.native(worktreePath), - ); - expect(result.branch).toBe("feature/pr-existing-worktree"); - expect(setupCalls).toHaveLength(0); - }), - ); - - it.effect( - "does not block fork PR worktree prep when the fork head branch collides with root main", - () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - const originDir = yield* createBareRemote(); - const forkDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", originDir]); - yield* runGit(repoDir, ["push", "-u", "origin", "main"]); - yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); - yield* runGit(repoDir, ["checkout", "-b", "fork-main-source"]); - fs.writeFileSync(path.join(repoDir, "fork-main.txt"), "fork main\n"); - yield* runGit(repoDir, ["add", "fork-main.txt"]); - yield* runGit(repoDir, ["commit", "-m", "Fork main branch"]); - yield* runGit(repoDir, ["push", "-u", "fork-seed", "fork-main-source:main"]); - yield* runGit(repoDir, ["checkout", "main"]); - const mainBefore = (yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim(); - - const { manager } = yield* makeManager({ - ghScenario: { - pullRequest: { - number: 91, - title: "Fork main PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/91", - baseRefName: "main", - headRefName: "main", - state: "open", - isCrossRepository: true, - headRepositoryNameWithOwner: "octocat/codething-mvp", - headRepositoryOwnerLogin: "octocat", - }, - repositoryCloneUrls: { - "octocat/codething-mvp": { - url: forkDir, - sshUrl: forkDir, - }, - }, - }, - }); - - const result = yield* preparePullRequestThread(manager, { - cwd: repoDir, - reference: "91", - mode: "worktree", - }); - - expect(result.branch).toBe("t3code/pr-91/main"); - expect(result.worktreePath).not.toBeNull(); - expect((yield* runGit(repoDir, ["branch", "--show-current"])).stdout.trim()).toBe("main"); - expect((yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim()).toBe(mainBefore); - expect( - (yield* runGit(result.worktreePath as string, [ - "branch", - "--show-current", - ])).stdout.trim(), - ).toBe("t3code/pr-91/main"); - }), - ); - - it.effect( - "does not overwrite an existing local main branch when preparing a fork PR worktree", - () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - const originDir = yield* createBareRemote(); - const forkDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", originDir]); - yield* runGit(repoDir, ["push", "-u", "origin", "main"]); - yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); - yield* runGit(repoDir, ["checkout", "-b", "fork-main-source"]); - fs.writeFileSync(path.join(repoDir, "fork-main-second.txt"), "fork main second\n"); - yield* runGit(repoDir, ["add", "fork-main-second.txt"]); - yield* runGit(repoDir, ["commit", "-m", "Fork main second branch"]); - yield* runGit(repoDir, ["push", "-u", "fork-seed", "fork-main-source:main"]); - yield* runGit(repoDir, ["checkout", "main"]); - const localMainBefore = (yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim(); - yield* runGit(repoDir, ["checkout", "-b", "feature/root-branch"]); - - const { manager } = yield* makeManager({ - ghScenario: { - pullRequest: { - number: 92, - title: "Fork main overwrite PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/92", - baseRefName: "main", - headRefName: "main", - state: "open", - isCrossRepository: true, - headRepositoryNameWithOwner: "octocat/codething-mvp", - headRepositoryOwnerLogin: "octocat", - }, - repositoryCloneUrls: { - "octocat/codething-mvp": { - url: forkDir, - sshUrl: forkDir, - }, - }, - }, - }); - - const result = yield* preparePullRequestThread(manager, { - cwd: repoDir, - reference: "92", - mode: "worktree", - }); - - expect(result.branch).toBe("t3code/pr-92/main"); - expect((yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim()).toBe(localMainBefore); - expect( - (yield* runGit(result.worktreePath as string, [ - "rev-parse", - "--abbrev-ref", - "@{upstream}", - ])).stdout.trim(), - ).toBe("fork-seed/main"); - }), - ); - - it.effect("reuses an existing PR worktree and restores fork upstream tracking", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - const originDir = yield* createBareRemote(); - const forkDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", originDir]); - yield* runGit(repoDir, ["push", "-u", "origin", "main"]); - yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); - yield* runGit(repoDir, ["checkout", "-b", "feature/pr-reused-fork"]); - fs.writeFileSync(path.join(repoDir, "reused-fork.txt"), "reused fork\n"); - yield* runGit(repoDir, ["add", "reused-fork.txt"]); - yield* runGit(repoDir, ["commit", "-m", "Reused fork PR branch"]); - yield* runGit(repoDir, ["push", "-u", "fork-seed", "feature/pr-reused-fork"]); - yield* runGit(repoDir, ["checkout", "main"]); - const worktreePath = path.join(repoDir, "..", `pr-reused-fork-${Date.now()}`); - yield* runGit(repoDir, ["worktree", "add", worktreePath, "feature/pr-reused-fork"]); - yield* runGit(worktreePath, ["branch", "--unset-upstream"], true); - - const { manager } = yield* makeManager({ - ghScenario: { - pullRequest: { - number: 83, - title: "Reused Fork PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/83", - baseRefName: "main", - headRefName: "feature/pr-reused-fork", - state: "open", - isCrossRepository: true, - headRepositoryNameWithOwner: "octocat/codething-mvp", - headRepositoryOwnerLogin: "octocat", - }, - repositoryCloneUrls: { - "octocat/codething-mvp": { - url: forkDir, - sshUrl: forkDir, - }, - }, - }, - }); - - const result = yield* preparePullRequestThread(manager, { - cwd: repoDir, - reference: "83", - mode: "worktree", - }); - - expect(result.worktreePath && fs.realpathSync.native(result.worktreePath)).toBe( - fs.realpathSync.native(worktreePath), - ); - expect( - (yield* runGit(worktreePath, ["rev-parse", "--abbrev-ref", "@{upstream}"])).stdout.trim(), - ).toBe("fork-seed/feature/pr-reused-fork"); - }), - ); - - it.effect("does not fail PR worktree prep when setup terminal startup fails", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - yield* runGit(repoDir, ["push", "-u", "origin", "main"]); - yield* runGit(repoDir, ["checkout", "-b", "feature/pr-setup-failure"]); - fs.writeFileSync(path.join(repoDir, "setup-failure.txt"), "setup failure\n"); - yield* runGit(repoDir, ["add", "setup-failure.txt"]); - yield* runGit(repoDir, ["commit", "-m", "PR setup failure branch"]); - yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-setup-failure"]); - yield* runGit(repoDir, ["push", "origin", "HEAD:refs/pull/184/head"]); - yield* runGit(repoDir, ["checkout", "main"]); - - const { manager } = yield* makeManager({ - ghScenario: { - pullRequest: { - number: 184, - title: "Setup failure PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/184", - baseRefName: "main", - headRefName: "feature/pr-setup-failure", - state: "open", - }, - }, - setupScriptRunner: { - runForThread: () => Effect.fail(new Error("terminal start failed")), - }, - }); - - const result = yield* preparePullRequestThread(manager, { - cwd: repoDir, - reference: "184", - mode: "worktree", - threadId: asThreadId("thread-pr-setup-failure"), - }); - - expect(result.branch).toBe("feature/pr-setup-failure"); - expect(result.worktreePath).not.toBeNull(); - expect(fs.existsSync(result.worktreePath as string)).toBe(true); - }), - ); - - it.effect("rejects worktree prep when the PR head branch is checked out in the main repo", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "feature/pr-root-only"]); - - const { manager } = yield* makeManager({ - ghScenario: { - pullRequest: { - number: 79, - title: "Root-only PR", - url: "https://github.com/pingdotgg/codething-mvp/pull/79", - baseRefName: "main", - headRefName: "feature/pr-root-only", - state: "open", - }, - }, - }); - - const errorMessage = yield* preparePullRequestThread(manager, { - cwd: repoDir, - reference: "79", - mode: "worktree", - }).pipe( - Effect.flip, - Effect.map((error) => error.message), - ); - - expect(errorMessage).toContain("already checked out in the main repo"); - }), - ); - - it.effect("emits ordered progress events for commit hooks", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - fs.writeFileSync(path.join(repoDir, "hooked.txt"), "hooked\n"); - fs.writeFileSync( - path.join(repoDir, ".git", "hooks", "pre-commit"), - '#!/bin/sh\necho "hook: start" >&2\nsleep 1\necho "hook: end" >&2\n', - { mode: 0o755 }, - ); - - const { manager } = yield* makeManager(); - const events: GitActionProgressEvent[] = []; - - const result = yield* runStackedAction( - manager, - { - cwd: repoDir, - action: "commit", - }, - { - actionId: "action-1", - progressReporter: { - publish: (event) => - Effect.sync(() => { - events.push(event); - }), - }, - }, - ); - - expect(result.commit.status).toBe("created"); - expect(events.map((event) => event.kind)).toContain("action_started"); - expect(events).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - kind: "phase_started", - phase: "commit", - }), - expect.objectContaining({ - kind: "hook_started", - hookName: "pre-commit", - }), - expect.objectContaining({ - kind: "hook_output", - text: "hook: start", - }), - expect.objectContaining({ - kind: "hook_output", - text: "hook: end", - }), - expect.objectContaining({ - kind: "hook_finished", - hookName: "pre-commit", - }), - expect.objectContaining({ - kind: "action_finished", - }), - ]), - ); - }), - ); - - it.effect("emits action_failed when a commit hook rejects", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - fs.writeFileSync(path.join(repoDir, "hook-failure.txt"), "broken\n"); - fs.writeFileSync( - path.join(repoDir, ".git", "hooks", "pre-commit"), - '#!/bin/sh\necho "hook: fail" >&2\nexit 1\n', - { mode: 0o755 }, - ); - - const { manager } = yield* makeManager(); - const events: GitActionProgressEvent[] = []; - - const errorMessage = yield* runStackedAction( - manager, - { - cwd: repoDir, - action: "commit", - }, - { - actionId: "action-2", - progressReporter: { - publish: (event) => - Effect.sync(() => { - events.push(event); - }), - }, - }, - ).pipe( - Effect.flip, - Effect.map((error) => error.message), - ); - - expect(errorMessage).toContain("hook: fail"); - expect(events).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - kind: "hook_started", - hookName: "pre-commit", - }), - expect.objectContaining({ - kind: "action_failed", - phase: "commit", - }), - ]), - ); - }), - ); - - it.effect("create_pr emits only the PR phase when the branch is already pushed", () => - Effect.gen(function* () { - const repoDir = yield* makeTempDir("t3code-git-manager-"); - yield* initRepo(repoDir); - yield* runGit(repoDir, ["checkout", "-b", "feature/pr-only-follow-up"]); - const remoteDir = yield* createBareRemote(); - yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); - fs.writeFileSync(path.join(repoDir, "pr-only.txt"), "pr only\n"); - yield* runGit(repoDir, ["add", "pr-only.txt"]); - yield* runGit(repoDir, ["commit", "-m", "PR only branch"]); - yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-only-follow-up"]); - - const { manager } = yield* makeManager({ - ghScenario: { - prListSequence: [ - JSON.stringify([]), - JSON.stringify([ - { - number: 201, - title: "PR only branch", - url: "https://github.com/pingdotgg/codething-mvp/pull/201", - baseRefName: "main", - headRefName: "feature/pr-only-follow-up", - state: "OPEN", - isCrossRepository: false, - }, - ]), - ], - }, - }); - const events: GitActionProgressEvent[] = []; - - const result = yield* runStackedAction( - manager, - { - cwd: repoDir, - action: "create_pr", - }, - { - actionId: "action-pr-only", - progressReporter: { - publish: (event) => - Effect.sync(() => { - events.push(event); - }), - }, - }, - ); - - expect(result.commit.status).toBe("skipped_not_requested"); - expect(result.push.status).toBe("skipped_not_requested"); - expect(result.pr.status).toBe("created"); - expect( - events.filter( - (event): event is Extract => - event.kind === "phase_started", - ), - ).toEqual([ - expect.objectContaining({ - kind: "phase_started", - phase: "pr", - label: "Preparing PR...", - }), - expect.objectContaining({ - kind: "phase_started", - phase: "pr", - label: "Generating PR content...", - }), - expect.objectContaining({ - kind: "phase_started", - phase: "pr", - label: "Creating GitHub pull request...", - }), - ]); - }), - ); -}); diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts deleted file mode 100644 index 21f3411d1e59de92d9077aa29e802d3f413210e9..0000000000000000000000000000000000000000 --- a/apps/server/src/git/Layers/GitManager.ts +++ /dev/null @@ -1,1740 +0,0 @@ -import { randomUUID } from "node:crypto"; -import { realpathSync } from "node:fs"; - -import { - Cache, - Duration, - Effect, - Exit, - FileSystem, - Layer, - Option, - Path, - Ref, - Result, -} from "effect"; -import { - GitActionProgressEvent, - GitActionProgressPhase, - GitCommandError, - GitRunStackedActionResult, - GitStackedAction, - type GitStatusLocalResult, - type GitStatusRemoteResult, - ModelSelection, -} from "@t3tools/contracts"; -import { - detectGitHostingProviderFromRemoteUrl, - mergeGitStatusParts, - resolveAutoFeatureBranchName, - sanitizeBranchFragment, - sanitizeFeatureBranchName, -} from "@t3tools/shared/git"; - -import { GitManagerError } from "@t3tools/contracts"; -import { - GitManager, - type GitActionProgressReporter, - type GitManagerShape, - type GitRunStackedActionOptions, -} from "../Services/GitManager.ts"; -import { GitCore } from "../Services/GitCore.ts"; -import type { GitStatusDetails } from "../Services/GitCore.ts"; -import { GitHubCli, type GitHubPullRequestSummary } from "../Services/GitHubCli.ts"; -import { TextGeneration } from "../Services/TextGeneration.ts"; -import { ProjectSetupScriptRunner } from "../../project/Services/ProjectSetupScriptRunner.ts"; -import { extractBranchNameFromRemoteRef } from "../remoteRefs.ts"; -import { ServerSettingsService } from "../../serverSettings.ts"; -import type { GitManagerServiceError } from "@t3tools/contracts"; -import { - decodeGitHubPullRequestListJson, - formatGitHubJsonDecodeError, -} from "../githubPullRequests.ts"; - -const COMMIT_TIMEOUT_MS = 10 * 60_000; -const MAX_PROGRESS_TEXT_LENGTH = 500; -const SHORT_SHA_LENGTH = 7; -const TOAST_DESCRIPTION_MAX = 72; -const STATUS_RESULT_CACHE_TTL = Duration.seconds(1); -const STATUS_RESULT_CACHE_CAPACITY = 2_048; -type StripProgressContext = T extends any ? Omit : never; -type GitActionProgressPayload = StripProgressContext; -type GitActionProgressEmitter = (event: GitActionProgressPayload) => Effect.Effect; - -function isNotGitRepositoryError(error: GitCommandError): boolean { - return error.message.toLowerCase().includes("not a git repository"); -} - -interface OpenPrInfo { - number: number; - title: string; - url: string; - baseRefName: string; - headRefName: string; -} - -interface PullRequestInfo extends OpenPrInfo, PullRequestHeadRemoteInfo { - state: "open" | "closed" | "merged"; - updatedAt: string | null; -} - -interface ResolvedPullRequest { - number: number; - title: string; - url: string; - baseBranch: string; - headBranch: string; - state: "open" | "closed" | "merged"; -} - -interface PullRequestHeadRemoteInfo { - isCrossRepository?: boolean; - headRepositoryNameWithOwner?: string | null; - headRepositoryOwnerLogin?: string | null; -} - -interface BranchHeadContext { - localBranch: string; - headBranch: string; - headSelectors: ReadonlyArray; - preferredHeadSelector: string; - remoteName: string | null; - headRepositoryNameWithOwner: string | null; - headRepositoryOwnerLogin: string | null; - isCrossRepository: boolean; -} - -function parseRepositoryNameFromPullRequestUrl(url: string): string | null { - const trimmed = url.trim(); - const match = /^https:\/\/github\.com\/[^/]+\/([^/]+)\/pull\/\d+(?:\/.*)?$/i.exec(trimmed); - const repositoryName = match?.[1]?.trim() ?? ""; - return repositoryName.length > 0 ? repositoryName : null; -} - -function resolveHeadRepositoryNameWithOwner( - pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo, -): string | null { - const explicitRepository = pullRequest.headRepositoryNameWithOwner?.trim() ?? ""; - if (explicitRepository.length > 0) { - return explicitRepository; - } - - if (!pullRequest.isCrossRepository) { - return null; - } - - const ownerLogin = pullRequest.headRepositoryOwnerLogin?.trim() ?? ""; - const repositoryName = parseRepositoryNameFromPullRequestUrl(pullRequest.url); - if (ownerLogin.length === 0 || !repositoryName) { - return null; - } - - return `${ownerLogin}/${repositoryName}`; -} - -function resolvePullRequestWorktreeLocalBranchName( - pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo, -): string { - if (!pullRequest.isCrossRepository) { - return pullRequest.headBranch; - } - - const sanitizedHeadBranch = sanitizeBranchFragment(pullRequest.headBranch).trim(); - const suffix = sanitizedHeadBranch.length > 0 ? sanitizedHeadBranch : "head"; - return `t3code/pr-${pullRequest.number}/${suffix}`; -} - -function parseGitHubRepositoryNameWithOwnerFromRemoteUrl(url: string | null): string | null { - const trimmed = url?.trim() ?? ""; - if (trimmed.length === 0) { - return null; - } - - const match = - /^(?:git@github\.com:|ssh:\/\/git@github\.com\/|https:\/\/github\.com\/|git:\/\/github\.com\/)([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/i.exec( - trimmed, - ); - const repositoryNameWithOwner = match?.[1]?.trim() ?? ""; - return repositoryNameWithOwner.length > 0 ? repositoryNameWithOwner : null; -} - -function parseRepositoryOwnerLogin(nameWithOwner: string | null): string | null { - const trimmed = nameWithOwner?.trim() ?? ""; - if (trimmed.length === 0) { - return null; - } - const [ownerLogin] = trimmed.split("/"); - const normalizedOwnerLogin = ownerLogin?.trim() ?? ""; - return normalizedOwnerLogin.length > 0 ? normalizedOwnerLogin : null; -} - -function normalizeOptionalString(value: string | null | undefined): string | null { - const trimmed = value?.trim() ?? ""; - return trimmed.length > 0 ? trimmed : null; -} - -function normalizeOptionalRepositoryNameWithOwner(value: string | null | undefined): string | null { - const normalized = normalizeOptionalString(value); - return normalized ? normalized.toLowerCase() : null; -} - -function normalizeOptionalOwnerLogin(value: string | null | undefined): string | null { - const normalized = normalizeOptionalString(value); - return normalized ? normalized.toLowerCase() : null; -} - -function resolvePullRequestHeadRepositoryNameWithOwner( - pr: PullRequestHeadRemoteInfo & { url: string }, -) { - const explicitRepository = normalizeOptionalString(pr.headRepositoryNameWithOwner); - if (explicitRepository) { - return explicitRepository; - } - - if (!pr.isCrossRepository) { - return null; - } - - const ownerLogin = normalizeOptionalString(pr.headRepositoryOwnerLogin); - const repositoryName = parseRepositoryNameFromPullRequestUrl(pr.url); - if (!ownerLogin || !repositoryName) { - return null; - } - - return `${ownerLogin}/${repositoryName}`; -} - -function matchesBranchHeadContext( - pr: PullRequestInfo, - headContext: Pick< - BranchHeadContext, - "headBranch" | "headRepositoryNameWithOwner" | "headRepositoryOwnerLogin" | "isCrossRepository" - >, -): boolean { - if (pr.headRefName !== headContext.headBranch) { - return false; - } - - const expectedHeadRepository = normalizeOptionalRepositoryNameWithOwner( - headContext.headRepositoryNameWithOwner, - ); - const expectedHeadOwner = - normalizeOptionalOwnerLogin(headContext.headRepositoryOwnerLogin) ?? - parseRepositoryOwnerLogin(expectedHeadRepository); - const prHeadRepository = normalizeOptionalRepositoryNameWithOwner( - resolvePullRequestHeadRepositoryNameWithOwner(pr), - ); - const prHeadOwner = - normalizeOptionalOwnerLogin(pr.headRepositoryOwnerLogin) ?? - parseRepositoryOwnerLogin(prHeadRepository); - - if (headContext.isCrossRepository) { - if (pr.isCrossRepository === false) { - return false; - } - if ((expectedHeadRepository || expectedHeadOwner) && !prHeadRepository && !prHeadOwner) { - return false; - } - if (expectedHeadRepository && prHeadRepository && expectedHeadRepository !== prHeadRepository) { - return false; - } - if (expectedHeadOwner && prHeadOwner && expectedHeadOwner !== prHeadOwner) { - return false; - } - return true; - } - - if (pr.isCrossRepository === true) { - return false; - } - if (expectedHeadRepository && prHeadRepository && expectedHeadRepository !== prHeadRepository) { - return false; - } - if (expectedHeadOwner && prHeadOwner && expectedHeadOwner !== prHeadOwner) { - return false; - } - return true; -} - -function toPullRequestInfo(summary: GitHubPullRequestSummary): PullRequestInfo { - return { - number: summary.number, - title: summary.title, - url: summary.url, - baseRefName: summary.baseRefName, - headRefName: summary.headRefName, - state: summary.state ?? "open", - updatedAt: null, - ...(summary.isCrossRepository !== undefined - ? { isCrossRepository: summary.isCrossRepository } - : {}), - ...(summary.headRepositoryNameWithOwner !== undefined - ? { headRepositoryNameWithOwner: summary.headRepositoryNameWithOwner } - : {}), - ...(summary.headRepositoryOwnerLogin !== undefined - ? { headRepositoryOwnerLogin: summary.headRepositoryOwnerLogin } - : {}), - }; -} - -function gitManagerError(operation: string, detail: string, cause?: unknown): GitManagerError { - return new GitManagerError({ - operation, - detail, - ...(cause !== undefined ? { cause } : {}), - }); -} - -function limitContext(value: string, maxChars: number): string { - if (value.length <= maxChars) return value; - return `${value.slice(0, maxChars)}\n\n[truncated]`; -} - -function shortenSha(sha: string | undefined): string | null { - if (!sha) return null; - return sha.slice(0, SHORT_SHA_LENGTH); -} - -function truncateText( - value: string | undefined, - maxLength = TOAST_DESCRIPTION_MAX, -): string | undefined { - if (!value) return undefined; - if (value.length <= maxLength) return value; - if (maxLength <= 3) return "...".slice(0, maxLength); - return `${value.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`; -} - -function withDescription(title: string, description: string | undefined) { - return description ? { title, description } : { title }; -} - -function summarizeGitActionResult( - result: Pick, -): { - title: string; - description?: string; -} { - if (result.pr.status === "created" || result.pr.status === "opened_existing") { - const prNumber = result.pr.number ? ` #${result.pr.number}` : ""; - const title = `${result.pr.status === "created" ? "Created PR" : "Opened PR"}${prNumber}`; - return withDescription(title, truncateText(result.pr.title)); - } - - if (result.push.status === "pushed") { - const shortSha = shortenSha(result.commit.commitSha); - const branch = result.push.upstreamBranch ?? result.push.branch; - const pushedCommitPart = shortSha ? ` ${shortSha}` : ""; - const branchPart = branch ? ` to ${branch}` : ""; - return withDescription( - `Pushed${pushedCommitPart}${branchPart}`, - truncateText(result.commit.subject), - ); - } - - if (result.commit.status === "created") { - const shortSha = shortenSha(result.commit.commitSha); - const title = shortSha ? `Committed ${shortSha}` : "Committed changes"; - return withDescription(title, truncateText(result.commit.subject)); - } - - return { title: "Done" }; -} - -function sanitizeCommitMessage(generated: { - subject: string; - body: string; - branch?: string | undefined; -}): { - subject: string; - body: string; - branch?: string | undefined; -} { - const rawSubject = generated.subject.trim().split(/\r?\n/g)[0]?.trim() ?? ""; - const subject = rawSubject.replace(/[.]+$/g, "").trim(); - const safeSubject = subject.length > 0 ? subject.slice(0, 72).trimEnd() : "Update project files"; - return { - subject: safeSubject, - body: generated.body.trim(), - ...(generated.branch !== undefined ? { branch: generated.branch } : {}), - }; -} - -function sanitizeProgressText(value: string): string | null { - const trimmed = value.trim(); - if (trimmed.length === 0) { - return null; - } - if (trimmed.length <= MAX_PROGRESS_TEXT_LENGTH) { - return trimmed; - } - return trimmed.slice(0, MAX_PROGRESS_TEXT_LENGTH).trimEnd(); -} - -interface CommitAndBranchSuggestion { - subject: string; - body: string; - branch?: string | undefined; - commitMessage: string; -} - -function isCommitAction( - action: GitStackedAction, -): action is "commit" | "commit_push" | "commit_push_pr" { - return action === "commit" || action === "commit_push" || action === "commit_push_pr"; -} - -function formatCommitMessage(subject: string, body: string): string { - const trimmedBody = body.trim(); - if (trimmedBody.length === 0) { - return subject; - } - return `${subject}\n\n${trimmedBody}`; -} - -function parseCustomCommitMessage(raw: string): { subject: string; body: string } | null { - const normalized = raw.replace(/\r\n/g, "\n").trim(); - if (normalized.length === 0) { - return null; - } - - const [firstLine, ...rest] = normalized.split("\n"); - const subject = firstLine?.trim() ?? ""; - if (subject.length === 0) { - return null; - } - - return { - subject, - body: rest.join("\n").trim(), - }; -} - -function appendUnique(values: string[], next: string | null | undefined): void { - const trimmed = next?.trim() ?? ""; - if (trimmed.length === 0 || values.includes(trimmed)) { - return; - } - values.push(trimmed); -} - -function toStatusPr(pr: PullRequestInfo): { - number: number; - title: string; - url: string; - baseBranch: string; - headBranch: string; - state: "open" | "closed" | "merged"; -} { - return { - number: pr.number, - title: pr.title, - url: pr.url, - baseBranch: pr.baseRefName, - headBranch: pr.headRefName, - state: pr.state, - }; -} - -function normalizePullRequestReference(reference: string): string { - const trimmed = reference.trim(); - const hashNumber = /^#(\d+)$/.exec(trimmed); - return hashNumber?.[1] ?? trimmed; -} - -function canonicalizeExistingPath(value: string): string { - try { - return realpathSync.native(value); - } catch { - return value; - } -} - -function toResolvedPullRequest(pr: { - number: number; - title: string; - url: string; - baseRefName: string; - headRefName: string; - state?: "open" | "closed" | "merged"; -}): ResolvedPullRequest { - return { - number: pr.number, - title: pr.title, - url: pr.url, - baseBranch: pr.baseRefName, - headBranch: pr.headRefName, - state: pr.state ?? "open", - }; -} - -function shouldPreferSshRemote(url: string | null): boolean { - if (!url) return false; - const trimmed = url.trim(); - return trimmed.startsWith("git@") || trimmed.startsWith("ssh://"); -} - -function toPullRequestHeadRemoteInfo(pr: { - isCrossRepository?: boolean; - headRepositoryNameWithOwner?: string | null; - headRepositoryOwnerLogin?: string | null; -}): PullRequestHeadRemoteInfo { - return { - ...(pr.isCrossRepository !== undefined ? { isCrossRepository: pr.isCrossRepository } : {}), - ...(pr.headRepositoryNameWithOwner !== undefined - ? { headRepositoryNameWithOwner: pr.headRepositoryNameWithOwner } - : {}), - ...(pr.headRepositoryOwnerLogin !== undefined - ? { headRepositoryOwnerLogin: pr.headRepositoryOwnerLogin } - : {}), - }; -} - -export const makeGitManager = Effect.fn("makeGitManager")(function* () { - const gitCore = yield* GitCore; - const gitHubCli = yield* GitHubCli; - const textGeneration = yield* TextGeneration; - const projectSetupScriptRunner = yield* ProjectSetupScriptRunner; - const serverSettingsService = yield* ServerSettingsService; - - const createProgressEmitter = ( - input: { cwd: string; action: GitStackedAction }, - options?: GitRunStackedActionOptions, - ) => { - const actionId = options?.actionId ?? randomUUID(); - const reporter = options?.progressReporter; - - const emit = (event: GitActionProgressPayload) => - reporter - ? reporter.publish({ - actionId, - cwd: input.cwd, - action: input.action, - ...event, - } as GitActionProgressEvent) - : Effect.void; - - return { - actionId, - emit, - }; - }; - - const configurePullRequestHeadUpstreamBase = Effect.fn("configurePullRequestHeadUpstream")( - function* ( - cwd: string, - pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo, - localBranch = pullRequest.headBranch, - ) { - const repositoryNameWithOwner = resolveHeadRepositoryNameWithOwner(pullRequest) ?? ""; - if (repositoryNameWithOwner.length === 0) { - return; - } - - const cloneUrls = yield* gitHubCli.getRepositoryCloneUrls({ - cwd, - repository: repositoryNameWithOwner, - }); - const originRemoteUrl = yield* gitCore.readConfigValue(cwd, "remote.origin.url"); - const remoteUrl = shouldPreferSshRemote(originRemoteUrl) ? cloneUrls.sshUrl : cloneUrls.url; - const preferredRemoteName = - pullRequest.headRepositoryOwnerLogin?.trim() || - repositoryNameWithOwner.split("/")[0]?.trim() || - "fork"; - const remoteName = yield* gitCore.ensureRemote({ - cwd, - preferredName: preferredRemoteName, - url: remoteUrl, - }); - - yield* gitCore.setBranchUpstream({ - cwd, - branch: localBranch, - remoteName, - remoteBranch: pullRequest.headBranch, - }); - }, - ); - - const configurePullRequestHeadUpstream = ( - cwd: string, - pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo, - localBranch = pullRequest.headBranch, - ) => - configurePullRequestHeadUpstreamBase(cwd, pullRequest, localBranch).pipe( - Effect.catch((error) => - Effect.logWarning( - `GitManager.configurePullRequestHeadUpstream: failed to configure upstream for ${localBranch} -> ${pullRequest.headBranch} in ${cwd}: ${error.message}`, - ).pipe(Effect.asVoid), - ), - ); - - const materializePullRequestHeadBranchBase = Effect.fn("materializePullRequestHeadBranch")( - function* ( - cwd: string, - pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo, - localBranch = pullRequest.headBranch, - ) { - const repositoryNameWithOwner = resolveHeadRepositoryNameWithOwner(pullRequest) ?? ""; - - if (repositoryNameWithOwner.length === 0) { - yield* gitCore.fetchPullRequestBranch({ - cwd, - prNumber: pullRequest.number, - branch: localBranch, - }); - return; - } - - const cloneUrls = yield* gitHubCli.getRepositoryCloneUrls({ - cwd, - repository: repositoryNameWithOwner, - }); - const originRemoteUrl = yield* gitCore.readConfigValue(cwd, "remote.origin.url"); - const remoteUrl = shouldPreferSshRemote(originRemoteUrl) ? cloneUrls.sshUrl : cloneUrls.url; - const preferredRemoteName = - pullRequest.headRepositoryOwnerLogin?.trim() || - repositoryNameWithOwner.split("/")[0]?.trim() || - "fork"; - const remoteName = yield* gitCore.ensureRemote({ - cwd, - preferredName: preferredRemoteName, - url: remoteUrl, - }); - - yield* gitCore.fetchRemoteBranch({ - cwd, - remoteName, - remoteBranch: pullRequest.headBranch, - localBranch, - }); - yield* gitCore.setBranchUpstream({ - cwd, - branch: localBranch, - remoteName, - remoteBranch: pullRequest.headBranch, - }); - }, - ); - - const materializePullRequestHeadBranch = ( - cwd: string, - pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo, - localBranch = pullRequest.headBranch, - ) => - materializePullRequestHeadBranchBase(cwd, pullRequest, localBranch).pipe( - Effect.catch(() => - gitCore.fetchPullRequestBranch({ - cwd, - prNumber: pullRequest.number, - branch: localBranch, - }), - ), - ); - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - - const tempDir = process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp"; - const normalizeStatusCacheKey = (cwd: string) => canonicalizeExistingPath(cwd); - const nonRepositoryStatusDetails = { - isRepo: false, - hasOriginRemote: false, - isDefaultBranch: false, - branch: null, - upstreamRef: null, - hasWorkingTreeChanges: false, - workingTree: { files: [], insertions: 0, deletions: 0 }, - hasUpstream: false, - aheadCount: 0, - behindCount: 0, - } satisfies GitStatusDetails; - const readLocalStatus = Effect.fn("readLocalStatus")(function* (cwd: string) { - const details = yield* gitCore - .statusDetailsLocal(cwd) - .pipe( - Effect.catchIf(isNotGitRepositoryError, () => Effect.succeed(nonRepositoryStatusDetails)), - ); - const hostingProvider = details.isRepo - ? yield* resolveHostingProvider(cwd, details.branch) - : null; - - return { - isRepo: details.isRepo, - ...(hostingProvider ? { hostingProvider } : {}), - hasOriginRemote: details.hasOriginRemote, - isDefaultBranch: details.isDefaultBranch, - branch: details.branch, - hasWorkingTreeChanges: details.hasWorkingTreeChanges, - workingTree: details.workingTree, - } satisfies GitStatusLocalResult; - }); - const localStatusResultCache = yield* Cache.makeWith(readLocalStatus, { - capacity: STATUS_RESULT_CACHE_CAPACITY, - timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_RESULT_CACHE_TTL : Duration.zero), - }); - const invalidateLocalStatusResultCache = (cwd: string) => - Cache.invalidate(localStatusResultCache, normalizeStatusCacheKey(cwd)); - const readRemoteStatus = Effect.fn("readRemoteStatus")(function* (cwd: string) { - const details = yield* gitCore - .statusDetails(cwd) - .pipe(Effect.catchIf(isNotGitRepositoryError, () => Effect.succeed(null))); - if (details === null || !details.isRepo) { - return null; - } - - const pr = - details.branch !== null - ? yield* findLatestPr(cwd, { - branch: details.branch, - upstreamRef: details.upstreamRef, - }).pipe( - Effect.map((latest) => { - if (!latest) return null; - // On the default branch, only surface open PRs. - // Merged/closed matches are usually reverse-merge history, not the thread's PR context. - if (details.isDefaultBranch && latest.state !== "open") return null; - return toStatusPr(latest); - }), - Effect.catch(() => Effect.succeed(null)), - ) - : null; - - return { - hasUpstream: details.hasUpstream, - aheadCount: details.aheadCount, - behindCount: details.behindCount, - pr, - } satisfies GitStatusRemoteResult; - }); - const remoteStatusResultCache = yield* Cache.makeWith(readRemoteStatus, { - capacity: STATUS_RESULT_CACHE_CAPACITY, - timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_RESULT_CACHE_TTL : Duration.zero), - }); - const invalidateRemoteStatusResultCache = (cwd: string) => - Cache.invalidate(remoteStatusResultCache, normalizeStatusCacheKey(cwd)); - - const readConfigValueNullable = (cwd: string, key: string) => - gitCore.readConfigValue(cwd, key).pipe(Effect.catch(() => Effect.succeed(null))); - - const resolveHostingProvider = Effect.fn("resolveHostingProvider")(function* ( - cwd: string, - branch: string | null, - ) { - const preferredRemoteName = - branch === null - ? "origin" - : ((yield* readConfigValueNullable(cwd, `branch.${branch}.remote`)) ?? "origin"); - const remoteUrl = - (yield* readConfigValueNullable(cwd, `remote.${preferredRemoteName}.url`)) ?? - (yield* readConfigValueNullable(cwd, "remote.origin.url")); - - return remoteUrl ? detectGitHostingProviderFromRemoteUrl(remoteUrl) : null; - }); - - const resolveRemoteRepositoryContext = Effect.fn("resolveRemoteRepositoryContext")(function* ( - cwd: string, - remoteName: string | null, - ) { - if (!remoteName) { - return { - repositoryNameWithOwner: null, - ownerLogin: null, - }; - } - - const remoteUrl = yield* readConfigValueNullable(cwd, `remote.${remoteName}.url`); - const repositoryNameWithOwner = parseGitHubRepositoryNameWithOwnerFromRemoteUrl(remoteUrl); - return { - repositoryNameWithOwner, - ownerLogin: parseRepositoryOwnerLogin(repositoryNameWithOwner), - }; - }); - - const resolveBranchHeadContext = Effect.fn("resolveBranchHeadContext")(function* ( - cwd: string, - details: { branch: string; upstreamRef: string | null }, - ) { - const remoteName = yield* readConfigValueNullable(cwd, `branch.${details.branch}.remote`); - const headBranchFromUpstream = details.upstreamRef - ? extractBranchNameFromRemoteRef(details.upstreamRef, { remoteName }) - : ""; - const headBranch = headBranchFromUpstream.length > 0 ? headBranchFromUpstream : details.branch; - const shouldProbeLocalBranchSelector = - headBranchFromUpstream.length === 0 || headBranch === details.branch; - - const [remoteRepository, originRepository] = yield* Effect.all( - [ - resolveRemoteRepositoryContext(cwd, remoteName), - resolveRemoteRepositoryContext(cwd, "origin"), - ], - { concurrency: "unbounded" }, - ); - - const isCrossRepository = - remoteRepository.repositoryNameWithOwner !== null && - originRepository.repositoryNameWithOwner !== null - ? remoteRepository.repositoryNameWithOwner.toLowerCase() !== - originRepository.repositoryNameWithOwner.toLowerCase() - : remoteName !== null && - remoteName !== "origin" && - remoteRepository.repositoryNameWithOwner !== null; - - const ownerHeadSelector = - remoteRepository.ownerLogin && headBranch.length > 0 - ? `${remoteRepository.ownerLogin}:${headBranch}` - : null; - const remoteAliasHeadSelector = - remoteName && headBranch.length > 0 ? `${remoteName}:${headBranch}` : null; - const shouldProbeRemoteOwnedSelectors = - isCrossRepository || (remoteName !== null && remoteName !== "origin"); - - const headSelectors: string[] = []; - if (isCrossRepository && shouldProbeRemoteOwnedSelectors) { - appendUnique(headSelectors, ownerHeadSelector); - appendUnique( - headSelectors, - remoteAliasHeadSelector !== ownerHeadSelector ? remoteAliasHeadSelector : null, - ); - } - if (shouldProbeLocalBranchSelector) { - appendUnique(headSelectors, details.branch); - } - appendUnique(headSelectors, headBranch !== details.branch ? headBranch : null); - if (!isCrossRepository && shouldProbeRemoteOwnedSelectors) { - appendUnique(headSelectors, ownerHeadSelector); - appendUnique( - headSelectors, - remoteAliasHeadSelector !== ownerHeadSelector ? remoteAliasHeadSelector : null, - ); - } - - return { - localBranch: details.branch, - headBranch, - headSelectors, - preferredHeadSelector: - ownerHeadSelector && isCrossRepository ? ownerHeadSelector : headBranch, - remoteName, - headRepositoryNameWithOwner: remoteRepository.repositoryNameWithOwner, - headRepositoryOwnerLogin: remoteRepository.ownerLogin, - isCrossRepository, - } satisfies BranchHeadContext; - }); - - const findOpenPr = Effect.fn("findOpenPr")(function* ( - cwd: string, - headContext: Pick< - BranchHeadContext, - | "headBranch" - | "headSelectors" - | "headRepositoryNameWithOwner" - | "headRepositoryOwnerLogin" - | "isCrossRepository" - >, - ) { - for (const headSelector of headContext.headSelectors) { - const pullRequests = yield* gitHubCli.listOpenPullRequests({ - cwd, - headSelector, - limit: 1, - }); - const normalizedPullRequests = pullRequests.map(toPullRequestInfo); - - const firstPullRequest = normalizedPullRequests.find((pullRequest) => - matchesBranchHeadContext(pullRequest, headContext), - ); - if (firstPullRequest) { - return { - number: firstPullRequest.number, - title: firstPullRequest.title, - url: firstPullRequest.url, - baseRefName: firstPullRequest.baseRefName, - headRefName: firstPullRequest.headRefName, - state: "open", - updatedAt: null, - } satisfies PullRequestInfo; - } - } - - return null; - }); - - const findLatestPr = Effect.fn("findLatestPr")(function* ( - cwd: string, - details: { branch: string; upstreamRef: string | null }, - ) { - const headContext = yield* resolveBranchHeadContext(cwd, details); - const parsedByNumber = new Map(); - - for (const headSelector of headContext.headSelectors) { - const stdout = yield* gitHubCli - .execute({ - cwd, - args: [ - "pr", - "list", - "--head", - headSelector, - "--state", - "all", - "--limit", - "20", - "--json", - "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", - ], - }) - .pipe(Effect.map((result) => result.stdout)); - - const raw = stdout.trim(); - if (raw.length === 0) { - continue; - } - - const pullRequests = yield* Effect.sync(() => decodeGitHubPullRequestListJson(raw)).pipe( - Effect.flatMap((decoded) => { - if (!Result.isSuccess(decoded)) { - return Effect.fail( - gitManagerError( - "findLatestPr", - `GitHub CLI returned invalid PR list JSON: ${formatGitHubJsonDecodeError(decoded.failure)}`, - decoded.failure, - ), - ); - } - - return Effect.succeed(decoded.success); - }), - ); - - for (const pr of pullRequests) { - if (!matchesBranchHeadContext(pr, headContext)) { - continue; - } - parsedByNumber.set(pr.number, pr); - } - } - - const parsed = Array.from(parsedByNumber.values()).toSorted((a, b) => { - const left = a.updatedAt ? Date.parse(a.updatedAt) : 0; - const right = b.updatedAt ? Date.parse(b.updatedAt) : 0; - return right - left; - }); - - const latestOpenPr = parsed.find((pr) => pr.state === "open"); - if (latestOpenPr) { - return latestOpenPr; - } - return parsed[0] ?? null; - }); - - const buildCompletionToast = Effect.fn("buildCompletionToast")(function* ( - cwd: string, - result: Pick, - ) { - const summary = summarizeGitActionResult(result); - let latestOpenPr: PullRequestInfo | null = null; - let currentBranchIsDefault = false; - let finalBranchContext: { - branch: string; - upstreamRef: string | null; - hasUpstream: boolean; - } | null = null; - - if (result.action !== "commit") { - const finalStatus = yield* gitCore.statusDetails(cwd); - if (finalStatus.branch) { - finalBranchContext = { - branch: finalStatus.branch, - upstreamRef: finalStatus.upstreamRef, - hasUpstream: finalStatus.hasUpstream, - }; - currentBranchIsDefault = finalStatus.isDefaultBranch; - } - } - - const explicitResultPr = - (result.pr.status === "created" || result.pr.status === "opened_existing") && result.pr.url - ? { - url: result.pr.url, - state: "open" as const, - } - : null; - const shouldLookupExistingOpenPr = - (result.action === "commit_push" || result.action === "push") && - result.push.status === "pushed" && - result.branch.status !== "created" && - !currentBranchIsDefault && - explicitResultPr === null && - finalBranchContext?.hasUpstream === true; - - if (shouldLookupExistingOpenPr && finalBranchContext) { - latestOpenPr = yield* resolveBranchHeadContext(cwd, { - branch: finalBranchContext.branch, - upstreamRef: finalBranchContext.upstreamRef, - }).pipe( - Effect.flatMap((headContext) => findOpenPr(cwd, headContext)), - Effect.catch(() => Effect.succeed(null)), - ); - } - - const openPr = latestOpenPr ?? explicitResultPr; - - const cta = - result.action === "commit" && result.commit.status === "created" - ? { - kind: "run_action" as const, - label: "Push", - action: { kind: "push" as const }, - } - : (result.action === "push" || - result.action === "create_pr" || - result.action === "commit_push" || - result.action === "commit_push_pr") && - openPr?.url && - (!currentBranchIsDefault || - result.pr.status === "created" || - result.pr.status === "opened_existing") - ? { - kind: "open_pr" as const, - label: "View PR", - url: openPr.url, - } - : (result.action === "push" || result.action === "commit_push") && - result.push.status === "pushed" && - !currentBranchIsDefault - ? { - kind: "run_action" as const, - label: "Create PR", - action: { kind: "create_pr" as const }, - } - : { - kind: "none" as const, - }; - - return { - ...summary, - cta, - }; - }); - - const resolveBaseBranch = Effect.fn("resolveBaseBranch")(function* ( - cwd: string, - branch: string, - upstreamRef: string | null, - headContext: Pick, - ) { - const configured = yield* gitCore.readConfigValue(cwd, `branch.${branch}.gh-merge-base`); - if (configured) return configured; - - if (upstreamRef && !headContext.isCrossRepository) { - const upstreamBranch = extractBranchNameFromRemoteRef(upstreamRef, { - remoteName: headContext.remoteName, - }); - if (upstreamBranch.length > 0 && upstreamBranch !== branch) { - return upstreamBranch; - } - } - - const defaultFromGh = yield* gitHubCli - .getDefaultBranch({ cwd }) - .pipe(Effect.catch(() => Effect.succeed(null))); - if (defaultFromGh) { - return defaultFromGh; - } - - return "main"; - }); - - const resolveCommitAndBranchSuggestion = Effect.fn("resolveCommitAndBranchSuggestion")( - function* (input: { - cwd: string; - branch: string | null; - commitMessage?: string; - /** When true, also produce a semantic feature branch name. */ - includeBranch?: boolean; - filePaths?: readonly string[]; - modelSelection: ModelSelection; - }) { - const context = yield* gitCore.prepareCommitContext(input.cwd, input.filePaths); - if (!context) { - return null; - } - - const customCommit = parseCustomCommitMessage(input.commitMessage ?? ""); - if (customCommit) { - return { - subject: customCommit.subject, - body: customCommit.body, - ...(input.includeBranch - ? { branch: sanitizeFeatureBranchName(customCommit.subject) } - : {}), - commitMessage: formatCommitMessage(customCommit.subject, customCommit.body), - }; - } - - const generated = yield* textGeneration - .generateCommitMessage({ - cwd: input.cwd, - branch: input.branch, - stagedSummary: limitContext(context.stagedSummary, 8_000), - stagedPatch: limitContext(context.stagedPatch, 50_000), - ...(input.includeBranch ? { includeBranch: true } : {}), - modelSelection: input.modelSelection, - }) - .pipe(Effect.map((result) => sanitizeCommitMessage(result))); - - return { - subject: generated.subject, - body: generated.body, - ...(generated.branch !== undefined ? { branch: generated.branch } : {}), - commitMessage: formatCommitMessage(generated.subject, generated.body), - }; - }, - ); - - const runCommitStep = Effect.fn("runCommitStep")(function* ( - modelSelection: ModelSelection, - cwd: string, - action: "commit" | "commit_push" | "commit_push_pr", - branch: string | null, - commitMessage?: string, - preResolvedSuggestion?: CommitAndBranchSuggestion, - filePaths?: readonly string[], - progressReporter?: GitActionProgressReporter, - actionId?: string, - ) { - const emit = (event: GitActionProgressPayload) => - progressReporter && actionId - ? progressReporter.publish({ - actionId, - cwd, - action, - ...event, - } as GitActionProgressEvent) - : Effect.void; - - let suggestion: CommitAndBranchSuggestion | null | undefined = preResolvedSuggestion; - if (!suggestion) { - const needsGeneration = !commitMessage?.trim(); - if (needsGeneration) { - yield* emit({ - kind: "phase_started", - phase: "commit", - label: "Generating commit message...", - }); - } - suggestion = yield* resolveCommitAndBranchSuggestion({ - cwd, - branch, - ...(commitMessage ? { commitMessage } : {}), - ...(filePaths ? { filePaths } : {}), - modelSelection, - }); - } - if (!suggestion) { - return { status: "skipped_no_changes" as const }; - } - - yield* emit({ - kind: "phase_started", - phase: "commit", - label: "Committing...", - }); - - let currentHookName: string | null = null; - const commitProgress = - progressReporter && actionId - ? { - onOutputLine: ({ stream, text }: { stream: "stdout" | "stderr"; text: string }) => { - const sanitized = sanitizeProgressText(text); - if (!sanitized) { - return Effect.void; - } - return emit({ - kind: "hook_output", - hookName: currentHookName, - stream, - text: sanitized, - }); - }, - onHookStarted: (hookName: string) => { - currentHookName = hookName; - return emit({ - kind: "hook_started", - hookName, - }); - }, - onHookFinished: ({ - hookName, - exitCode, - durationMs, - }: { - hookName: string; - exitCode: number | null; - durationMs: number | null; - }) => { - if (currentHookName === hookName) { - currentHookName = null; - } - return emit({ - kind: "hook_finished", - hookName, - exitCode, - durationMs, - }); - }, - } - : null; - const { commitSha } = yield* gitCore.commit(cwd, suggestion.subject, suggestion.body, { - timeoutMs: COMMIT_TIMEOUT_MS, - ...(commitProgress ? { progress: commitProgress } : {}), - }); - if (currentHookName !== null) { - yield* emit({ - kind: "hook_finished", - hookName: currentHookName, - exitCode: 0, - durationMs: null, - }); - currentHookName = null; - } - return { - status: "created" as const, - commitSha, - subject: suggestion.subject, - }; - }); - - const runPrStep = Effect.fn("runPrStep")(function* ( - modelSelection: ModelSelection, - cwd: string, - fallbackBranch: string | null, - emit: GitActionProgressEmitter, - ) { - const details = yield* gitCore.statusDetails(cwd); - const branch = details.branch ?? fallbackBranch; - if (!branch) { - return yield* gitManagerError( - "runPrStep", - "Cannot create a pull request from detached HEAD.", - ); - } - if (!details.hasUpstream) { - return yield* gitManagerError( - "runPrStep", - "Current branch has not been pushed. Push before creating a PR.", - ); - } - - const headContext = yield* resolveBranchHeadContext(cwd, { - branch, - upstreamRef: details.upstreamRef, - }); - - const existing = yield* findOpenPr(cwd, headContext); - if (existing) { - return { - status: "opened_existing" as const, - url: existing.url, - number: existing.number, - baseBranch: existing.baseRefName, - headBranch: existing.headRefName, - title: existing.title, - }; - } - - const baseBranch = yield* resolveBaseBranch(cwd, branch, details.upstreamRef, headContext); - yield* emit({ - kind: "phase_started", - phase: "pr", - label: "Generating PR content...", - }); - const rangeContext = yield* gitCore.readRangeContext(cwd, baseBranch); - - const generated = yield* textGeneration.generatePrContent({ - cwd, - baseBranch, - headBranch: headContext.headBranch, - commitSummary: limitContext(rangeContext.commitSummary, 20_000), - diffSummary: limitContext(rangeContext.diffSummary, 20_000), - diffPatch: limitContext(rangeContext.diffPatch, 60_000), - modelSelection, - }); - - const bodyFile = path.join(tempDir, `t3code-pr-body-${process.pid}-${randomUUID()}.md`); - yield* fileSystem - .writeFileString(bodyFile, generated.body) - .pipe( - Effect.mapError((cause) => - gitManagerError("runPrStep", "Failed to write pull request body temp file.", cause), - ), - ); - yield* emit({ - kind: "phase_started", - phase: "pr", - label: "Creating GitHub pull request...", - }); - yield* gitHubCli - .createPullRequest({ - cwd, - baseBranch, - headSelector: headContext.preferredHeadSelector, - title: generated.title, - bodyFile, - }) - .pipe(Effect.ensuring(fileSystem.remove(bodyFile).pipe(Effect.catch(() => Effect.void)))); - - const created = yield* findOpenPr(cwd, headContext); - if (!created) { - return { - status: "created" as const, - baseBranch, - headBranch: headContext.headBranch, - title: generated.title, - }; - } - - return { - status: "created" as const, - url: created.url, - number: created.number, - baseBranch: created.baseRefName, - headBranch: created.headRefName, - title: created.title, - }; - }); - - const localStatus: GitManagerShape["localStatus"] = Effect.fn("localStatus")(function* (input) { - return yield* Cache.get(localStatusResultCache, normalizeStatusCacheKey(input.cwd)); - }); - const remoteStatus: GitManagerShape["remoteStatus"] = Effect.fn("remoteStatus")( - function* (input) { - return yield* Cache.get(remoteStatusResultCache, normalizeStatusCacheKey(input.cwd)); - }, - ); - const status: GitManagerShape["status"] = Effect.fn("status")(function* (input) { - const [local, remote] = yield* Effect.all([localStatus(input), remoteStatus(input)]); - return mergeGitStatusParts(local, remote); - }); - const invalidateLocalStatus: GitManagerShape["invalidateLocalStatus"] = Effect.fn( - "invalidateLocalStatus", - )(function* (cwd) { - yield* invalidateLocalStatusResultCache(cwd); - }); - const invalidateRemoteStatus: GitManagerShape["invalidateRemoteStatus"] = Effect.fn( - "invalidateRemoteStatus", - )(function* (cwd) { - yield* invalidateRemoteStatusResultCache(cwd); - }); - const invalidateStatus: GitManagerShape["invalidateStatus"] = Effect.fn("invalidateStatus")( - function* (cwd) { - yield* invalidateLocalStatusResultCache(cwd); - yield* invalidateRemoteStatusResultCache(cwd); - }, - ); - - const resolvePullRequest: GitManagerShape["resolvePullRequest"] = Effect.fn("resolvePullRequest")( - function* (input) { - const pullRequest = yield* gitHubCli - .getPullRequest({ - cwd: input.cwd, - reference: normalizePullRequestReference(input.reference), - }) - .pipe(Effect.map((resolved) => toResolvedPullRequest(resolved))); - - return { pullRequest }; - }, - ); - - const preparePullRequestThread: GitManagerShape["preparePullRequestThread"] = Effect.fn( - "preparePullRequestThread", - )(function* (input) { - const maybeRunSetupScript = (worktreePath: string) => { - if (!input.threadId) { - return Effect.void; - } - return projectSetupScriptRunner - .runForThread({ - threadId: input.threadId, - projectCwd: input.cwd, - worktreePath, - }) - .pipe( - Effect.catch((error) => - Effect.logWarning( - `GitManager.preparePullRequestThread: failed to launch worktree setup script for thread ${input.threadId} in ${worktreePath}: ${error.message}`, - ).pipe(Effect.asVoid), - ), - ); - }; - return yield* Effect.gen(function* () { - const normalizedReference = normalizePullRequestReference(input.reference); - const rootWorktreePath = canonicalizeExistingPath(input.cwd); - const pullRequestSummary = yield* gitHubCli.getPullRequest({ - cwd: input.cwd, - reference: normalizedReference, - }); - const pullRequest = toResolvedPullRequest(pullRequestSummary); - - if (input.mode === "local") { - yield* gitHubCli.checkoutPullRequest({ - cwd: input.cwd, - reference: normalizedReference, - force: true, - }); - const details = yield* gitCore.statusDetails(input.cwd); - yield* configurePullRequestHeadUpstream( - input.cwd, - { - ...pullRequest, - ...toPullRequestHeadRemoteInfo(pullRequestSummary), - }, - details.branch ?? pullRequest.headBranch, - ); - return { - pullRequest, - branch: details.branch ?? pullRequest.headBranch, - worktreePath: null, - }; - } - - const ensureExistingWorktreeUpstream = Effect.fn("ensureExistingWorktreeUpstream")(function* ( - worktreePath: string, - ) { - const details = yield* gitCore.statusDetails(worktreePath); - yield* configurePullRequestHeadUpstream( - worktreePath, - { - ...pullRequest, - ...toPullRequestHeadRemoteInfo(pullRequestSummary), - }, - details.branch ?? pullRequest.headBranch, - ); - }); - - const pullRequestWithRemoteInfo = { - ...pullRequest, - ...toPullRequestHeadRemoteInfo(pullRequestSummary), - } as const; - const localPullRequestBranch = - resolvePullRequestWorktreeLocalBranchName(pullRequestWithRemoteInfo); - - const findLocalHeadBranch = (cwd: string) => - gitCore.listBranches({ cwd }).pipe( - Effect.map((result) => { - const localBranch = result.branches.find( - (branch) => !branch.isRemote && branch.name === localPullRequestBranch, - ); - if (localBranch) { - return localBranch; - } - if (localPullRequestBranch === pullRequest.headBranch) { - return null; - } - return ( - result.branches.find( - (branch) => - !branch.isRemote && - branch.name === pullRequest.headBranch && - branch.worktreePath !== null && - canonicalizeExistingPath(branch.worktreePath) !== rootWorktreePath, - ) ?? null - ); - }), - ); - - const existingBranchBeforeFetch = yield* findLocalHeadBranch(input.cwd); - const existingBranchBeforeFetchPath = existingBranchBeforeFetch?.worktreePath - ? canonicalizeExistingPath(existingBranchBeforeFetch.worktreePath) - : null; - if ( - existingBranchBeforeFetch?.worktreePath && - existingBranchBeforeFetchPath !== rootWorktreePath - ) { - yield* ensureExistingWorktreeUpstream(existingBranchBeforeFetch.worktreePath); - return { - pullRequest, - branch: localPullRequestBranch, - worktreePath: existingBranchBeforeFetch.worktreePath, - }; - } - if (existingBranchBeforeFetchPath === rootWorktreePath) { - return yield* gitManagerError( - "preparePullRequestThread", - "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.", - ); - } - - yield* materializePullRequestHeadBranch( - input.cwd, - pullRequestWithRemoteInfo, - localPullRequestBranch, - ); - - const existingBranchAfterFetch = yield* findLocalHeadBranch(input.cwd); - const existingBranchAfterFetchPath = existingBranchAfterFetch?.worktreePath - ? canonicalizeExistingPath(existingBranchAfterFetch.worktreePath) - : null; - if ( - existingBranchAfterFetch?.worktreePath && - existingBranchAfterFetchPath !== rootWorktreePath - ) { - yield* ensureExistingWorktreeUpstream(existingBranchAfterFetch.worktreePath); - return { - pullRequest, - branch: localPullRequestBranch, - worktreePath: existingBranchAfterFetch.worktreePath, - }; - } - if (existingBranchAfterFetchPath === rootWorktreePath) { - return yield* gitManagerError( - "preparePullRequestThread", - "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.", - ); - } - - const worktree = yield* gitCore.createWorktree({ - cwd: input.cwd, - branch: localPullRequestBranch, - path: null, - }); - yield* ensureExistingWorktreeUpstream(worktree.worktree.path); - yield* maybeRunSetupScript(worktree.worktree.path); - - return { - pullRequest, - branch: worktree.worktree.branch, - worktreePath: worktree.worktree.path, - }; - }).pipe(Effect.ensuring(invalidateStatus(input.cwd))); - }); - - const runFeatureBranchStep = Effect.fn("runFeatureBranchStep")(function* ( - modelSelection: ModelSelection, - cwd: string, - branch: string | null, - commitMessage?: string, - filePaths?: readonly string[], - ) { - const suggestion = yield* resolveCommitAndBranchSuggestion({ - cwd, - branch, - ...(commitMessage ? { commitMessage } : {}), - ...(filePaths ? { filePaths } : {}), - includeBranch: true, - modelSelection, - }); - if (!suggestion) { - return yield* gitManagerError( - "runFeatureBranchStep", - "Cannot create a feature branch because there are no changes to commit.", - ); - } - - const preferredBranch = suggestion.branch ?? sanitizeFeatureBranchName(suggestion.subject); - const existingBranchNames = yield* gitCore.listLocalBranchNames(cwd); - const resolvedBranch = resolveAutoFeatureBranchName(existingBranchNames, preferredBranch); - - yield* gitCore.createBranch({ cwd, branch: resolvedBranch }); - yield* Effect.scoped(gitCore.checkoutBranch({ cwd, branch: resolvedBranch })); - - return { - branchStep: { status: "created" as const, name: resolvedBranch }, - resolvedCommitMessage: suggestion.commitMessage, - resolvedCommitSuggestion: suggestion, - }; - }); - - const runStackedAction: GitManagerShape["runStackedAction"] = Effect.fn("runStackedAction")( - function* (input, options) { - const progress = createProgressEmitter(input, options); - const currentPhase = yield* Ref.make>(Option.none()); - - const runAction = Effect.fn("runStackedAction.runAction")(function* (): Effect.fn.Return< - GitRunStackedActionResult, - GitManagerServiceError - > { - const initialStatus = yield* gitCore.statusDetails(input.cwd); - const wantsCommit = isCommitAction(input.action); - const wantsPush = - input.action === "push" || - input.action === "commit_push" || - input.action === "commit_push_pr" || - (input.action === "create_pr" && - (!initialStatus.hasUpstream || initialStatus.aheadCount > 0)); - const wantsPr = input.action === "create_pr" || input.action === "commit_push_pr"; - - if (input.featureBranch && !wantsCommit) { - return yield* gitManagerError( - "runStackedAction", - "Feature-branch checkout is only supported for commit actions.", - ); - } - if (input.action === "push" && initialStatus.hasWorkingTreeChanges) { - return yield* gitManagerError( - "runStackedAction", - "Commit or stash local changes before pushing.", - ); - } - if (input.action === "create_pr" && initialStatus.hasWorkingTreeChanges) { - return yield* gitManagerError( - "runStackedAction", - "Commit local changes before creating a PR.", - ); - } - - const phases: GitActionProgressPhase[] = [ - ...(input.featureBranch ? (["branch"] as const) : []), - ...(wantsCommit ? (["commit"] as const) : []), - ...(wantsPush ? (["push"] as const) : []), - ...(wantsPr ? (["pr"] as const) : []), - ]; - - yield* progress.emit({ - kind: "action_started", - phases, - }); - - if (!input.featureBranch && wantsPush && !initialStatus.branch) { - return yield* gitManagerError("runStackedAction", "Cannot push from detached HEAD."); - } - if (!input.featureBranch && wantsPr && !initialStatus.branch) { - return yield* gitManagerError( - "runStackedAction", - "Cannot create a pull request from detached HEAD.", - ); - } - - let branchStep: { status: "created" | "skipped_not_requested"; name?: string }; - let commitMessageForStep = input.commitMessage; - let preResolvedCommitSuggestion: CommitAndBranchSuggestion | undefined = undefined; - - const modelSelection = yield* serverSettingsService.getSettings.pipe( - Effect.map((settings) => settings.textGenerationModelSelection), - Effect.mapError((cause) => - gitManagerError("runStackedAction", "Failed to get server settings.", cause), - ), - ); - - if (input.featureBranch) { - yield* Ref.set(currentPhase, Option.some("branch")); - yield* progress.emit({ - kind: "phase_started", - phase: "branch", - label: "Preparing feature branch...", - }); - const result = yield* runFeatureBranchStep( - modelSelection, - input.cwd, - initialStatus.branch, - input.commitMessage, - input.filePaths, - ); - branchStep = result.branchStep; - commitMessageForStep = result.resolvedCommitMessage; - preResolvedCommitSuggestion = result.resolvedCommitSuggestion; - } else { - branchStep = { status: "skipped_not_requested" as const }; - } - - const currentBranch = branchStep.name ?? initialStatus.branch; - const commitAction = isCommitAction(input.action) ? input.action : null; - - const commit = commitAction - ? yield* Ref.set(currentPhase, Option.some("commit")).pipe( - Effect.flatMap(() => - runCommitStep( - modelSelection, - input.cwd, - commitAction, - currentBranch, - commitMessageForStep, - preResolvedCommitSuggestion, - input.filePaths, - options?.progressReporter, - progress.actionId, - ), - ), - ) - : { status: "skipped_not_requested" as const }; - - const push = wantsPush - ? yield* progress - .emit({ - kind: "phase_started", - phase: "push", - label: "Pushing...", - }) - .pipe( - Effect.tap(() => Ref.set(currentPhase, Option.some("push"))), - Effect.flatMap(() => gitCore.pushCurrentBranch(input.cwd, currentBranch)), - ) - : { status: "skipped_not_requested" as const }; - - const pr = wantsPr - ? yield* progress - .emit({ - kind: "phase_started", - phase: "pr", - label: "Preparing PR...", - }) - .pipe( - Effect.tap(() => Ref.set(currentPhase, Option.some("pr"))), - Effect.flatMap(() => - runPrStep(modelSelection, input.cwd, currentBranch, progress.emit), - ), - ) - : { status: "skipped_not_requested" as const }; - - const toast = yield* buildCompletionToast(input.cwd, { - action: input.action, - branch: branchStep, - commit, - push, - pr, - }); - - const result = { - action: input.action, - branch: branchStep, - commit, - push, - pr, - toast, - }; - yield* progress.emit({ - kind: "action_finished", - result, - }); - return result; - }); - - return yield* runAction().pipe( - Effect.ensuring(invalidateStatus(input.cwd)), - Effect.tapError((error) => - Effect.flatMap(Ref.get(currentPhase), (phase) => - progress.emit({ - kind: "action_failed", - phase: Option.getOrNull(phase), - message: error.message, - }), - ), - ), - ); - }, - ); - - return { - localStatus, - remoteStatus, - status, - invalidateLocalStatus, - invalidateRemoteStatus, - invalidateStatus, - resolvePullRequest, - preparePullRequestThread, - runStackedAction, - } satisfies GitManagerShape; -}); - -export const GitManagerLive = Layer.effect(GitManager, makeGitManager()); diff --git a/apps/server/src/git/Layers/GitStatusBroadcaster.test.ts b/apps/server/src/git/Layers/GitStatusBroadcaster.test.ts deleted file mode 100644 index 72a0c24e27b50f201ba7838f2b003c34ebba1fca..0000000000000000000000000000000000000000 --- a/apps/server/src/git/Layers/GitStatusBroadcaster.test.ts +++ /dev/null @@ -1,312 +0,0 @@ -import { assert, it } from "@effect/vitest"; -import { Deferred, Effect, Exit, Layer, Option, Scope, Stream } from "effect"; -import type { - GitStatusLocalResult, - GitStatusRemoteResult, - GitStatusResult, - GitStatusStreamEvent, -} from "@t3tools/contracts"; -import { describe } from "vitest"; - -import { GitStatusBroadcaster } from "../Services/GitStatusBroadcaster.ts"; -import { GitStatusBroadcasterLive } from "./GitStatusBroadcaster.ts"; -import { type GitManagerShape, GitManager } from "../Services/GitManager.ts"; - -const baseLocalStatus: GitStatusLocalResult = { - isRepo: true, - hostingProvider: { - kind: "github", - name: "GitHub", - baseUrl: "https://github.com", - }, - hasOriginRemote: true, - isDefaultBranch: false, - branch: "feature/status-broadcast", - hasWorkingTreeChanges: false, - workingTree: { files: [], insertions: 0, deletions: 0 }, -}; - -const baseRemoteStatus: GitStatusRemoteResult = { - hasUpstream: true, - aheadCount: 0, - behindCount: 0, - pr: null, -}; - -const baseStatus: GitStatusResult = { - ...baseLocalStatus, - ...baseRemoteStatus, -}; - -function makeTestLayer(state: { - currentLocalStatus: GitStatusLocalResult; - currentRemoteStatus: GitStatusRemoteResult | null; - localStatusCalls: number; - remoteStatusCalls: number; - localInvalidationCalls: number; - remoteInvalidationCalls: number; -}) { - const gitManager: GitManagerShape = { - localStatus: () => - Effect.sync(() => { - state.localStatusCalls += 1; - return state.currentLocalStatus; - }), - remoteStatus: () => - Effect.sync(() => { - state.remoteStatusCalls += 1; - return state.currentRemoteStatus; - }), - status: () => Effect.die("status should not be called in this test"), - invalidateLocalStatus: () => - Effect.sync(() => { - state.localInvalidationCalls += 1; - }), - invalidateRemoteStatus: () => - Effect.sync(() => { - state.remoteInvalidationCalls += 1; - }), - invalidateStatus: () => Effect.die("invalidateStatus should not be called in this test"), - resolvePullRequest: () => Effect.die("resolvePullRequest should not be called in this test"), - preparePullRequestThread: () => - Effect.die("preparePullRequestThread should not be called in this test"), - runStackedAction: () => Effect.die("runStackedAction should not be called in this test"), - }; - - return GitStatusBroadcasterLive.pipe(Layer.provide(Layer.succeed(GitManager, gitManager))); -} - -describe("GitStatusBroadcasterLive", () => { - it.effect("reuses the cached git status across repeated reads", () => { - const state = { - currentLocalStatus: baseLocalStatus, - currentRemoteStatus: baseRemoteStatus, - localStatusCalls: 0, - remoteStatusCalls: 0, - localInvalidationCalls: 0, - remoteInvalidationCalls: 0, - }; - - return Effect.gen(function* () { - const broadcaster = yield* GitStatusBroadcaster; - - const first = yield* broadcaster.getStatus({ cwd: "/repo" }); - const second = yield* broadcaster.getStatus({ cwd: "/repo" }); - - assert.deepStrictEqual(first, baseStatus); - assert.deepStrictEqual(second, baseStatus); - assert.equal(state.localStatusCalls, 1); - assert.equal(state.remoteStatusCalls, 1); - assert.equal(state.localInvalidationCalls, 0); - assert.equal(state.remoteInvalidationCalls, 0); - }).pipe(Effect.provide(makeTestLayer(state))); - }); - - it.effect("refreshes the cached snapshot after explicit invalidation", () => { - const state = { - currentLocalStatus: baseLocalStatus, - currentRemoteStatus: baseRemoteStatus, - localStatusCalls: 0, - remoteStatusCalls: 0, - localInvalidationCalls: 0, - remoteInvalidationCalls: 0, - }; - - return Effect.gen(function* () { - const broadcaster = yield* GitStatusBroadcaster; - const initial = yield* broadcaster.getStatus({ cwd: "/repo" }); - - state.currentLocalStatus = { - ...baseLocalStatus, - branch: "feature/updated-status", - }; - state.currentRemoteStatus = { - ...baseRemoteStatus, - aheadCount: 2, - }; - const refreshed = yield* broadcaster.refreshStatus("/repo"); - const cached = yield* broadcaster.getStatus({ cwd: "/repo" }); - - assert.deepStrictEqual(initial, baseStatus); - assert.deepStrictEqual(refreshed, { - ...state.currentLocalStatus, - ...state.currentRemoteStatus, - }); - assert.deepStrictEqual(cached, { - ...state.currentLocalStatus, - ...state.currentRemoteStatus, - }); - assert.equal(state.localStatusCalls, 2); - assert.equal(state.remoteStatusCalls, 2); - assert.equal(state.localInvalidationCalls, 1); - assert.equal(state.remoteInvalidationCalls, 1); - }).pipe(Effect.provide(makeTestLayer(state))); - }); - - it.effect("refreshes only the cached local snapshot when requested", () => { - const state = { - currentLocalStatus: baseLocalStatus, - currentRemoteStatus: baseRemoteStatus, - localStatusCalls: 0, - remoteStatusCalls: 0, - localInvalidationCalls: 0, - remoteInvalidationCalls: 0, - }; - - return Effect.gen(function* () { - const broadcaster = yield* GitStatusBroadcaster; - const initial = yield* broadcaster.getStatus({ cwd: "/repo" }); - - state.currentLocalStatus = { - ...baseLocalStatus, - branch: "feature/local-only-refresh", - hasWorkingTreeChanges: true, - }; - - const refreshedLocal = yield* broadcaster.refreshLocalStatus("/repo"); - const cached = yield* broadcaster.getStatus({ cwd: "/repo" }); - - assert.deepStrictEqual(initial, baseStatus); - assert.deepStrictEqual(refreshedLocal, state.currentLocalStatus); - assert.deepStrictEqual(cached, { - ...state.currentLocalStatus, - ...baseRemoteStatus, - }); - assert.equal(state.localStatusCalls, 2); - assert.equal(state.remoteStatusCalls, 1); - assert.equal(state.localInvalidationCalls, 1); - assert.equal(state.remoteInvalidationCalls, 0); - }).pipe(Effect.provide(makeTestLayer(state))); - }); - - it.effect("streams a local snapshot first and remote updates later", () => { - const state = { - currentLocalStatus: baseLocalStatus, - currentRemoteStatus: baseRemoteStatus, - localStatusCalls: 0, - remoteStatusCalls: 0, - localInvalidationCalls: 0, - remoteInvalidationCalls: 0, - }; - - return Effect.gen(function* () { - const broadcaster = yield* GitStatusBroadcaster; - const snapshotDeferred = yield* Deferred.make(); - const remoteUpdatedDeferred = yield* Deferred.make(); - yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) => { - if (event._tag === "snapshot") { - return Deferred.succeed(snapshotDeferred, event).pipe(Effect.ignore); - } - if (event._tag === "remoteUpdated") { - return Deferred.succeed(remoteUpdatedDeferred, event).pipe(Effect.ignore); - } - return Effect.void; - }).pipe(Effect.forkScoped); - - const snapshot = yield* Deferred.await(snapshotDeferred); - yield* broadcaster.refreshStatus("/repo"); - const remoteUpdated = yield* Deferred.await(remoteUpdatedDeferred); - - assert.deepStrictEqual(snapshot, { - _tag: "snapshot", - local: baseLocalStatus, - remote: null, - } satisfies GitStatusStreamEvent); - assert.deepStrictEqual(remoteUpdated, { - _tag: "remoteUpdated", - remote: baseRemoteStatus, - } satisfies GitStatusStreamEvent); - }).pipe(Effect.provide(makeTestLayer(state))); - }); - - it.effect("stops the remote poller after the last stream subscriber disconnects", () => { - const state = { - currentLocalStatus: baseLocalStatus, - currentRemoteStatus: baseRemoteStatus, - localStatusCalls: 0, - remoteStatusCalls: 0, - localInvalidationCalls: 0, - remoteInvalidationCalls: 0, - }; - let remoteInterruptedDeferred: Deferred.Deferred | null = null; - let remoteStartedDeferred: Deferred.Deferred | null = null; - const testLayer = GitStatusBroadcasterLive.pipe( - Layer.provide( - Layer.succeed(GitManager, { - localStatus: () => - Effect.sync(() => { - state.localStatusCalls += 1; - return state.currentLocalStatus; - }), - remoteStatus: () => - Effect.sync(() => { - state.remoteStatusCalls += 1; - }).pipe( - Effect.andThen( - remoteStartedDeferred - ? Deferred.succeed(remoteStartedDeferred, undefined).pipe(Effect.ignore) - : Effect.void, - ), - Effect.andThen(Effect.never as Effect.Effect), - Effect.onInterrupt(() => - remoteInterruptedDeferred - ? Deferred.succeed(remoteInterruptedDeferred, undefined).pipe(Effect.ignore) - : Effect.void, - ), - ), - status: () => Effect.die("status should not be called in this test"), - invalidateLocalStatus: () => - Effect.sync(() => { - state.localInvalidationCalls += 1; - }), - invalidateRemoteStatus: () => - Effect.sync(() => { - state.remoteInvalidationCalls += 1; - }), - invalidateStatus: () => Effect.die("invalidateStatus should not be called in this test"), - resolvePullRequest: () => - Effect.die("resolvePullRequest should not be called in this test"), - preparePullRequestThread: () => - Effect.die("preparePullRequestThread should not be called in this test"), - runStackedAction: () => Effect.die("runStackedAction should not be called in this test"), - } satisfies GitManagerShape), - ), - ); - - return Effect.gen(function* () { - const remoteInterrupted = yield* Deferred.make(); - const remoteStarted = yield* Deferred.make(); - remoteInterruptedDeferred = remoteInterrupted; - remoteStartedDeferred = remoteStarted; - - const broadcaster = yield* GitStatusBroadcaster; - const firstSnapshot = yield* Deferred.make(); - const secondSnapshot = yield* Deferred.make(); - const firstScope = yield* Scope.make(); - const secondScope = yield* Scope.make(); - yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) => - event._tag === "snapshot" - ? Deferred.succeed(firstSnapshot, event).pipe(Effect.ignore) - : Effect.void, - ).pipe(Effect.forkIn(firstScope)); - yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) => - event._tag === "snapshot" - ? Deferred.succeed(secondSnapshot, event).pipe(Effect.ignore) - : Effect.void, - ).pipe(Effect.forkIn(secondScope)); - - yield* Deferred.await(firstSnapshot); - yield* Deferred.await(secondSnapshot); - yield* Deferred.await(remoteStarted); - - assert.equal(state.remoteStatusCalls, 1); - - yield* Scope.close(firstScope, Exit.void); - assert.equal(Option.isNone(yield* Deferred.poll(remoteInterrupted)), true); - - yield* Scope.close(secondScope, Exit.void).pipe(Effect.forkScoped); - yield* Deferred.await(remoteInterrupted); - assert.equal(Option.isSome(yield* Deferred.poll(remoteInterrupted)), true); - }).pipe(Effect.provide(testLayer)); - }); -}); diff --git a/apps/server/src/git/Layers/GitStatusBroadcaster.ts b/apps/server/src/git/Layers/GitStatusBroadcaster.ts deleted file mode 100644 index 3ad7d095d8d02e80be28224262d61e9c0ee6254a..0000000000000000000000000000000000000000 --- a/apps/server/src/git/Layers/GitStatusBroadcaster.ts +++ /dev/null @@ -1,311 +0,0 @@ -import { realpathSync } from "node:fs"; - -import { - Duration, - Effect, - Exit, - Fiber, - Layer, - PubSub, - Ref, - Scope, - Stream, - SynchronizedRef, -} from "effect"; -import type { - GitStatusInput, - GitStatusLocalResult, - GitStatusRemoteResult, - GitStatusStreamEvent, -} from "@t3tools/contracts"; -import { mergeGitStatusParts } from "@t3tools/shared/git"; - -import { - GitStatusBroadcaster, - type GitStatusBroadcasterShape, -} from "../Services/GitStatusBroadcaster.ts"; -import { GitManager } from "../Services/GitManager.ts"; - -const GIT_STATUS_REFRESH_INTERVAL = Duration.seconds(30); - -interface GitStatusChange { - readonly cwd: string; - readonly event: GitStatusStreamEvent; -} - -interface CachedValue { - readonly fingerprint: string; - readonly value: T; -} - -interface CachedGitStatus { - readonly local: CachedValue | null; - readonly remote: CachedValue | null; -} - -interface ActiveRemotePoller { - readonly fiber: Fiber.Fiber; - readonly subscriberCount: number; -} - -function normalizeCwd(cwd: string): string { - try { - return realpathSync.native(cwd); - } catch { - return cwd; - } -} - -function fingerprintStatusPart(status: unknown): string { - return JSON.stringify(status); -} - -export const GitStatusBroadcasterLive = Layer.effect( - GitStatusBroadcaster, - Effect.gen(function* () { - const gitManager = yield* GitManager; - const changesPubSub = yield* Effect.acquireRelease( - PubSub.unbounded(), - (pubsub) => PubSub.shutdown(pubsub), - ); - const broadcasterScope = yield* Effect.acquireRelease(Scope.make(), (scope) => - Scope.close(scope, Exit.void), - ); - const cacheRef = yield* Ref.make(new Map()); - const pollersRef = yield* SynchronizedRef.make(new Map()); - - const getCachedStatus = Effect.fn("getCachedStatus")(function* (cwd: string) { - return yield* Ref.get(cacheRef).pipe(Effect.map((cache) => cache.get(cwd) ?? null)); - }); - - const updateCachedLocalStatus = Effect.fn("updateCachedLocalStatus")(function* ( - cwd: string, - local: GitStatusLocalResult, - options?: { publish?: boolean }, - ) { - const nextLocal = { - fingerprint: fingerprintStatusPart(local), - value: local, - } satisfies CachedValue; - const shouldPublish = yield* Ref.modify(cacheRef, (cache) => { - const previous = cache.get(cwd) ?? { local: null, remote: null }; - const nextCache = new Map(cache); - nextCache.set(cwd, { - ...previous, - local: nextLocal, - }); - return [previous.local?.fingerprint !== nextLocal.fingerprint, nextCache] as const; - }); - - if (options?.publish && shouldPublish) { - yield* PubSub.publish(changesPubSub, { - cwd, - event: { - _tag: "localUpdated", - local, - }, - }); - } - - return local; - }); - - const updateCachedRemoteStatus = Effect.fn("updateCachedRemoteStatus")(function* ( - cwd: string, - remote: GitStatusRemoteResult | null, - options?: { publish?: boolean }, - ) { - const nextRemote = { - fingerprint: fingerprintStatusPart(remote), - value: remote, - } satisfies CachedValue; - const shouldPublish = yield* Ref.modify(cacheRef, (cache) => { - const previous = cache.get(cwd) ?? { local: null, remote: null }; - const nextCache = new Map(cache); - nextCache.set(cwd, { - ...previous, - remote: nextRemote, - }); - return [previous.remote?.fingerprint !== nextRemote.fingerprint, nextCache] as const; - }); - - if (options?.publish && shouldPublish) { - yield* PubSub.publish(changesPubSub, { - cwd, - event: { - _tag: "remoteUpdated", - remote, - }, - }); - } - - return remote; - }); - - const loadLocalStatus = Effect.fn("loadLocalStatus")(function* (cwd: string) { - const local = yield* gitManager.localStatus({ cwd }); - return yield* updateCachedLocalStatus(cwd, local); - }); - - const loadRemoteStatus = Effect.fn("loadRemoteStatus")(function* (cwd: string) { - const remote = yield* gitManager.remoteStatus({ cwd }); - return yield* updateCachedRemoteStatus(cwd, remote); - }); - - const getOrLoadLocalStatus = Effect.fn("getOrLoadLocalStatus")(function* (cwd: string) { - const cached = yield* getCachedStatus(cwd); - if (cached?.local) { - return cached.local.value; - } - return yield* loadLocalStatus(cwd); - }); - - const getOrLoadRemoteStatus = Effect.fn("getOrLoadRemoteStatus")(function* (cwd: string) { - const cached = yield* getCachedStatus(cwd); - if (cached?.remote) { - return cached.remote.value; - } - return yield* loadRemoteStatus(cwd); - }); - - const getStatus: GitStatusBroadcasterShape["getStatus"] = Effect.fn("getStatus")(function* ( - input: GitStatusInput, - ) { - const normalizedCwd = normalizeCwd(input.cwd); - const [local, remote] = yield* Effect.all([ - getOrLoadLocalStatus(normalizedCwd), - getOrLoadRemoteStatus(normalizedCwd), - ]); - return mergeGitStatusParts(local, remote); - }); - - const refreshLocalStatus: GitStatusBroadcasterShape["refreshLocalStatus"] = Effect.fn( - "refreshLocalStatus", - )(function* (cwd) { - const normalizedCwd = normalizeCwd(cwd); - yield* gitManager.invalidateLocalStatus(normalizedCwd); - const local = yield* gitManager.localStatus({ cwd: normalizedCwd }); - return yield* updateCachedLocalStatus(normalizedCwd, local, { publish: true }); - }); - - const refreshRemoteStatus = Effect.fn("refreshRemoteStatus")(function* (cwd: string) { - yield* gitManager.invalidateRemoteStatus(cwd); - const remote = yield* gitManager.remoteStatus({ cwd }); - return yield* updateCachedRemoteStatus(cwd, remote, { publish: true }); - }); - - const refreshStatus: GitStatusBroadcasterShape["refreshStatus"] = Effect.fn("refreshStatus")( - function* (cwd) { - const normalizedCwd = normalizeCwd(cwd); - const [local, remote] = yield* Effect.all([ - refreshLocalStatus(normalizedCwd), - refreshRemoteStatus(normalizedCwd), - ]); - return mergeGitStatusParts(local, remote); - }, - ); - - const makeRemoteRefreshLoop = (cwd: string) => { - const logRefreshFailure = (error: Error) => - Effect.logWarning("git remote status refresh failed", { - cwd, - detail: error.message, - }); - - return refreshRemoteStatus(cwd).pipe( - Effect.catch(logRefreshFailure), - Effect.andThen( - Effect.forever( - Effect.sleep(GIT_STATUS_REFRESH_INTERVAL).pipe( - Effect.andThen(refreshRemoteStatus(cwd).pipe(Effect.catch(logRefreshFailure))), - ), - ), - ), - ); - }; - - const retainRemotePoller = Effect.fn("retainRemotePoller")(function* (cwd: string) { - yield* SynchronizedRef.modifyEffect(pollersRef, (activePollers) => { - const existing = activePollers.get(cwd); - if (existing) { - const nextPollers = new Map(activePollers); - nextPollers.set(cwd, { - ...existing, - subscriberCount: existing.subscriberCount + 1, - }); - return Effect.succeed([undefined, nextPollers] as const); - } - - return makeRemoteRefreshLoop(cwd).pipe( - Effect.forkIn(broadcasterScope), - Effect.map((fiber) => { - const nextPollers = new Map(activePollers); - nextPollers.set(cwd, { - fiber, - subscriberCount: 1, - }); - return [undefined, nextPollers] as const; - }), - ); - }); - }); - - const releaseRemotePoller = Effect.fn("releaseRemotePoller")(function* (cwd: string) { - const pollerToInterrupt = yield* SynchronizedRef.modify(pollersRef, (activePollers) => { - const existing = activePollers.get(cwd); - if (!existing) { - return [null, activePollers] as const; - } - - if (existing.subscriberCount > 1) { - const nextPollers = new Map(activePollers); - nextPollers.set(cwd, { - ...existing, - subscriberCount: existing.subscriberCount - 1, - }); - return [null, nextPollers] as const; - } - - const nextPollers = new Map(activePollers); - nextPollers.delete(cwd); - return [existing.fiber, nextPollers] as const; - }); - - if (pollerToInterrupt) { - yield* Fiber.interrupt(pollerToInterrupt).pipe(Effect.ignore); - } - }); - - const streamStatus: GitStatusBroadcasterShape["streamStatus"] = (input) => - Stream.unwrap( - Effect.gen(function* () { - const normalizedCwd = normalizeCwd(input.cwd); - const subscription = yield* PubSub.subscribe(changesPubSub); - const initialLocal = yield* getOrLoadLocalStatus(normalizedCwd); - const initialRemote = (yield* getCachedStatus(normalizedCwd))?.remote?.value ?? null; - yield* retainRemotePoller(normalizedCwd); - - const release = releaseRemotePoller(normalizedCwd).pipe(Effect.ignore, Effect.asVoid); - - return Stream.concat( - Stream.make({ - _tag: "snapshot" as const, - local: initialLocal, - remote: initialRemote, - }), - Stream.fromSubscription(subscription).pipe( - Stream.filter((event) => event.cwd === normalizedCwd), - Stream.map((event) => event.event), - ), - ).pipe(Stream.ensuring(release)); - }), - ); - - return { - getStatus, - refreshLocalStatus, - refreshStatus, - streamStatus, - } satisfies GitStatusBroadcasterShape; - }), -); diff --git a/apps/server/src/git/Layers/OpenCodeTextGeneration.test.ts b/apps/server/src/git/Layers/OpenCodeTextGeneration.test.ts deleted file mode 100644 index c3bdd6035ebb5f468833c9a566df748c5d330d79..0000000000000000000000000000000000000000 --- a/apps/server/src/git/Layers/OpenCodeTextGeneration.test.ts +++ /dev/null @@ -1,346 +0,0 @@ -import { OpenCodeSettings, ProviderInstanceId } from "@t3tools/contracts"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { it } from "@effect/vitest"; -import { Duration, Effect, Layer, Schema } from "effect"; -import { TestClock } from "effect/testing"; -import { NetService } from "@t3tools/shared/Net"; -import { beforeEach, expect } from "vitest"; - -import { ServerConfig } from "../../config.ts"; -import { - OpenCodeRuntime, - OpenCodeRuntimeError, - type OpenCodeRuntimeShape, -} from "../../provider/opencodeRuntime.ts"; -import { type TextGenerationShape } from "../Services/TextGeneration.ts"; -import { makeOpenCodeTextGeneration } from "./OpenCodeTextGeneration.ts"; - -const runtimeMock = { - state: { - startCalls: [] as string[], - promptUrls: [] as string[], - authHeaders: [] as Array, - closeCalls: [] as string[], - promptResult: undefined as - | { data?: { info?: { error?: unknown }; parts?: Array<{ type: string; text?: string }> } } - | undefined, - }, - reset() { - this.state.startCalls.length = 0; - this.state.promptUrls.length = 0; - this.state.authHeaders.length = 0; - this.state.closeCalls.length = 0; - this.state.promptResult = undefined; - }, -}; - -const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { - startOpenCodeServerProcess: ({ binaryPath }) => - Effect.gen(function* () { - const index = runtimeMock.state.startCalls.length + 1; - const url = `http://127.0.0.1:${4_300 + index}`; - runtimeMock.state.startCalls.push(binaryPath); - // The production runtime binds server lifetime to the caller's scope. - // Mirror that here so the closeCalls probe observes scope close. - yield* Effect.addFinalizer(() => - Effect.sync(() => { - runtimeMock.state.closeCalls.push(url); - }), - ); - return { - url, - exitCode: Effect.never, - }; - }), - connectToOpenCodeServer: ({ serverUrl }) => - Effect.succeed({ - url: serverUrl ?? "http://127.0.0.1:4301", - exitCode: null, - external: Boolean(serverUrl), - }), - runOpenCodeCommand: () => Effect.succeed({ stdout: "", stderr: "", code: 0 }), - createOpenCodeSdkClient: ({ baseUrl, serverPassword }) => - ({ - session: { - create: async () => ({ data: { id: `${baseUrl}/session` } }), - prompt: async () => { - runtimeMock.state.promptUrls.push(baseUrl); - runtimeMock.state.authHeaders.push( - serverPassword ? `Basic ${btoa(`opencode:${serverPassword}`)}` : null, - ); - return ( - runtimeMock.state.promptResult ?? { - data: { - parts: [ - { - type: "text", - text: JSON.stringify({ - subject: "Improve OpenCode reuse", - body: "Reuse one server for the full action.", - }), - }, - ], - }, - } - ); - }, - }, - }) as unknown as ReturnType, - loadOpenCodeInventory: () => - Effect.fail( - new OpenCodeRuntimeError({ - operation: "loadOpenCodeInventory", - detail: "OpenCodeRuntimeTestDouble.loadOpenCodeInventory not used in this test", - cause: null, - }), - ), -}; - -const DEFAULT_TEST_MODEL_SELECTION = { - instanceId: ProviderInstanceId.make("opencode"), - model: "openai/gpt-5", -}; - -const OPENCODE_TEXT_GENERATION_IDLE_TTL_MS = 30_000; - -const OpenCodeTextGenerationTestLayer = Layer.succeed( - OpenCodeRuntime, - OpenCodeRuntimeTestDouble, -).pipe( - Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3code-opencode-text-generation-test-", - }), - ), - Layer.provideMerge(NetService.layer), - Layer.provideMerge(NodeServices.layer), -); - -const OpenCodeTextGenerationExistingServerTestLayer = Layer.succeed( - OpenCodeRuntime, - OpenCodeRuntimeTestDouble, -).pipe( - Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3code-opencode-text-generation-existing-server-test-", - }), - ), - Layer.provideMerge(NetService.layer), - Layer.provideMerge(NodeServices.layer), -); - -const DEFAULT_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({ - binaryPath: "fake-opencode", -}); -const EXISTING_SERVER_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({ - binaryPath: "fake-opencode", - serverUrl: "http://127.0.0.1:9999", - serverPassword: "secret-password", -}); - -function withOpenCodeTextGeneration( - settings: OpenCodeSettings, - effectFn: (textGeneration: TextGenerationShape) => Effect.Effect, -) { - return Effect.gen(function* () { - const textGeneration = yield* makeOpenCodeTextGeneration(settings); - return yield* effectFn(textGeneration); - }).pipe(Effect.scoped); -} - -beforeEach(() => { - runtimeMock.reset(); -}); - -const advanceIdleClock = Effect.gen(function* () { - yield* Effect.yieldNow; - yield* TestClock.adjust(Duration.millis(OPENCODE_TEXT_GENERATION_IDLE_TTL_MS + 1)); - yield* Effect.yieldNow; -}); - -it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGenerationLive", (it) => { - it.effect("reuses a warm server across back-to-back requests and closes it after idling", () => - withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => - Effect.gen(function* () { - yield* textGeneration.generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/opencode-reuse", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }); - yield* textGeneration.generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/opencode-reuse", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }); - - expect(runtimeMock.state.startCalls).toEqual(["fake-opencode"]); - expect(runtimeMock.state.promptUrls).toEqual([ - "http://127.0.0.1:4301", - "http://127.0.0.1:4301", - ]); - expect(runtimeMock.state.closeCalls).toEqual([]); - - yield* advanceIdleClock; - - expect(runtimeMock.state.closeCalls).toEqual(["http://127.0.0.1:4301"]); - }), - ).pipe(Effect.provide(TestClock.layer())), - ); - - it.effect("starts a new server after the warm server idles out", () => - withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => - Effect.gen(function* () { - yield* textGeneration.generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/opencode-reuse", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }); - - yield* advanceIdleClock; - - yield* textGeneration.generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/opencode-reuse", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }); - - expect(runtimeMock.state.startCalls).toEqual(["fake-opencode", "fake-opencode"]); - expect(runtimeMock.state.promptUrls).toEqual([ - "http://127.0.0.1:4301", - "http://127.0.0.1:4302", - ]); - expect(runtimeMock.state.closeCalls).toEqual(["http://127.0.0.1:4301"]); - }), - ).pipe(Effect.provide(TestClock.layer())), - ); - - it.effect("returns a typed empty-output error when OpenCode returns no text parts", () => - withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => - Effect.gen(function* () { - runtimeMock.state.promptResult = { data: {} }; - - const error = yield* textGeneration - .generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/opencode-reuse", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }) - .pipe(Effect.flip); - - expect(error.message).toContain("OpenCode returned empty output."); - }), - ), - ); - - it.effect("parses JSON returned as plain text output", () => - withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => - Effect.gen(function* () { - runtimeMock.state.promptResult = { - data: { - parts: [ - { - type: "text", - text: 'Here is the result:\n{"subject":"Tighten OpenCode parsing","body":"Handle JSON text output locally."}', - }, - ], - }, - }; - - const result = yield* textGeneration.generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/opencode-reuse", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }); - - expect(result).toEqual({ - subject: "Tighten OpenCode parsing", - body: "Handle JSON text output locally.", - }); - }), - ), - ); - - it.effect("surfaces the upstream OpenCode structured-output error message", () => - withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => - Effect.gen(function* () { - runtimeMock.state.promptResult = { - data: { - info: { - error: { - name: "StructuredOutputError", - data: { - message: "Model did not produce structured output", - retries: 2, - }, - }, - }, - }, - }; - - const error = yield* textGeneration - .generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/opencode-reuse", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }) - .pipe(Effect.flip); - - expect(error.message).toContain("Model did not produce structured output"); - }), - ), - ); -}); - -it.layer(OpenCodeTextGenerationExistingServerTestLayer)( - "OpenCodeTextGenerationLive with configured server URL", - (it) => { - it.effect("reuses a configured OpenCode server URL without spawning or applying idle TTL", () => - withOpenCodeTextGeneration(EXISTING_SERVER_OPENCODE_SETTINGS, (textGeneration) => - Effect.gen(function* () { - yield* textGeneration.generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/opencode-reuse", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }); - yield* textGeneration.generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/opencode-reuse", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }); - - expect(runtimeMock.state.startCalls).toEqual([]); - expect(runtimeMock.state.promptUrls).toEqual([ - "http://127.0.0.1:9999", - "http://127.0.0.1:9999", - ]); - expect(runtimeMock.state.authHeaders).toEqual([ - `Basic ${btoa("opencode:secret-password")}`, - `Basic ${btoa("opencode:secret-password")}`, - ]); - - yield* advanceIdleClock; - - expect(runtimeMock.state.closeCalls).toEqual([]); - }), - ).pipe(Effect.provide(TestClock.layer())), - ); - }, -); diff --git a/apps/server/src/git/Layers/OpenCodeTextGeneration.ts b/apps/server/src/git/Layers/OpenCodeTextGeneration.ts deleted file mode 100644 index f2d0b4b2724808ed55430c39c1b6fe456734cb43..0000000000000000000000000000000000000000 --- a/apps/server/src/git/Layers/OpenCodeTextGeneration.ts +++ /dev/null @@ -1,464 +0,0 @@ -import { Effect, Exit, Fiber, Schema, Scope } from "effect"; -import * as Semaphore from "effect/Semaphore"; - -import { - TextGenerationError, - type ChatAttachment, - type ModelSelection, - type OpenCodeSettings, -} from "@t3tools/contracts"; -import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; -import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; - -import { ServerConfig } from "../../config.ts"; -import { resolveAttachmentPath } from "../../attachmentStore.ts"; -import { - buildBranchNamePrompt, - buildCommitMessagePrompt, - buildPrContentPrompt, - buildThreadTitlePrompt, -} from "../Prompts.ts"; -import { type TextGenerationShape } from "../Services/TextGeneration.ts"; -import { - extractJsonObject, - sanitizeCommitSubject, - sanitizePrTitle, - sanitizeThreadTitle, -} from "../Utils.ts"; -import { - OpenCodeRuntime, - type OpenCodeServerConnection, - type OpenCodeServerProcess, - openCodeRuntimeErrorDetail, - parseOpenCodeModelSlug, - toOpenCodeFileParts, -} from "../../provider/opencodeRuntime.ts"; - -const OPENCODE_TEXT_GENERATION_IDLE_TTL = "30 seconds"; - -function getOpenCodePromptErrorMessage(error: unknown): string | null { - if (!error || typeof error !== "object") { - return null; - } - - const message = - "data" in error && - error.data && - typeof error.data === "object" && - "message" in error.data && - typeof error.data.message === "string" - ? error.data.message.trim() - : ""; - if (message.length > 0) { - return message; - } - - if ("name" in error && typeof error.name === "string") { - const name = error.name.trim(); - return name.length > 0 ? name : null; - } - - return null; -} - -function getOpenCodeTextResponse(parts: ReadonlyArray | undefined): string { - return (parts ?? []) - .flatMap((part) => { - if (!part || typeof part !== "object") { - return []; - } - if (!("type" in part) || part.type !== "text") { - return []; - } - if (!("text" in part) || typeof part.text !== "string") { - return []; - } - return [part.text]; - }) - .join("") - .trim(); -} - -interface SharedOpenCodeTextGenerationServerState { - server: OpenCodeServerProcess | null; - /** - * The scope that owns the shared server's lifetime. Closing this scope - * terminates the OpenCode child process and interrupts any fibers the - * runtime forked during startup. We don't hold a `close()` function on - * the server handle anymore — the scope is the only lifecycle handle. - */ - serverScope: Scope.Closeable | null; - binaryPath: string | null; - activeRequests: number; - idleCloseFiber: Fiber.Fiber | null; -} - -export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration")(function* ( - openCodeSettings: OpenCodeSettings, - environment: NodeJS.ProcessEnv = process.env, -) { - const serverConfig = yield* ServerConfig; - const openCodeRuntime = yield* OpenCodeRuntime; - const idleFiberScope = yield* Effect.acquireRelease(Scope.make(), (scope) => - Scope.close(scope, Exit.void), - ); - const sharedServerMutex = yield* Semaphore.make(1); - const sharedServerState: SharedOpenCodeTextGenerationServerState = { - server: null, - serverScope: null, - binaryPath: null, - activeRequests: 0, - idleCloseFiber: null, - }; - - const closeSharedServer = Effect.fn("closeSharedServer")(function* () { - const scope = sharedServerState.serverScope; - sharedServerState.server = null; - sharedServerState.serverScope = null; - sharedServerState.binaryPath = null; - if (scope !== null) { - yield* Scope.close(scope, Exit.void).pipe(Effect.ignore); - } - }); - - const cancelIdleCloseFiber = Effect.fn("cancelIdleCloseFiber")(function* () { - const idleCloseFiber = sharedServerState.idleCloseFiber; - sharedServerState.idleCloseFiber = null; - if (idleCloseFiber !== null) { - yield* Fiber.interrupt(idleCloseFiber).pipe(Effect.ignore); - } - }); - - const scheduleIdleClose = Effect.fn("scheduleIdleClose")(function* ( - server: OpenCodeServerProcess, - ) { - yield* cancelIdleCloseFiber(); - const fiber = yield* Effect.sleep(OPENCODE_TEXT_GENERATION_IDLE_TTL).pipe( - Effect.andThen( - sharedServerMutex.withPermit( - Effect.gen(function* () { - if (sharedServerState.server !== server || sharedServerState.activeRequests > 0) { - return; - } - sharedServerState.idleCloseFiber = null; - yield* closeSharedServer(); - }), - ), - ), - Effect.forkIn(idleFiberScope), - ); - sharedServerState.idleCloseFiber = fiber; - }); - - const acquireSharedServer = (input: { - readonly binaryPath: string; - readonly operation: - | "generateCommitMessage" - | "generatePrContent" - | "generateBranchName" - | "generateThreadTitle"; - }) => - sharedServerMutex.withPermit( - Effect.gen(function* () { - yield* cancelIdleCloseFiber(); - - const existingServer = sharedServerState.server; - if (existingServer !== null) { - if ( - sharedServerState.binaryPath !== input.binaryPath && - sharedServerState.activeRequests === 0 - ) { - yield* closeSharedServer(); - } else { - if (sharedServerState.binaryPath !== input.binaryPath) { - yield* Effect.logWarning( - "OpenCode shared server binary path mismatch: requested " + - input.binaryPath + - " but active server uses " + - sharedServerState.binaryPath + - "; reusing existing server because there are active requests", - ); - } - sharedServerState.activeRequests += 1; - return existingServer; - } - } - - // Create a fresh scope that owns this shared server. The runtime - // will attach its child-process and fiber finalizers to this scope; - // closing it kills the server and interrupts those fibers. - // - // The `Scope.make` / spawn / record-or-close transitions run inside - // `uninterruptibleMask` so an interrupt arriving between any two - // steps can't orphan the scope (and the child process attached to - // it) before we either close it on failure or hand ownership to - // `sharedServerState`. `restore` keeps the actual spawn - // interruptible; an interrupt during the spawn is captured by - // `Effect.exit` and drives us through the failure branch that - // closes the fresh scope. - return yield* Effect.uninterruptibleMask((restore) => - Effect.gen(function* () { - const serverScope = yield* Scope.make(); - const startedExit = yield* Effect.exit( - restore( - openCodeRuntime - .startOpenCodeServerProcess({ - binaryPath: input.binaryPath, - environment, - }) - .pipe( - Effect.provideService(Scope.Scope, serverScope), - Effect.mapError( - (cause) => - new TextGenerationError({ - operation: input.operation, - detail: openCodeRuntimeErrorDetail(cause), - cause, - }), - ), - ), - ), - ); - if (startedExit._tag === "Failure") { - yield* Scope.close(serverScope, Exit.void).pipe(Effect.ignore); - return yield* Effect.failCause(startedExit.cause); - } - - const server = startedExit.value; - sharedServerState.server = server; - sharedServerState.serverScope = serverScope; - sharedServerState.binaryPath = input.binaryPath; - sharedServerState.activeRequests = 1; - return server; - }), - ); - }), - ); - - const releaseSharedServer = (server: OpenCodeServerProcess) => - sharedServerMutex.withPermit( - Effect.gen(function* () { - if (sharedServerState.server !== server) { - return; - } - sharedServerState.activeRequests = Math.max(0, sharedServerState.activeRequests - 1); - if (sharedServerState.activeRequests === 0) { - yield* scheduleIdleClose(server); - } - }), - ); - - // Module-level finalizer: on layer shutdown, cancel the idle close fiber - // and close the shared server scope. Consumers therefore cannot leak - // the shared OpenCode server by forgetting to call anything. - yield* Effect.addFinalizer(() => - sharedServerMutex.withPermit( - Effect.gen(function* () { - yield* cancelIdleCloseFiber(); - sharedServerState.activeRequests = 0; - yield* closeSharedServer(); - }), - ), - ); - - const runOpenCodeJson = Effect.fn("runOpenCodeJson")(function* (input: { - readonly operation: - | "generateCommitMessage" - | "generatePrContent" - | "generateBranchName" - | "generateThreadTitle"; - readonly cwd: string; - readonly prompt: string; - readonly outputSchemaJson: S; - readonly modelSelection: ModelSelection; - readonly attachments?: ReadonlyArray | undefined; - }) { - const parsedModel = parseOpenCodeModelSlug(input.modelSelection.model); - if (!parsedModel) { - return yield* new TextGenerationError({ - operation: input.operation, - detail: "OpenCode model selection must use the 'provider/model' format.", - }); - } - - const fileParts = toOpenCodeFileParts({ - attachments: input.attachments, - resolveAttachmentPath: (attachment) => - resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }), - }); - - const runAgainstServer = (server: Pick) => - Effect.tryPromise({ - try: async () => { - const client = openCodeRuntime.createOpenCodeSdkClient({ - baseUrl: server.url, - directory: input.cwd, - ...(openCodeSettings.serverUrl.length > 0 && openCodeSettings.serverPassword - ? { serverPassword: openCodeSettings.serverPassword } - : {}), - }); - const session = await client.session.create({ - title: `T3 Code ${input.operation}`, - permission: [{ permission: "*", pattern: "*", action: "deny" }], - }); - if (!session.data) { - throw new Error("OpenCode session.create returned no session payload."); - } - const selectedAgent = getModelSelectionStringOptionValue(input.modelSelection, "agent"); - const selectedVariant = getModelSelectionStringOptionValue( - input.modelSelection, - "variant", - ); - - const result = await client.session.prompt({ - sessionID: session.data.id, - model: parsedModel, - ...(selectedAgent ? { agent: selectedAgent } : {}), - ...(selectedVariant ? { variant: selectedVariant } : {}), - parts: [{ type: "text", text: input.prompt }, ...fileParts], - }); - const info = result.data?.info; - const errorMessage = getOpenCodePromptErrorMessage(info?.error); - if (errorMessage) { - throw new Error(errorMessage); - } - const rawText = getOpenCodeTextResponse(result.data?.parts); - if (rawText.length === 0) { - throw new Error("OpenCode returned empty output."); - } - return rawText; - }, - catch: (cause) => - new TextGenerationError({ - operation: input.operation, - detail: openCodeRuntimeErrorDetail(cause), - cause, - }), - }); - - const rawOutput = - openCodeSettings.serverUrl.length > 0 - ? yield* runAgainstServer({ url: openCodeSettings.serverUrl }) - : yield* Effect.acquireUseRelease( - acquireSharedServer({ - binaryPath: openCodeSettings.binaryPath, - operation: input.operation, - }), - runAgainstServer, - releaseSharedServer, - ); - - return yield* Schema.decodeEffect(Schema.fromJsonString(input.outputSchemaJson))( - extractJsonObject(rawOutput), - ).pipe( - Effect.catchTag("SchemaError", (cause) => - Effect.fail( - new TextGenerationError({ - operation: input.operation, - detail: "OpenCode returned invalid structured output.", - cause, - }), - ), - ), - ); - }); - - const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn( - "OpenCodeTextGeneration.generateCommitMessage", - )(function* (input) { - const { prompt, outputSchema } = buildCommitMessagePrompt({ - branch: input.branch, - stagedSummary: input.stagedSummary, - stagedPatch: input.stagedPatch, - includeBranch: input.includeBranch === true, - }); - const generated = yield* runOpenCodeJson({ - operation: "generateCommitMessage", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - modelSelection: input.modelSelection, - }); - - return { - subject: sanitizeCommitSubject(generated.subject), - body: generated.body.trim(), - ...("branch" in generated && typeof generated.branch === "string" - ? { branch: sanitizeFeatureBranchName(generated.branch) } - : {}), - }; - }); - - const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn( - "OpenCodeTextGeneration.generatePrContent", - )(function* (input) { - const { prompt, outputSchema } = buildPrContentPrompt({ - baseBranch: input.baseBranch, - headBranch: input.headBranch, - commitSummary: input.commitSummary, - diffSummary: input.diffSummary, - diffPatch: input.diffPatch, - }); - const generated = yield* runOpenCodeJson({ - operation: "generatePrContent", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - modelSelection: input.modelSelection, - }); - - return { - title: sanitizePrTitle(generated.title), - body: generated.body.trim(), - }; - }); - - const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn( - "OpenCodeTextGeneration.generateBranchName", - )(function* (input) { - const { prompt, outputSchema } = buildBranchNamePrompt({ - message: input.message, - attachments: input.attachments, - }); - const generated = yield* runOpenCodeJson({ - operation: "generateBranchName", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - modelSelection: input.modelSelection, - attachments: input.attachments, - }); - - return { - branch: sanitizeBranchFragment(generated.branch), - }; - }); - - const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn( - "OpenCodeTextGeneration.generateThreadTitle", - )(function* (input) { - const { prompt, outputSchema } = buildThreadTitlePrompt({ - message: input.message, - attachments: input.attachments, - }); - const generated = yield* runOpenCodeJson({ - operation: "generateThreadTitle", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - modelSelection: input.modelSelection, - attachments: input.attachments, - }); - - return { - title: sanitizeThreadTitle(generated.title), - }; - }); - - return { - generateCommitMessage, - generatePrContent, - generateBranchName, - generateThreadTitle, - } satisfies TextGenerationShape; -}); diff --git a/apps/server/src/git/Layers/TextGenerationLive.test.ts b/apps/server/src/git/Layers/TextGenerationLive.test.ts deleted file mode 100644 index 3b03696eb42946c996e9d921ab908f50f727c8a7..0000000000000000000000000000000000000000 --- a/apps/server/src/git/Layers/TextGenerationLive.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { it } from "@effect/vitest"; -import { Effect, PubSub, Result, Stream } from "effect"; -import { describe, expect } from "vitest"; - -import { ProviderInstanceId } from "@t3tools/contracts"; -import { createModelSelection } from "@t3tools/shared/model"; - -import type { ProviderInstance } from "../../provider/ProviderDriver.ts"; -import type { ProviderInstanceRegistryShape } from "../../provider/Services/ProviderInstanceRegistry.ts"; -import type { TextGenerationShape } from "../Services/TextGeneration.ts"; - -import { makeTextGenerationFromRegistry } from "./TextGenerationLive.ts"; - -const makeStubTextGeneration = (overrides: Partial): TextGenerationShape => ({ - generateCommitMessage: () => - Effect.die("generateCommitMessage stub not configured for this test"), - generatePrContent: () => Effect.die("generatePrContent stub not configured for this test"), - generateBranchName: () => Effect.die("generateBranchName stub not configured for this test"), - generateThreadTitle: () => Effect.die("generateThreadTitle stub not configured for this test"), - ...overrides, -}); - -const makeStubInstance = ( - instanceId: ProviderInstanceId, - textGeneration: TextGenerationShape, -): ProviderInstance => - ({ - instanceId, - driverKind: instanceId as unknown as ProviderInstance["driverKind"], - continuationIdentity: { - driverKind: instanceId as unknown as ProviderInstance["driverKind"], - continuationKey: `${instanceId}:test`, - }, - displayName: undefined, - enabled: true, - snapshot: {} as ProviderInstance["snapshot"], - adapter: {} as ProviderInstance["adapter"], - textGeneration, - }) satisfies ProviderInstance; - -const makeStubRegistry = ( - instances: ReadonlyArray, -): ProviderInstanceRegistryShape => { - const byId = new Map(instances.map((instance) => [instance.instanceId, instance] as const)); - return { - getInstance: (id) => Effect.succeed(byId.get(id)), - listInstances: Effect.succeed(instances), - listUnavailable: Effect.succeed([]), - streamChanges: Stream.empty, - // Tests never drive changes through this stub; acquire a throwaway - // subscription on an unused PubSub so the shape is satisfied. - subscribeChanges: Effect.flatMap(PubSub.unbounded(), (pubsub) => - PubSub.subscribe(pubsub), - ), - }; -}; - -describe("makeTextGenerationFromRegistry", () => { - it.effect("delegates to the matching instance's textGeneration closure", () => - Effect.gen(function* () { - const personalId = ProviderInstanceId.make("codex_personal"); - const personalCalls: string[] = []; - const personal = makeStubInstance( - personalId, - makeStubTextGeneration({ - generateBranchName: (input) => { - personalCalls.push(input.message); - return Effect.succeed({ branch: "personal-branch" }); - }, - }), - ); - - const workId = ProviderInstanceId.make("codex_work"); - const work = makeStubInstance( - workId, - makeStubTextGeneration({ - generateBranchName: () => Effect.succeed({ branch: "work-branch" }), - }), - ); - - const tg = makeTextGenerationFromRegistry(makeStubRegistry([personal, work])); - - const result = yield* tg.generateBranchName({ - cwd: process.cwd(), - message: "Refactor the routing layer", - modelSelection: createModelSelection(ProviderInstanceId.make("codex_personal"), "gpt-5"), - }); - - expect(result.branch).toBe("personal-branch"); - expect(personalCalls).toEqual(["Refactor the routing layer"]); - }), - ); - - it.effect("fails with TextGenerationError when the instance is unknown", () => - Effect.gen(function* () { - const tg = makeTextGenerationFromRegistry(makeStubRegistry([])); - - const result = yield* tg - .generateBranchName({ - cwd: process.cwd(), - message: "anything", - modelSelection: createModelSelection( - ProviderInstanceId.make("missing_instance"), - "gpt-5", - ), - }) - .pipe(Effect.result); - - expect(Result.isFailure(result)).toBe(true); - if (Result.isFailure(result)) { - expect(result.failure._tag).toBe("TextGenerationError"); - expect(result.failure.operation).toBe("generateBranchName"); - expect(result.failure.detail).toContain("missing_instance"); - } - }), - ); -}); diff --git a/apps/server/src/git/Layers/TextGenerationLive.ts b/apps/server/src/git/Layers/TextGenerationLive.ts deleted file mode 100644 index 58e1541d55c5087ac676fe0ab9d4fc73b262b243..0000000000000000000000000000000000000000 --- a/apps/server/src/git/Layers/TextGenerationLive.ts +++ /dev/null @@ -1,101 +0,0 @@ -/** - * TextGenerationLive — registry-backed implementation of the `TextGeneration` - * service tag. - * - * The `TextGeneration` tag is kept as a thin facade over - * `ProviderInstanceRegistry`. Every op pulls `modelSelection.instanceId`, - * looks up the matching `ProviderInstance`, and delegates to that instance's - * own `textGeneration` closure (built by its driver's `create()`). - * - * There is deliberately no per-driver dispatch here — the registry already - * knows which driver backs each instance, and each `ProviderInstance` - * carries the fully-bound `TextGenerationShape` produced by its driver. - * That means: - * - * - Multiple instances of the same driver (e.g. `codex_personal`, - * `codex_work`) each get their own text-generation closure bound to - * their own settings — the routing is by instance, not by driver. - * - Unknown or disabled instances surface a `TextGenerationError` with - * the missing `instanceId`, instead of silently falling back to a - * default. - * - * This replaces the old `RoutingTextGenerationLive`, which tried to route - * by driver-kind and misused `modelSelection.instanceId` as a driver-id - * literal. - * - * @module git/Layers/TextGenerationLive - */ -import { Effect, Layer } from "effect"; - -import { TextGenerationError } from "@t3tools/contracts"; -import type { ProviderInstanceId } from "@t3tools/contracts"; - -import { - ProviderInstanceRegistry, - type ProviderInstanceRegistryShape, -} from "../../provider/Services/ProviderInstanceRegistry.ts"; -import type { ProviderInstance } from "../../provider/ProviderDriver.ts"; -import { TextGeneration, type TextGenerationShape } from "../Services/TextGeneration.ts"; - -type TextGenerationOp = - | "generateCommitMessage" - | "generatePrContent" - | "generateBranchName" - | "generateThreadTitle"; - -const resolveInstance = ( - registry: ProviderInstanceRegistryShape, - operation: TextGenerationOp, - instanceId: ProviderInstanceId, -): Effect.Effect => - registry.getInstance(instanceId).pipe( - Effect.flatMap((instance) => - instance - ? Effect.succeed(instance.textGeneration) - : Effect.fail( - new TextGenerationError({ - operation, - detail: `No provider instance registered for id '${instanceId}'.`, - }), - ), - ), - ); - -/** - * Build a `TextGenerationShape` that routes every call through the - * registry. Exposed separately from the Layer so tests can construct it - * against a stub registry without layering gymnastics. - */ -export const makeTextGenerationFromRegistry = ( - registry: ProviderInstanceRegistryShape, -): TextGenerationShape => ({ - generateCommitMessage: (input) => - resolveInstance(registry, "generateCommitMessage", input.modelSelection.instanceId).pipe( - Effect.flatMap((tg) => tg.generateCommitMessage(input)), - ), - generatePrContent: (input) => - resolveInstance(registry, "generatePrContent", input.modelSelection.instanceId).pipe( - Effect.flatMap((tg) => tg.generatePrContent(input)), - ), - generateBranchName: (input) => - resolveInstance(registry, "generateBranchName", input.modelSelection.instanceId).pipe( - Effect.flatMap((tg) => tg.generateBranchName(input)), - ), - generateThreadTitle: (input) => - resolveInstance(registry, "generateThreadTitle", input.modelSelection.instanceId).pipe( - Effect.flatMap((tg) => tg.generateThreadTitle(input)), - ), -}); - -/** - * `TextGeneration` Layer wired to the `ProviderInstanceRegistry`. The rest - * of the server keeps using `yield* TextGeneration` — only the underlying - * wiring changed from kind-based routing to instance-based routing. - */ -export const TextGenerationLive = Layer.effect( - TextGeneration, - Effect.gen(function* () { - const registry = yield* ProviderInstanceRegistry; - return makeTextGenerationFromRegistry(registry); - }), -); diff --git a/apps/server/src/git/Prompts.test.ts b/apps/server/src/git/Prompts.test.ts deleted file mode 100644 index d8d079c0cf360d94ae3a72034c8a16b645ae8789..0000000000000000000000000000000000000000 --- a/apps/server/src/git/Prompts.test.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - buildBranchNamePrompt, - buildCommitMessagePrompt, - buildPrContentPrompt, - buildThreadTitlePrompt, -} from "./Prompts.ts"; -import { normalizeCliError, sanitizeThreadTitle } from "./Utils.ts"; -import { TextGenerationError } from "@t3tools/contracts"; - -describe("buildCommitMessagePrompt", () => { - it("includes staged patch and summary in the prompt", () => { - const result = buildCommitMessagePrompt({ - branch: "main", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md\n+hello", - includeBranch: false, - }); - - expect(result.prompt).toContain("Staged files:"); - expect(result.prompt).toContain("M README.md"); - expect(result.prompt).toContain("Staged patch:"); - expect(result.prompt).toContain("diff --git a/README.md b/README.md"); - expect(result.prompt).toContain("Branch: main"); - // Should NOT include the branch generation instruction - expect(result.prompt).not.toContain("branch must be a short semantic git branch fragment"); - }); - - it("includes branch generation instruction when includeBranch is true", () => { - const result = buildCommitMessagePrompt({ - branch: "feature/foo", - stagedSummary: "M README.md", - stagedPatch: "diff", - includeBranch: true, - }); - - expect(result.prompt).toContain("branch must be a short semantic git branch fragment"); - expect(result.prompt).toContain("Return a JSON object with keys: subject, body, branch."); - }); - - it("shows (detached) when branch is null", () => { - const result = buildCommitMessagePrompt({ - branch: null, - stagedSummary: "M a.ts", - stagedPatch: "diff", - includeBranch: false, - }); - - expect(result.prompt).toContain("Branch: (detached)"); - }); -}); - -describe("buildPrContentPrompt", () => { - it("includes branch names, commits, and diff in the prompt", () => { - const result = buildPrContentPrompt({ - baseBranch: "main", - headBranch: "feature/auth", - commitSummary: "feat: add login page", - diffSummary: "3 files changed", - diffPatch: "diff --git a/auth.ts b/auth.ts\n+export function login()", - }); - - expect(result.prompt).toContain("Base branch: main"); - expect(result.prompt).toContain("Head branch: feature/auth"); - expect(result.prompt).toContain("Commits:"); - expect(result.prompt).toContain("feat: add login page"); - expect(result.prompt).toContain("Diff stat:"); - expect(result.prompt).toContain("3 files changed"); - expect(result.prompt).toContain("Diff patch:"); - expect(result.prompt).toContain("export function login()"); - }); -}); - -describe("buildBranchNamePrompt", () => { - it("includes the user message in the prompt", () => { - const result = buildBranchNamePrompt({ - message: "Fix the login timeout bug", - }); - - expect(result.prompt).toContain("User message:"); - expect(result.prompt).toContain("Fix the login timeout bug"); - expect(result.prompt).not.toContain("Attachment metadata:"); - }); - - it("includes attachment metadata when attachments are provided", () => { - const result = buildBranchNamePrompt({ - message: "Fix the layout from screenshot", - attachments: [ - { - type: "image" as const, - id: "att-123", - name: "screenshot.png", - mimeType: "image/png", - sizeBytes: 12345, - }, - ], - }); - - expect(result.prompt).toContain("Attachment metadata:"); - expect(result.prompt).toContain("screenshot.png"); - expect(result.prompt).toContain("image/png"); - expect(result.prompt).toContain("12345 bytes"); - }); -}); - -describe("buildThreadTitlePrompt", () => { - it("includes the user message in the prompt", () => { - const result = buildThreadTitlePrompt({ - message: "Investigate reconnect regressions after session restore", - }); - - expect(result.prompt).toContain("User message:"); - expect(result.prompt).toContain("Investigate reconnect regressions after session restore"); - expect(result.prompt).not.toContain("Attachment metadata:"); - }); - - it("includes attachment metadata when attachments are provided", () => { - const result = buildThreadTitlePrompt({ - message: "Name this thread from the screenshot", - attachments: [ - { - type: "image" as const, - id: "att-456", - name: "thread.png", - mimeType: "image/png", - sizeBytes: 67890, - }, - ], - }); - - expect(result.prompt).toContain("Attachment metadata:"); - expect(result.prompt).toContain("thread.png"); - expect(result.prompt).toContain("image/png"); - expect(result.prompt).toContain("67890 bytes"); - }); -}); - -describe("sanitizeThreadTitle", () => { - it("truncates long titles with the shared sidebar-safe limit", () => { - expect( - sanitizeThreadTitle( - ' "Reconnect failures after restart because the session state does not recover" ', - ), - ).toBe("Reconnect failures after restart because the se..."); - }); -}); - -describe("normalizeCliError", () => { - it("detects 'Command not found' and includes CLI name in the message", () => { - const error = normalizeCliError( - "claude", - "generateCommitMessage", - new Error("Command not found: claude"), - "Something went wrong", - ); - - expect(error).toBeInstanceOf(TextGenerationError); - expect(error.detail).toContain("Claude CLI"); - expect(error.detail).toContain("not available on PATH"); - }); - - it("uses the CLI name from the first argument for codex", () => { - const error = normalizeCliError( - "codex", - "generateBranchName", - new Error("Command not found: codex"), - "Something went wrong", - ); - - expect(error).toBeInstanceOf(TextGenerationError); - expect(error.detail).toContain("Codex CLI"); - expect(error.detail).toContain("not available on PATH"); - }); - - it("returns the error as-is if it is already a TextGenerationError", () => { - const existing = new TextGenerationError({ - operation: "generatePrContent", - detail: "Already wrapped", - }); - - const result = normalizeCliError("claude", "generatePrContent", existing, "fallback"); - - expect(result).toBe(existing); - }); - - it("wraps unknown non-Error values with the fallback message", () => { - const result = normalizeCliError("codex", "generateCommitMessage", "string error", "fallback"); - - expect(result).toBeInstanceOf(TextGenerationError); - expect(result.detail).toBe("fallback"); - }); -}); diff --git a/apps/server/src/git/Prompts.ts b/apps/server/src/git/Prompts.ts deleted file mode 100644 index 4092358825c3bafbcaa70e71f0931cf2c5c27f8f..0000000000000000000000000000000000000000 --- a/apps/server/src/git/Prompts.ts +++ /dev/null @@ -1,202 +0,0 @@ -/** - * Shared prompt builders for text generation providers. - * - * Extracts the prompt construction logic that is identical across - * Codex, Claude, and any future CLI-based text generation backends. - * - * @module textGenerationPrompts - */ -import { Schema } from "effect"; -import type { ChatAttachment } from "@t3tools/contracts"; - -import { limitSection } from "./Utils.ts"; - -// --------------------------------------------------------------------------- -// Commit message -// --------------------------------------------------------------------------- - -export interface CommitMessagePromptInput { - branch: string | null; - stagedSummary: string; - stagedPatch: string; - includeBranch: boolean; -} - -export function buildCommitMessagePrompt(input: CommitMessagePromptInput) { - const wantsBranch = input.includeBranch; - - const prompt = [ - "You write concise git commit messages.", - wantsBranch - ? "Return a JSON object with keys: subject, body, branch." - : "Return a JSON object with keys: subject, body.", - "Rules:", - "- subject must be imperative, <= 72 chars, and no trailing period", - "- body can be empty string or short bullet points", - ...(wantsBranch - ? ["- branch must be a short semantic git branch fragment for this change"] - : []), - "- capture the primary user-visible or developer-visible change", - "", - `Branch: ${input.branch ?? "(detached)"}`, - "", - "Staged files:", - limitSection(input.stagedSummary, 6_000), - "", - "Staged patch:", - limitSection(input.stagedPatch, 40_000), - ].join("\n"); - - if (wantsBranch) { - return { - prompt, - outputSchema: Schema.Struct({ - subject: Schema.String, - body: Schema.String, - branch: Schema.String, - }), - }; - } - - return { - prompt, - outputSchema: Schema.Struct({ - subject: Schema.String, - body: Schema.String, - }), - }; -} - -// --------------------------------------------------------------------------- -// PR content -// --------------------------------------------------------------------------- - -export interface PrContentPromptInput { - baseBranch: string; - headBranch: string; - commitSummary: string; - diffSummary: string; - diffPatch: string; -} - -export function buildPrContentPrompt(input: PrContentPromptInput) { - const prompt = [ - "You write GitHub pull request content.", - "Return a JSON object with keys: title, body.", - "Rules:", - "- title should be concise and specific", - "- body must be markdown and include headings '## Summary' and '## Testing'", - "- under Summary, provide short bullet points", - "- under Testing, include bullet points with concrete checks or 'Not run' where appropriate", - "", - `Base branch: ${input.baseBranch}`, - `Head branch: ${input.headBranch}`, - "", - "Commits:", - limitSection(input.commitSummary, 12_000), - "", - "Diff stat:", - limitSection(input.diffSummary, 12_000), - "", - "Diff patch:", - limitSection(input.diffPatch, 40_000), - ].join("\n"); - - const outputSchema = Schema.Struct({ - title: Schema.String, - body: Schema.String, - }); - - return { prompt, outputSchema }; -} - -// --------------------------------------------------------------------------- -// Branch name -// --------------------------------------------------------------------------- - -export interface BranchNamePromptInput { - message: string; - attachments?: ReadonlyArray | undefined; -} - -interface PromptFromMessageInput { - instruction: string; - responseShape: string; - rules: ReadonlyArray; - message: string; - attachments?: ReadonlyArray | undefined; -} - -function buildPromptFromMessage(input: PromptFromMessageInput): string { - const attachmentLines = (input.attachments ?? []).map( - (attachment) => `- ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes)`, - ); - - const promptSections = [ - input.instruction, - input.responseShape, - "Rules:", - ...input.rules.map((rule) => `- ${rule}`), - "", - "User message:", - limitSection(input.message, 8_000), - ]; - if (attachmentLines.length > 0) { - promptSections.push( - "", - "Attachment metadata:", - limitSection(attachmentLines.join("\n"), 4_000), - ); - } - - return promptSections.join("\n"); -} - -export function buildBranchNamePrompt(input: BranchNamePromptInput) { - const prompt = buildPromptFromMessage({ - instruction: "You generate concise git branch names.", - responseShape: "Return a JSON object with key: branch.", - rules: [ - "Branch should describe the requested work from the user message.", - "Keep it short and specific (2-6 words).", - "Use plain words only, no issue prefixes and no punctuation-heavy text.", - "If images are attached, use them as primary context for visual/UI issues.", - ], - message: input.message, - attachments: input.attachments, - }); - const outputSchema = Schema.Struct({ - branch: Schema.String, - }); - - return { prompt, outputSchema }; -} - -// --------------------------------------------------------------------------- -// Thread title -// --------------------------------------------------------------------------- - -export interface ThreadTitlePromptInput { - message: string; - attachments?: ReadonlyArray | undefined; -} - -export function buildThreadTitlePrompt(input: ThreadTitlePromptInput) { - const prompt = buildPromptFromMessage({ - instruction: "You write concise thread titles for coding conversations.", - responseShape: "Return a JSON object with key: title.", - rules: [ - "Title should summarize the user's request, not restate it verbatim.", - "Keep it short and specific (3-8 words).", - "Avoid quotes, filler, prefixes, and trailing punctuation.", - "If images are attached, use them as primary context for visual/UI issues.", - ], - message: input.message, - attachments: input.attachments, - }); - const outputSchema = Schema.Struct({ - title: Schema.String, - }); - - return { prompt, outputSchema }; -} diff --git a/apps/server/src/git/Services/GitCore.ts b/apps/server/src/git/Services/GitCore.ts deleted file mode 100644 index 9f3bc0b9b9168499a791098a470f9d1c3ac37ebb..0000000000000000000000000000000000000000 --- a/apps/server/src/git/Services/GitCore.ts +++ /dev/null @@ -1,313 +0,0 @@ -/** - * GitCore - Effect service contract for low-level Git operations. - * - * Wraps core repository primitives used by higher-level orchestration - * services and WebSocket routes. - * - * @module GitCore - */ -import { Context } from "effect"; -import type { Effect } from "effect"; -import type { - GitCheckoutInput, - GitCheckoutResult, - GitCreateBranchInput, - GitCreateBranchResult, - GitCreateWorktreeInput, - GitCreateWorktreeResult, - GitInitInput, - GitListBranchesInput, - GitListBranchesResult, - GitPullResult, - GitRemoveWorktreeInput, - GitStatusInput, - GitStatusResult, -} from "@t3tools/contracts"; - -import type { GitCommandError } from "@t3tools/contracts"; - -export interface ExecuteGitInput { - readonly operation: string; - readonly cwd: string; - readonly args: ReadonlyArray; - readonly stdin?: string; - readonly env?: NodeJS.ProcessEnv; - readonly allowNonZeroExit?: boolean; - readonly timeoutMs?: number; - readonly maxOutputBytes?: number; - readonly truncateOutputAtMaxBytes?: boolean; - readonly progress?: ExecuteGitProgress; -} - -export interface ExecuteGitResult { - readonly code: number; - readonly stdout: string; - readonly stderr: string; - readonly stdoutTruncated: boolean; - readonly stderrTruncated: boolean; -} - -export interface GitStatusDetails extends Omit { - upstreamRef: string | null; -} - -export interface GitPreparedCommitContext { - stagedSummary: string; - stagedPatch: string; -} - -export interface ExecuteGitProgress { - readonly onStdoutLine?: (line: string) => Effect.Effect; - readonly onStderrLine?: (line: string) => Effect.Effect; - readonly onHookStarted?: (hookName: string) => Effect.Effect; - readonly onHookFinished?: (input: { - hookName: string; - exitCode: number | null; - durationMs: number | null; - }) => Effect.Effect; -} - -export interface GitCommitProgress { - readonly onOutputLine?: (input: { - stream: "stdout" | "stderr"; - text: string; - }) => Effect.Effect; - readonly onHookStarted?: (hookName: string) => Effect.Effect; - readonly onHookFinished?: (input: { - hookName: string; - exitCode: number | null; - durationMs: number | null; - }) => Effect.Effect; -} - -export interface GitCommitOptions { - readonly timeoutMs?: number; - readonly progress?: GitCommitProgress; -} - -export interface GitPushResult { - status: "pushed" | "skipped_up_to_date"; - branch: string; - upstreamBranch?: string | undefined; - setUpstream?: boolean | undefined; -} - -export interface GitRangeContext { - commitSummary: string; - diffSummary: string; - diffPatch: string; -} - -export interface GitListWorkspaceFilesResult { - readonly paths: ReadonlyArray; - readonly truncated: boolean; -} - -export interface GitRenameBranchInput { - cwd: string; - oldBranch: string; - newBranch: string; -} - -export interface GitRenameBranchResult { - branch: string; -} - -export interface GitFetchPullRequestBranchInput { - cwd: string; - prNumber: number; - branch: string; -} - -export interface GitEnsureRemoteInput { - cwd: string; - preferredName: string; - url: string; -} - -export interface GitFetchRemoteBranchInput { - cwd: string; - remoteName: string; - remoteBranch: string; - localBranch: string; -} - -export interface GitSetBranchUpstreamInput { - cwd: string; - branch: string; - remoteName: string; - remoteBranch: string; -} - -/** - * GitCoreShape - Service API for low-level Git repository interactions. - */ -export interface GitCoreShape { - /** - * Execute a raw Git command. - */ - readonly execute: (input: ExecuteGitInput) => Effect.Effect; - - /** - * Read Git status for a repository. - */ - readonly status: (input: GitStatusInput) => Effect.Effect; - - /** - * Read detailed working tree / branch status for a repository. - */ - readonly statusDetails: (cwd: string) => Effect.Effect; - - /** - * Read detailed working tree / branch status without refreshing remote tracking refs. - */ - readonly statusDetailsLocal: (cwd: string) => Effect.Effect; - - /** - * Build staged change context for commit generation. - */ - readonly prepareCommitContext: ( - cwd: string, - filePaths?: readonly string[], - ) => Effect.Effect; - - /** - * Create a commit with provided subject/body. - */ - readonly commit: ( - cwd: string, - subject: string, - body: string, - options?: GitCommitOptions, - ) => Effect.Effect<{ commitSha: string }, GitCommandError>; - - /** - * Push current branch, setting upstream if needed. - */ - readonly pushCurrentBranch: ( - cwd: string, - fallbackBranch: string | null, - ) => Effect.Effect; - - /** - * Collect commit/diff context between base branch and current HEAD. - */ - readonly readRangeContext: ( - cwd: string, - baseBranch: string, - ) => Effect.Effect; - - /** - * Read a Git config value from the local repository. - */ - readonly readConfigValue: ( - cwd: string, - key: string, - ) => Effect.Effect; - - /** - * Determine whether the provided cwd is inside a git work tree. - */ - readonly isInsideWorkTree: (cwd: string) => Effect.Effect; - - /** - * List tracked and untracked workspace file paths relative to cwd. - */ - readonly listWorkspaceFiles: ( - cwd: string, - ) => Effect.Effect; - - /** - * Remove gitignored paths from a relative path list. - */ - readonly filterIgnoredPaths: ( - cwd: string, - relativePaths: ReadonlyArray, - ) => Effect.Effect, GitCommandError>; - - /** - * List local + remote branches and branch metadata. - */ - readonly listBranches: ( - input: GitListBranchesInput, - ) => Effect.Effect; - - /** - * Pull current branch from upstream using fast-forward only. - */ - readonly pullCurrentBranch: (cwd: string) => Effect.Effect; - - /** - * Create a worktree and branch from a base branch. - */ - readonly createWorktree: ( - input: GitCreateWorktreeInput, - ) => Effect.Effect; - - /** - * Materialize a GitHub pull request head as a local branch without switching checkout. - */ - readonly fetchPullRequestBranch: ( - input: GitFetchPullRequestBranchInput, - ) => Effect.Effect; - - /** - * Ensure a named remote exists for the provided URL, returning the reused or created remote name. - */ - readonly ensureRemote: (input: GitEnsureRemoteInput) => Effect.Effect; - - /** - * Fetch a remote branch into a local branch without checkout. - */ - readonly fetchRemoteBranch: ( - input: GitFetchRemoteBranchInput, - ) => Effect.Effect; - - /** - * Set the upstream tracking branch for a local branch. - */ - readonly setBranchUpstream: ( - input: GitSetBranchUpstreamInput, - ) => Effect.Effect; - - /** - * Remove an existing worktree. - */ - readonly removeWorktree: (input: GitRemoveWorktreeInput) => Effect.Effect; - - /** - * Rename an existing local branch. - */ - readonly renameBranch: ( - input: GitRenameBranchInput, - ) => Effect.Effect; - - /** - * Create a local branch. - */ - readonly createBranch: ( - input: GitCreateBranchInput, - ) => Effect.Effect; - - /** - * Checkout an existing branch and refresh its upstream metadata in background. - */ - readonly checkoutBranch: ( - input: GitCheckoutInput, - ) => Effect.Effect; - - /** - * Initialize a repository in the provided directory. - */ - readonly initRepo: (input: GitInitInput) => Effect.Effect; - - /** - * List local branch names (short format). - */ - readonly listLocalBranchNames: (cwd: string) => Effect.Effect; -} - -/** - * GitCore - Service tag for low-level Git repository operations. - */ -export class GitCore extends Context.Service()("t3/git/Services/GitCore") {} diff --git a/apps/server/src/git/Services/GitHubCli.ts b/apps/server/src/git/Services/GitHubCli.ts deleted file mode 100644 index 81a53761a3b8f7fbb6082382508d8830757258bb..0000000000000000000000000000000000000000 --- a/apps/server/src/git/Services/GitHubCli.ts +++ /dev/null @@ -1,103 +0,0 @@ -/** - * GitHubCli - Effect service contract for `gh` process interactions. - * - * Provides thin command execution helpers used by Git workflow orchestration. - * - * @module GitHubCli - */ -import { Context } from "effect"; -import type { Effect } from "effect"; - -import type { ProcessRunResult } from "../../processRunner.ts"; -import type { GitHubCliError } from "@t3tools/contracts"; - -export interface GitHubPullRequestSummary { - readonly number: number; - readonly title: string; - readonly url: string; - readonly baseRefName: string; - readonly headRefName: string; - readonly state?: "open" | "closed" | "merged"; - readonly isCrossRepository?: boolean; - readonly headRepositoryNameWithOwner?: string | null; - readonly headRepositoryOwnerLogin?: string | null; -} - -export interface GitHubRepositoryCloneUrls { - readonly nameWithOwner: string; - readonly url: string; - readonly sshUrl: string; -} - -/** - * GitHubCliShape - Service API for executing GitHub CLI commands. - */ -export interface GitHubCliShape { - /** - * Execute a GitHub CLI command and return full process output. - */ - readonly execute: (input: { - readonly cwd: string; - readonly args: ReadonlyArray; - readonly timeoutMs?: number; - }) => Effect.Effect; - - /** - * List open pull requests for a head branch. - */ - readonly listOpenPullRequests: (input: { - readonly cwd: string; - readonly headSelector: string; - readonly limit?: number; - }) => Effect.Effect, GitHubCliError>; - - /** - * Resolve a pull request by URL, number, or branch-ish identifier. - */ - readonly getPullRequest: (input: { - readonly cwd: string; - readonly reference: string; - }) => Effect.Effect; - - /** - * Resolve clone URLs for a GitHub repository. - */ - readonly getRepositoryCloneUrls: (input: { - readonly cwd: string; - readonly repository: string; - }) => Effect.Effect; - - /** - * Create a pull request from branch context and body file. - */ - readonly createPullRequest: (input: { - readonly cwd: string; - readonly baseBranch: string; - readonly headSelector: string; - readonly title: string; - readonly bodyFile: string; - }) => Effect.Effect; - - /** - * Resolve repository default branch through GitHub metadata. - */ - readonly getDefaultBranch: (input: { - readonly cwd: string; - }) => Effect.Effect; - - /** - * Checkout a pull request into the current repository worktree. - */ - readonly checkoutPullRequest: (input: { - readonly cwd: string; - readonly reference: string; - readonly force?: boolean; - }) => Effect.Effect; -} - -/** - * GitHubCli - Service tag for GitHub CLI process execution. - */ -export class GitHubCli extends Context.Service()( - "t3/git/Services/GitHubCli", -) {} diff --git a/apps/server/src/git/Services/GitManager.ts b/apps/server/src/git/Services/GitManager.ts deleted file mode 100644 index 29c762195e57a41fcc197203aff4cd45c47d92d2..0000000000000000000000000000000000000000 --- a/apps/server/src/git/Services/GitManager.ts +++ /dev/null @@ -1,104 +0,0 @@ -/** - * GitManager - Effect service contract for stacked Git workflows. - * - * Orchestrates status inspection and commit/push/PR flows by composing - * lower-level Git and external tool services. - * - * @module GitManager - */ -import { - GitActionProgressEvent, - GitPreparePullRequestThreadInput, - GitPreparePullRequestThreadResult, - GitPullRequestRefInput, - GitResolvePullRequestResult, - GitRunStackedActionInput, - GitRunStackedActionResult, - GitStatusLocalResult, - GitStatusRemoteResult, - GitStatusInput, - GitStatusResult, -} from "@t3tools/contracts"; -import { Context } from "effect"; -import type { Effect } from "effect"; -import type { GitManagerServiceError } from "@t3tools/contracts"; - -export interface GitActionProgressReporter { - readonly publish: (event: GitActionProgressEvent) => Effect.Effect; -} - -export interface GitRunStackedActionOptions { - readonly actionId?: string; - readonly progressReporter?: GitActionProgressReporter; -} - -/** - * GitManagerShape - Service API for high-level Git workflow actions. - */ -export interface GitManagerShape { - /** - * Read current repository Git status plus open PR metadata when available. - */ - readonly status: ( - input: GitStatusInput, - ) => Effect.Effect; - - /** - * Read local repository status without remote hosting enrichment. - */ - readonly localStatus: ( - input: GitStatusInput, - ) => Effect.Effect; - - /** - * Read remote tracking / PR status for a repository. - */ - readonly remoteStatus: ( - input: GitStatusInput, - ) => Effect.Effect; - - /** - * Clear any cached local status snapshot for a repository. - */ - readonly invalidateLocalStatus: (cwd: string) => Effect.Effect; - - /** - * Clear any cached remote status snapshot for a repository. - */ - readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect; - - /** - * Clear any cached status snapshot for a repository so the next read is fresh. - */ - readonly invalidateStatus: (cwd: string) => Effect.Effect; - - /** - * Resolve a pull request by URL/number against the current repository. - */ - readonly resolvePullRequest: ( - input: GitPullRequestRefInput, - ) => Effect.Effect; - - /** - * Prepare a new thread workspace from a pull request in local or worktree mode. - */ - readonly preparePullRequestThread: ( - input: GitPreparePullRequestThreadInput, - ) => Effect.Effect; - - /** - * Run a Git action (`commit`, `push`, `create_pr`, `commit_push`, `commit_push_pr`). - * When `featureBranch` is set, creates and checks out a feature branch first. - */ - readonly runStackedAction: ( - input: GitRunStackedActionInput, - options?: GitRunStackedActionOptions, - ) => Effect.Effect; -} - -/** - * GitManager - Service tag for stacked Git workflow orchestration. - */ -export class GitManager extends Context.Service()( - "t3/git/Services/GitManager", -) {} diff --git a/apps/server/src/git/Services/GitStatusBroadcaster.ts b/apps/server/src/git/Services/GitStatusBroadcaster.ts deleted file mode 100644 index 647f840824228d967fb64285ed45cc826952819f..0000000000000000000000000000000000000000 --- a/apps/server/src/git/Services/GitStatusBroadcaster.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Context } from "effect"; -import type { Effect, Stream } from "effect"; -import type { - GitManagerServiceError, - GitStatusInput, - GitStatusLocalResult, - GitStatusResult, - GitStatusStreamEvent, -} from "@t3tools/contracts"; - -export interface GitStatusBroadcasterShape { - readonly getStatus: ( - input: GitStatusInput, - ) => Effect.Effect; - readonly refreshLocalStatus: ( - cwd: string, - ) => Effect.Effect; - readonly refreshStatus: (cwd: string) => Effect.Effect; - readonly streamStatus: ( - input: GitStatusInput, - ) => Stream.Stream; -} - -export class GitStatusBroadcaster extends Context.Service< - GitStatusBroadcaster, - GitStatusBroadcasterShape ->()("t3/git/Services/GitStatusBroadcaster") {} diff --git a/apps/server/src/git/Services/TextGeneration.ts b/apps/server/src/git/Services/TextGeneration.ts deleted file mode 100644 index 78d37a010884e186aa38d51685b3f4af7ccc93fc..0000000000000000000000000000000000000000 --- a/apps/server/src/git/Services/TextGeneration.ts +++ /dev/null @@ -1,123 +0,0 @@ -/** - * TextGeneration - Effect service contract for AI-generated Git content. - * - * Generates commit messages and pull request titles/bodies from repository - * context prepared by Git services. - * - * @module TextGeneration - */ -import { Context } from "effect"; -import type { Effect } from "effect"; -import type { ChatAttachment, ModelSelection } from "@t3tools/contracts"; - -import type { TextGenerationError } from "@t3tools/contracts"; - -/** Providers that support git text generation (commit messages, PR content, branch names). */ -export type TextGenerationProvider = "codex" | "claudeAgent" | "cursor" | "opencode"; - -export interface CommitMessageGenerationInput { - cwd: string; - branch: string | null; - stagedSummary: string; - stagedPatch: string; - /** When true, the model also returns a semantic branch name for the change. */ - includeBranch?: boolean; - /** What model and provider to use for generation. */ - modelSelection: ModelSelection; -} - -export interface CommitMessageGenerationResult { - subject: string; - body: string; - /** Only present when `includeBranch` was set on the input. */ - branch?: string | undefined; -} - -export interface PrContentGenerationInput { - cwd: string; - baseBranch: string; - headBranch: string; - commitSummary: string; - diffSummary: string; - diffPatch: string; - /** What model and provider to use for generation. */ - modelSelection: ModelSelection; -} - -export interface PrContentGenerationResult { - title: string; - body: string; -} - -export interface BranchNameGenerationInput { - cwd: string; - message: string; - attachments?: ReadonlyArray | undefined; - /** What model and provider to use for generation. */ - modelSelection: ModelSelection; -} - -export interface BranchNameGenerationResult { - branch: string; -} - -export interface ThreadTitleGenerationInput { - cwd: string; - message: string; - attachments?: ReadonlyArray | undefined; - /** What model and provider to use for generation. */ - modelSelection: ModelSelection; -} - -export interface ThreadTitleGenerationResult { - title: string; -} - -export interface TextGenerationService { - generateCommitMessage( - input: CommitMessageGenerationInput, - ): Promise; - generatePrContent(input: PrContentGenerationInput): Promise; - generateBranchName(input: BranchNameGenerationInput): Promise; - generateThreadTitle(input: ThreadTitleGenerationInput): Promise; -} - -/** - * TextGenerationShape - Service API for commit/PR text generation. - */ -export interface TextGenerationShape { - /** - * Generate a commit message from staged change context. - */ - readonly generateCommitMessage: ( - input: CommitMessageGenerationInput, - ) => Effect.Effect; - - /** - * Generate pull request title/body from branch and diff context. - */ - readonly generatePrContent: ( - input: PrContentGenerationInput, - ) => Effect.Effect; - - /** - * Generate a concise branch name from a user message. - */ - readonly generateBranchName: ( - input: BranchNameGenerationInput, - ) => Effect.Effect; - - /** - * Generate a concise thread title from a user's first message. - */ - readonly generateThreadTitle: ( - input: ThreadTitleGenerationInput, - ) => Effect.Effect; -} - -/** - * TextGeneration - Service tag for commit and PR text generation. - */ -export class TextGeneration extends Context.Service()( - "t3/git/Services/TextGeneration", -) {} diff --git a/apps/server/src/git/Utils.ts b/apps/server/src/git/Utils.ts index 15015e8cda5f7230af944e2692fe1aa4d081dc67..6faf3e99c77b34cdd3a2dc8cebc03a687f74dde6 100644 --- a/apps/server/src/git/Utils.ts +++ b/apps/server/src/git/Utils.ts @@ -1,171 +1,6 @@ -/** - * Shared utilities for text generation layers (Codex, Claude, etc.). - * - * @module textGenerationUtils - */ -import { Schema } from "effect"; - -import { TextGenerationError } from "@t3tools/contracts"; - import { existsSync } from "node:fs"; import { join } from "node:path"; export function isGitRepository(cwd: string): boolean { return existsSync(join(cwd, ".git")); } - -/** Convert an Effect Schema to a flat JSON Schema object, inlining `$defs` when present. */ -export function toJsonSchemaObject(schema: Schema.Top): unknown { - const document = Schema.toJsonSchemaDocument(schema); - if (document.definitions && Object.keys(document.definitions).length > 0) { - return { ...document.schema, $defs: document.definitions }; - } - return document.schema; -} - -/** Truncate a text section to `maxChars`, appending a `[truncated]` marker when needed. */ -export function limitSection(value: string, maxChars: number): string { - if (value.length <= maxChars) return value; - const truncated = value.slice(0, maxChars); - return `${truncated}\n\n[truncated]`; -} - -export function extractJsonObject(raw: string): string { - const trimmed = raw.trim(); - if (trimmed.length === 0) { - return trimmed; - } - - const start = trimmed.indexOf("{"); - if (start < 0) { - return trimmed; - } - - let depth = 0; - let inString = false; - let escaping = false; - for (let index = start; index < trimmed.length; index += 1) { - const char = trimmed[index]; - if (inString) { - if (escaping) { - escaping = false; - } else if (char === "\\") { - escaping = true; - } else if (char === '"') { - inString = false; - } - continue; - } - - if (char === '"') { - inString = true; - continue; - } - - if (char === "{") { - depth += 1; - continue; - } - - if (char === "}") { - depth -= 1; - if (depth === 0) { - return trimmed.slice(start, index + 1); - } - } - } - - return trimmed.slice(start); -} - -/** Normalise a raw commit subject to imperative-mood, ≤72 chars, no trailing period. */ -export function sanitizeCommitSubject(raw: string): string { - const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? ""; - const withoutTrailingPeriod = singleLine.replace(/[.]+$/g, "").trim(); - if (withoutTrailingPeriod.length === 0) { - return "Update project files"; - } - - if (withoutTrailingPeriod.length <= 72) { - return withoutTrailingPeriod; - } - return withoutTrailingPeriod.slice(0, 72).trimEnd(); -} - -/** Normalise a raw PR title to a single line with a sensible fallback. */ -export function sanitizePrTitle(raw: string): string { - const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? ""; - if (singleLine.length > 0) { - return singleLine; - } - return "Update project changes"; -} - -/** Normalise a raw thread title to a compact single-line sidebar-safe label. */ -export function sanitizeThreadTitle(raw: string): string { - const normalized = raw - .trim() - .split(/\r?\n/g)[0] - ?.trim() - .replace(/^['"`]+|['"`]+$/g, "") - .trim() - .replace(/\s+/g, " "); - - if (!normalized || normalized.trim().length === 0) { - return "New thread"; - } - - if (normalized.length <= 50) { - return normalized; - } - - return `${normalized.slice(0, 47).trimEnd()}...`; -} - -/** CLI name to human-readable label, e.g. "codex" → "Codex CLI (`codex`)" */ -function cliLabel(cliName: string): string { - const capitalized = cliName.charAt(0).toUpperCase() + cliName.slice(1); - return `${capitalized} CLI (\`${cliName}\`)`; -} - -/** - * Normalize an unknown error from a CLI text generation process into a - * typed `TextGenerationError`. Parameterized by CLI name so both Codex - * and Claude (and future providers) can share the same logic. - */ -export function normalizeCliError( - cliName: string, - operation: string, - error: unknown, - fallback: string, -): TextGenerationError { - if (Schema.is(TextGenerationError)(error)) { - return error; - } - - if (error instanceof Error) { - const lower = error.message.toLowerCase(); - if ( - error.message.includes(`Command not found: ${cliName}`) || - lower.includes(`spawn ${cliName}`) || - lower.includes("enoent") - ) { - return new TextGenerationError({ - operation, - detail: `${cliLabel(cliName)} is required but not available on PATH.`, - cause: error, - }); - } - return new TextGenerationError({ - operation, - detail: `${fallback}: ${error.message}`, - cause: error, - }); - } - - return new TextGenerationError({ - operation, - detail: fallback, - cause: error, - }); -} diff --git a/apps/server/src/git/githubPullRequests.ts b/apps/server/src/git/githubPullRequests.ts index d137a46d6fa3abf16f3803e1156d7760fd8efc3b..f0804dda8c69c76ad8aae37b94839d7ec2da1118 100644 --- a/apps/server/src/git/githubPullRequests.ts +++ b/apps/server/src/git/githubPullRequests.ts @@ -1,4 +1,4 @@ -import { Cause, Exit, Result, Schema } from "effect"; +import { Cause, DateTime, Exit, Option, Result, Schema } from "effect"; import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts"; import { decodeJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson"; @@ -9,7 +9,7 @@ export interface NormalizedGitHubPullRequestRecord { readonly baseRefName: string; readonly headRefName: string; readonly state: "open" | "closed" | "merged"; - readonly updatedAt: string | null; + readonly updatedAt: Option.Option; readonly isCrossRepository?: boolean; readonly headRepositoryNameWithOwner?: string | null; readonly headRepositoryOwnerLogin?: string | null; @@ -23,7 +23,7 @@ const GitHubPullRequestSchema = Schema.Struct({ headRefName: TrimmedNonEmptyString, state: Schema.optional(Schema.NullOr(Schema.String)), mergedAt: Schema.optional(Schema.NullOr(Schema.String)), - updatedAt: Schema.optional(Schema.NullOr(Schema.String)), + updatedAt: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), isCrossRepository: Schema.optional(Schema.Boolean), headRepository: Schema.optional( Schema.NullOr( @@ -80,8 +80,7 @@ function normalizeGitHubPullRequestRecord( baseRefName: raw.baseRefName, headRefName: raw.headRefName, state: normalizeGitHubPullRequestState(raw), - updatedAt: - typeof raw.updatedAt === "string" && raw.updatedAt.trim().length > 0 ? raw.updatedAt : null, + updatedAt: raw.updatedAt ?? Option.none(), ...(typeof raw.isCrossRepository === "boolean" ? { isCrossRepository: raw.isCrossRepository } : {}), diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 5603ce6325228d3c058f4cb9eb6605802665fc88..ad5fb59bd1ef53c5c7743685cc2ab73c1d2016eb 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -24,8 +24,9 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { CheckpointStoreLive } from "../../checkpointing/Layers/CheckpointStore.ts"; import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts"; -import { GitCoreLive } from "../../git/Layers/GitCore.ts"; -import { GitStatusBroadcaster } from "../../git/Services/GitStatusBroadcaster.ts"; +import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts"; +import * as VcsProcess from "../../vcs/VcsProcess.ts"; +import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; import { CheckpointReactorLive } from "./CheckpointReactor.ts"; import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; @@ -282,7 +283,7 @@ describe("CheckpointReactor", () => { const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-checkpoint-reactor-test-", }); - const gitStatusBroadcasterLayer = Layer.succeed(GitStatusBroadcaster, { + const vcsStatusBroadcasterLayer = Layer.succeed(VcsStatusBroadcaster, { getStatus: () => Effect.die("getStatus should not be called in this test"), refreshLocalStatus: (cwd: string) => Effect.sync(() => { @@ -290,9 +291,9 @@ describe("CheckpointReactor", () => { }).pipe( Effect.as({ isRepo: true, - hasOriginRemote: false, - isDefaultBranch: true, - branch: "main", + hasPrimaryRemote: false, + isDefaultRef: true, + refName: "main", hasWorkingTreeChanges: false, workingTree: { files: [], insertions: 0, deletions: 0 }, }), @@ -305,11 +306,16 @@ describe("CheckpointReactor", () => { Layer.provideMerge(orchestrationLayer), Layer.provideMerge(RuntimeReceiptBusLive), Layer.provideMerge(Layer.succeed(ProviderService, provider.service)), - Layer.provideMerge(gitStatusBroadcasterLayer), - Layer.provideMerge(CheckpointStoreLive), - Layer.provideMerge(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))), + Layer.provideMerge(vcsStatusBroadcasterLayer), + Layer.provideMerge(CheckpointStoreLive.pipe(Layer.provide(VcsDriverRegistry.layer))), + Layer.provideMerge( + WorkspaceEntriesLive.pipe( + Layer.provide(WorkspacePathsLive), + Layer.provideMerge(VcsDriverRegistry.layer), + ), + ), Layer.provideMerge(WorkspacePathsLive), - Layer.provideMerge(GitCoreLive), + Layer.provideMerge(VcsProcess.layer), Layer.provideMerge(ServerConfigLayer), Layer.provideMerge(NodeServices.layer), ); diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index 71445b4671dc65386a6b19a361203167da7603ca..15677feaaf72c6ac59a5981ce6af54141b8202a8 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -24,7 +24,7 @@ import { RuntimeReceiptBus } from "../Services/RuntimeReceiptBus.ts"; import type { CheckpointStoreError } from "../../checkpointing/Errors.ts"; import type { OrchestrationDispatchError } from "../Errors.ts"; import { isGitRepository } from "../../git/Utils.ts"; -import { GitStatusBroadcaster } from "../../git/Services/GitStatusBroadcaster.ts"; +import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; import { WorkspaceEntries } from "../../workspace/Services/WorkspaceEntries.ts"; type ReactorInput = @@ -70,7 +70,7 @@ const make = Effect.gen(function* () { const checkpointStore = yield* CheckpointStore; const receiptBus = yield* RuntimeReceiptBus; const workspaceEntries = yield* WorkspaceEntries; - const gitStatusBroadcaster = yield* GitStatusBroadcaster; + const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; const appendRevertFailureActivity = (input: { readonly threadId: ThreadId; @@ -508,7 +508,7 @@ const make = Effect.gen(function* () { return; } - yield* gitStatusBroadcaster.refreshLocalStatus(sessionRuntime.value.cwd).pipe( + yield* vcsStatusBroadcaster.refreshLocalStatus(sessionRuntime.value.cwd).pipe( Effect.catch((error) => Effect.logWarning("failed to refresh local git status after turn completion", { threadId: event.threadId, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index c44f291504a7f5dc7179b4b5ad839e32c3391712..09252571c37cd03f3c67eea9af336cb842d92d99 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -33,12 +33,7 @@ import { ProviderService, type ProviderServiceShape, } from "../../provider/Services/ProviderService.ts"; -import { GitCore, type GitCoreShape } from "../../git/Services/GitCore.ts"; -import { - GitStatusBroadcaster, - type GitStatusBroadcasterShape, -} from "../../git/Services/GitStatusBroadcaster.ts"; -import { TextGeneration, type TextGenerationShape } from "../../git/Services/TextGeneration.ts"; +import { TextGeneration, type TextGenerationShape } from "../../textGeneration/TextGeneration.ts"; import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; @@ -52,6 +47,8 @@ import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { ServerSettingsService } from "../../serverSettings.ts"; +import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; +import { GitWorkflowService, type GitWorkflowServiceShape } from "../../git/GitWorkflowService.ts"; const asProjectId = (value: string): ProjectId => ProjectId.make(value); const asApprovalRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.make(value); @@ -243,9 +240,9 @@ describe("ProviderCommandReactor", () => { const refreshStatus = vi.fn((_: string) => Effect.succeed({ isRepo: true, - hasOriginRemote: true, - isDefaultBranch: false, - branch: "renamed-branch", + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "renamed-branch", hasWorkingTreeChanges: false, workingTree: { files: [], @@ -324,15 +321,19 @@ describe("ProviderCommandReactor", () => { const layer = ProviderCommandReactorLive.pipe( Layer.provideMerge(orchestrationLayer), Layer.provideMerge(Layer.succeed(ProviderService, service)), - Layer.provideMerge(Layer.succeed(GitCore, { renameBranch } as unknown as GitCoreShape)), Layer.provideMerge( - Layer.succeed(GitStatusBroadcaster, { + Layer.mock(GitWorkflowService)({ + renameBranch, + } satisfies Partial), + ), + Layer.provideMerge( + Layer.succeed(VcsStatusBroadcaster, { getStatus: () => Effect.die("getStatus should not be called in this test"), refreshLocalStatus: () => Effect.die("refreshLocalStatus should not be called in this test"), refreshStatus, streamStatus: () => Stream.die("streamStatus should not be called in this test"), - } satisfies GitStatusBroadcasterShape), + }), ), Layer.provideMerge( Layer.mock(TextGeneration, { diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 9a9f3d71b08681a73c1585a3d587a4b128032288..998475f611888ed75fdba284cd2165d5138ed21d 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -16,12 +16,10 @@ import { Cache, Cause, Duration, Effect, Equal, Layer, Option, Schema, Stream } import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts"; -import { GitCore } from "../../git/Services/GitCore.ts"; -import { GitStatusBroadcaster } from "../../git/Services/GitStatusBroadcaster.ts"; import { increment, orchestrationEventsProcessedTotal } from "../../observability/Metrics.ts"; import { ProviderAdapterRequestError } from "../../provider/Errors.ts"; import type { ProviderServiceError } from "../../provider/Errors.ts"; -import { TextGeneration } from "../../git/Services/TextGeneration.ts"; +import { TextGeneration } from "../../textGeneration/TextGeneration.ts"; import { ProviderService } from "../../provider/Services/ProviderService.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { @@ -29,6 +27,8 @@ import { type ProviderCommandReactorShape, } from "../Services/ProviderCommandReactor.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; +import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; +import { GitWorkflowService } from "../../git/GitWorkflowService.ts"; type ProviderIntentEvent = Extract< OrchestrationEvent, @@ -168,8 +168,8 @@ function buildGeneratedWorktreeBranchName(raw: string): string { const make = Effect.gen(function* () { const orchestrationEngine = yield* OrchestrationEngineService; const providerService = yield* ProviderService; - const git = yield* GitCore; - const gitStatusBroadcaster = yield* GitStatusBroadcaster; + const gitWorkflow = yield* GitWorkflowService; + const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; const textGeneration = yield* TextGeneration; const serverSettingsService = yield* ServerSettingsService; const handledTurnStartKeys = yield* Cache.make({ @@ -587,7 +587,7 @@ const make = Effect.gen(function* () { const targetBranch = buildGeneratedWorktreeBranchName(generated.branch); if (targetBranch === oldBranch) return; - const renamed = yield* git.renameBranch({ cwd, oldBranch, newBranch: targetBranch }); + const renamed = yield* gitWorkflow.renameBranch({ cwd, oldBranch, newBranch: targetBranch }); yield* orchestrationEngine.dispatch({ type: "thread.meta.update", commandId: serverCommandId("worktree-branch-rename"), @@ -595,7 +595,7 @@ const make = Effect.gen(function* () { branch: renamed.branch, worktreePath: cwd, }); - yield* gitStatusBroadcaster.refreshStatus(cwd).pipe(Effect.ignoreCause({ log: true })); + yield* vcsStatusBroadcaster.refreshStatus(cwd).pipe(Effect.ignoreCause({ log: true })); }).pipe( Effect.catchCause((cause) => Effect.logWarning("provider command reactor failed to generate or rename worktree branch", { diff --git a/apps/server/src/project/Layers/RepositoryIdentityResolver.ts b/apps/server/src/project/Layers/RepositoryIdentityResolver.ts index 307123551bb455d72ca32178beada5a85a99718d..ae35c29ce21a6db1558598cbb8b332d4aa1cd387 100644 --- a/apps/server/src/project/Layers/RepositoryIdentityResolver.ts +++ b/apps/server/src/project/Layers/RepositoryIdentityResolver.ts @@ -1,6 +1,9 @@ import type { RepositoryIdentity } from "@t3tools/contracts"; import { Cache, Duration, Effect, Exit, Layer } from "effect"; -import { detectGitHostingProviderFromRemoteUrl, normalizeGitRemoteUrl } from "@t3tools/shared/git"; +import { + detectSourceControlProviderFromGitRemoteUrl, + normalizeGitRemoteUrl, +} from "@t3tools/shared/git"; import { runProcess } from "../../processRunner.ts"; import { @@ -45,7 +48,7 @@ function buildRepositoryIdentity(input: { readonly rootPath: string; }): RepositoryIdentity { const canonicalKey = normalizeGitRemoteUrl(input.remoteUrl); - const hostingProvider = detectGitHostingProviderFromRemoteUrl(input.remoteUrl); + const sourceControlProvider = detectSourceControlProviderFromGitRemoteUrl(input.remoteUrl); const repositoryPath = canonicalKey.split("/").slice(1).join("/"); const repositoryPathSegments = repositoryPath.split("/").filter((segment) => segment.length > 0); const [owner] = repositoryPathSegments; @@ -60,7 +63,7 @@ function buildRepositoryIdentity(input: { }, rootPath: input.rootPath, ...(repositoryPath ? { displayName: repositoryPath } : {}), - ...(hostingProvider ? { provider: hostingProvider.kind } : {}), + ...(sourceControlProvider ? { provider: sourceControlProvider.kind } : {}), ...(owner ? { owner } : {}), ...(repositoryName ? { name: repositoryName } : {}), }; diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index feddd3b86b2c476a1e2246f7ab337075da7a4e31..311f495865166f4b71d1529e733a595a5d0d830c 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -16,7 +16,7 @@ import { ClaudeSettings, ProviderDriverKind, type ServerProvider } from "@t3tool import { Cache, Duration, Effect, FileSystem, Path, Schema, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { makeClaudeTextGeneration } from "../../git/Layers/ClaudeTextGeneration.ts"; +import { makeClaudeTextGeneration } from "../../textGeneration/ClaudeTextGeneration.ts"; import { ServerConfig } from "../../config.ts"; import { ProviderDriverError } from "../Errors.ts"; import { makeClaudeAdapter } from "../Layers/ClaudeAdapter.ts"; diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 13598ab5f61e8c8674a380f9b791f39c5512db3c..26fffd5e213ac2e7531552e893c21e0bd5bb70f9 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -25,7 +25,7 @@ import { CodexSettings, ProviderDriverKind, type ServerProvider } from "@t3tools import { Duration, Effect, FileSystem, Path, Schema, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { makeCodexTextGeneration } from "../../git/Layers/CodexTextGeneration.ts"; +import { makeCodexTextGeneration } from "../../textGeneration/CodexTextGeneration.ts"; import { ServerConfig } from "../../config.ts"; import { ProviderDriverError } from "../Errors.ts"; import { makeCodexAdapter } from "../Layers/CodexAdapter.ts"; diff --git a/apps/server/src/provider/Drivers/CursorDriver.ts b/apps/server/src/provider/Drivers/CursorDriver.ts index b7eda591bc87adabc511e6e97100354fed337aee..cd058800f29a650a6b2c6ea43da6bdf1b271956a 100644 --- a/apps/server/src/provider/Drivers/CursorDriver.ts +++ b/apps/server/src/provider/Drivers/CursorDriver.ts @@ -17,7 +17,7 @@ import { Duration, Effect, FileSystem, Path, Schema, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import { ServerConfig } from "../../config.ts"; -import { makeCursorTextGeneration } from "../../git/Layers/CursorTextGeneration.ts"; +import { makeCursorTextGeneration } from "../../textGeneration/CursorTextGeneration.ts"; import { ProviderDriverError } from "../Errors.ts"; import { makeCursorAdapter } from "../Layers/CursorAdapter.ts"; import { diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.ts index 29f74ead023eee501686db2b24fdd04b6ad7acc3..27f98a9830d3cfcfe9e90f6297c0af6b3b7ba59a 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.ts @@ -16,7 +16,7 @@ import { OpenCodeSettings, ProviderDriverKind, type ServerProvider } from "@t3to import { Duration, Effect, FileSystem, Path, Schema, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { makeOpenCodeTextGeneration } from "../../git/Layers/OpenCodeTextGeneration.ts"; +import { makeOpenCodeTextGeneration } from "../../textGeneration/OpenCodeTextGeneration.ts"; import { ServerConfig } from "../../config.ts"; import { ProviderDriverError } from "../Errors.ts"; import { makeOpenCodeAdapter } from "../Layers/OpenCodeAdapter.ts"; diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts index 05735f3a50dfa87ea897c10c028193387268419f..eeba158ab429d2562e0539fd67bbd4ef712596a4 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts @@ -14,7 +14,7 @@ import type { OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts"; import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; import type { ProviderInstance } from "../ProviderDriver.ts"; -import type { TextGenerationShape } from "../../git/Services/TextGeneration.ts"; +import type { TextGenerationShape } from "../../textGeneration/TextGeneration.ts"; import { ProviderAdapterRegistryLive } from "./ProviderAdapterRegistry.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; diff --git a/apps/server/src/provider/ProviderDriver.ts b/apps/server/src/provider/ProviderDriver.ts index bedfd61194e7ea027018ec2889aa9b7eb8286e2e..e7d3e40cd8c5aa2fd287eb51a3138c1a3c4cdcf3 100644 --- a/apps/server/src/provider/ProviderDriver.ts +++ b/apps/server/src/provider/ProviderDriver.ts @@ -28,7 +28,7 @@ import type { } from "@t3tools/contracts"; import type { Effect, Schema, Scope } from "effect"; -import type { TextGenerationShape } from "../git/Services/TextGeneration.ts"; +import type { TextGenerationShape } from "../textGeneration/TextGeneration.ts"; import type { ProviderAdapterError, ProviderDriverError } from "./Errors.ts"; import type { ProviderAdapterShape } from "./Services/ProviderAdapter.ts"; import type { ServerProviderShape } from "./Services/ServerProvider.ts"; diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index e699ad8339cc8618cb09e9e182ab26c1f694da0d..a1064752e295a2c226667ac41f5f85cacf770e4d 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -29,6 +29,7 @@ import { assert, it } from "@effect/vitest"; import { assertFailure, assertInclude, assertTrue } from "@effect/vitest/utils"; import { Deferred, + DateTime, Duration, Effect, FileSystem, @@ -38,6 +39,7 @@ import { Path, Stream, } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; import { FetchHttpClient, HttpBody, @@ -50,6 +52,8 @@ import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; import * as Socket from "effect/unstable/socket/Socket"; import { vi } from "vitest"; +const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); + import type { ServerConfigShape } from "./config.ts"; import { deriveServerPaths, ServerConfig } from "./config.ts"; import { makeRoutesLayer } from "./server.ts"; @@ -58,13 +62,7 @@ import { CheckpointDiffQuery, type CheckpointDiffQueryShape, } from "./checkpointing/Services/CheckpointDiffQuery.ts"; -import { GitCore, type GitCoreShape } from "./git/Services/GitCore.ts"; -import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts"; -import { GitStatusBroadcasterLive } from "./git/Layers/GitStatusBroadcaster.ts"; -import { - GitStatusBroadcaster, - type GitStatusBroadcasterShape, -} from "./git/Services/GitStatusBroadcaster.ts"; +import { GitManager, type GitManagerShape } from "./git/GitManager.ts"; import { Keybindings, type KeybindingsShape } from "./keybindings.ts"; import { Open, type OpenShape } from "./open.ts"; import { @@ -105,6 +103,20 @@ import { import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries.ts"; import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts"; import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; +import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; +import type { VcsDriverShape } from "./vcs/VcsDriver.ts"; +import { + VcsStatusBroadcaster, + type VcsStatusBroadcasterShape, + layer as VcsStatusBroadcasterLayer, +} from "./vcs/VcsStatusBroadcaster.ts"; +import { + VcsDriverRegistry, + type VcsDriverRegistryShape, + type VcsDriverHandle, +} from "./vcs/VcsDriverRegistry.ts"; +import { layer as VcsProvisioningServiceLayer } from "./vcs/VcsProvisioningService.ts"; +import { layer as GitWorkflowServiceLayer } from "./git/GitWorkflowService.ts"; import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts"; import { ServerAuthLive } from "./auth/Layers/ServerAuth.ts"; @@ -195,16 +207,6 @@ const makeDefaultOrchestrationThreadShell = ( }; }; -const workspaceAndProjectServicesLayer = Layer.mergeAll( - WorkspacePathsLive, - WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive)), - WorkspaceFileSystemLive.pipe( - Layer.provide(WorkspacePathsLive), - Layer.provide(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))), - ), - ProjectFaviconResolverLive, -); - const browserOtlpTracingLayer = Layer.mergeAll( FetchHttpClient.layer, OtlpSerialization.layerJson, @@ -324,9 +326,11 @@ const buildAppUnderTest = (options?: { providerRegistry?: Partial; serverSettings?: Partial; open?: Partial; - gitCore?: Partial; + vcsDriver?: Partial; + vcsDriverRegistry?: Partial; + gitVcsDriver?: Partial; gitManager?: Partial; - gitStatusBroadcaster?: Partial; + vcsStatusBroadcaster?: Partial; projectSetupScriptRunner?: Partial; terminalManager?: Partial; orchestrationEngine?: Partial; @@ -372,22 +376,110 @@ const buildAppUnderTest = (options?: { ...options?.config, }; const layerConfig = Layer.succeed(ServerConfig, config); - const gitCoreLayer = Layer.mock(GitCore)({ + const defaultVcsDriver: VcsDriverShape = { + capabilities: { + kind: "git", + supportsWorktrees: true, + supportsBookmarks: false, + supportsAtomicSnapshot: false, + supportsPushDefaultRemote: true, + ignoreClassifier: "native", + }, + execute: () => + Effect.succeed({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: "", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }), + detectRepository: () => Effect.succeed(null), isInsideWorkTree: () => Effect.succeed(false), listWorkspaceFiles: () => Effect.succeed({ paths: [], truncated: false, + freshness: { + source: "live-local", + observedAt: TEST_EPOCH, + expiresAt: Option.none(), + }, + }), + listRemotes: () => + Effect.succeed({ + remotes: [], + freshness: { + source: "live-local", + observedAt: TEST_EPOCH, + expiresAt: Option.none(), + }, }), filterIgnoredPaths: (_cwd, relativePaths) => Effect.succeed(relativePaths), - ...options?.layers?.gitCore, + initRepository: () => Effect.void, + ...options?.layers?.vcsDriver, + }; + const vcsDriverRegistryLayer = Layer.mock(VcsDriverRegistry)({ + get: () => Effect.succeed(defaultVcsDriver), + detect: (input) => + defaultVcsDriver.detectRepository(input.cwd).pipe( + Effect.flatMap((repository) => + repository + ? Effect.succeed(repository) + : defaultVcsDriver.isInsideWorkTree(input.cwd).pipe( + Effect.map((isInsideWorkTree) => + isInsideWorkTree + ? { + kind: "git" as const, + rootPath: input.cwd, + metadataPath: null, + freshness: { + source: "live-local" as const, + observedAt: TEST_EPOCH, + expiresAt: Option.none(), + }, + } + : null, + ), + ), + ), + Effect.map((repository) => + repository + ? ({ + kind: repository.kind, + repository, + driver: defaultVcsDriver, + } satisfies VcsDriverHandle) + : null, + ), + ), + resolve: (input) => + Effect.succeed({ + kind: + input.requestedKind === "auto" || !input.requestedKind ? "git" : input.requestedKind, + repository: { + kind: + input.requestedKind === "auto" || !input.requestedKind ? "git" : input.requestedKind, + rootPath: input.cwd, + metadataPath: null, + freshness: { + source: "live-local", + observedAt: TEST_EPOCH, + expiresAt: Option.none(), + }, + }, + driver: defaultVcsDriver, + }), + ...options?.layers?.vcsDriverRegistry, + }); + const gitVcsDriverLayer = Layer.mock(GitVcsDriver.GitVcsDriver)({ + ...options?.layers?.gitVcsDriver, }); const gitManagerLayer = Layer.mock(GitManager)({ ...options?.layers?.gitManager, }); const workspaceEntriesLayer = WorkspaceEntriesLive.pipe( Layer.provide(WorkspacePathsLive), - Layer.provideMerge(gitCoreLayer), + Layer.provideMerge(vcsDriverRegistryLayer), ); const workspaceAndProjectServicesLayer = Layer.mergeAll( WorkspacePathsLive, @@ -398,11 +490,19 @@ const buildAppUnderTest = (options?: { ), ProjectFaviconResolverLive, ); - const gitStatusBroadcasterLayer = options?.layers?.gitStatusBroadcaster - ? Layer.mock(GitStatusBroadcaster)({ - ...options.layers.gitStatusBroadcaster, + const gitWorkflowLayer = GitWorkflowServiceLayer.pipe( + Layer.provideMerge(vcsDriverRegistryLayer), + Layer.provideMerge(gitVcsDriverLayer), + Layer.provideMerge(gitManagerLayer), + ); + const vcsProvisioningLayer = VcsProvisioningServiceLayer.pipe( + Layer.provide(vcsDriverRegistryLayer), + ); + const vcsStatusBroadcasterLayer = options?.layers?.vcsStatusBroadcaster + ? Layer.mock(VcsStatusBroadcaster)({ + ...options.layers.vcsStatusBroadcaster, }) - : GitStatusBroadcasterLive.pipe(Layer.provide(gitManagerLayer)); + : VcsStatusBroadcasterLayer.pipe(Layer.provide(gitWorkflowLayer)); const servedRoutesLayer = HttpRouter.serve(makeRoutesLayer, { disableListenLog: true, @@ -441,9 +541,11 @@ const buildAppUnderTest = (options?: { ...options?.layers?.open, }), ), - Layer.provide(gitCoreLayer), Layer.provide(gitManagerLayer), - Layer.provideMerge(gitStatusBroadcasterLayer), + Layer.provide(gitVcsDriverLayer), + Layer.provide(gitWorkflowLayer), + Layer.provide(vcsProvisioningLayer), + Layer.provideMerge(vcsStatusBroadcasterLayer), Layer.provide( Layer.mock(ProjectSetupScriptRunner)({ runForThread: () => Effect.succeed({ status: "no-script" as const }), @@ -2068,12 +2170,17 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest({ layers: { - gitCore: { + vcsDriver: { isInsideWorkTree: () => Effect.succeed(true), listWorkspaceFiles: () => Effect.succeed({ paths: ["src/tracked.ts"], truncated: false, + freshness: { + source: "live-local", + observedAt: TEST_EPOCH, + expiresAt: Option.none(), + }, }), filterIgnoredPaths: (_cwd, relativePaths) => Effect.succeed( @@ -2273,9 +2380,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { localStatus: () => Effect.succeed({ isRepo: true, - hasOriginRemote: true, - isDefaultBranch: true, - branch: "main", + hasPrimaryRemote: true, + isDefaultRef: true, + refName: "main", hasWorkingTreeChanges: false, workingTree: { files: [], insertions: 0, deletions: 0 }, }), @@ -2289,9 +2396,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { status: () => Effect.succeed({ isRepo: true, - hasOriginRemote: true, - isDefaultBranch: true, - branch: "main", + hasPrimaryRemote: true, + isDefaultRef: true, + refName: "main", hasWorkingTreeChanges: false, workingTree: { files: [], insertions: 0, deletions: 0 }, hasUpstream: true, @@ -2372,16 +2479,16 @@ it.layer(NodeServices.layer)("server router seam", (it) => { worktreePath: null, }), }, - gitCore: { + gitVcsDriver: { pullCurrentBranch: () => Effect.succeed({ status: "pulled", - branch: "main", - upstreamBranch: "origin/main", + refName: "main", + upstreamRef: "origin/main", }), - listBranches: () => + listRefs: () => Effect.succeed({ - branches: [ + refs: [ { name: "main", current: true, @@ -2390,18 +2497,20 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }, ], isRepo: true, - hasOriginRemote: true, + hasPrimaryRemote: true, nextCursor: null, totalCount: 1, }), createWorktree: () => Effect.succeed({ - worktree: { path: "/tmp/wt", branch: "feature/demo" }, + worktree: { path: "/tmp/wt", refName: "feature/demo" }, }), removeWorktree: () => Effect.void, - createBranch: (input) => Effect.succeed({ branch: input.branch }), - checkoutBranch: (input) => Effect.succeed({ branch: input.branch }), - initRepo: () => Effect.void, + createRef: (input) => Effect.succeed({ refName: input.refName }), + switchRef: (input) => Effect.succeed({ refName: input.refName }), + }, + vcsDriver: { + isInsideWorkTree: () => Effect.succeed(true), }, }, }); @@ -2409,13 +2518,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const wsUrl = yield* getWsServerUrl("/ws"); const pull = yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => client[WS_METHODS.gitPull]({ cwd: "/tmp/repo" })), + withWsRpcClient(wsUrl, (client) => client[WS_METHODS.vcsPull]({ cwd: "/tmp/repo" })), ); assert.equal(pull.status, "pulled"); const refreshedStatus = yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => - client[WS_METHODS.gitRefreshStatus]({ cwd: "/tmp/repo" }), + client[WS_METHODS.vcsRefreshStatus]({ cwd: "/tmp/repo" }), ), ); assert.equal(refreshedStatus.isRepo, true); @@ -2459,27 +2568,25 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ); assert.equal(prepared.branch, "feature/demo"); - const branches = yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => - client[WS_METHODS.gitListBranches]({ cwd: "/tmp/repo" }), - ), + const refs = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => client[WS_METHODS.vcsListRefs]({ cwd: "/tmp/repo" })), ); - assert.equal(branches.branches[0]?.name, "main"); + assert.equal(refs.refs[0]?.name, "main"); const worktree = yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => - client[WS_METHODS.gitCreateWorktree]({ + client[WS_METHODS.vcsCreateWorktree]({ cwd: "/tmp/repo", - branch: "main", + refName: "main", path: null, }), ), ); - assert.equal(worktree.worktree.branch, "feature/demo"); + assert.equal(worktree.worktree.refName, "feature/demo"); yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => - client[WS_METHODS.gitRemoveWorktree]({ + client[WS_METHODS.vcsRemoveWorktree]({ cwd: "/tmp/repo", path: "/tmp/wt", }), @@ -2488,25 +2595,25 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => - client[WS_METHODS.gitCreateBranch]({ + client[WS_METHODS.vcsCreateRef]({ cwd: "/tmp/repo", - branch: "feature/new", + refName: "feature/new", }), ), ); yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => - client[WS_METHODS.gitCheckout]({ + client[WS_METHODS.vcsSwitchRef]({ cwd: "/tmp/repo", - branch: "main", + refName: "main", }), ), ); yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => - client[WS_METHODS.gitInit]({ + client[WS_METHODS.vcsInit]({ cwd: "/tmp/repo", }), ), @@ -2526,7 +2633,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { let statusCalls = 0; yield* buildAppUnderTest({ layers: { - gitCore: { + gitVcsDriver: { pullCurrentBranch: () => Effect.fail(gitError), }, gitManager: { @@ -2545,9 +2652,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { localStatus: () => Effect.succeed({ isRepo: true, - hasOriginRemote: true, - isDefaultBranch: true, - branch: "main", + hasPrimaryRemote: true, + isDefaultRef: true, + refName: "main", hasWorkingTreeChanges: true, workingTree: { files: [], insertions: 0, deletions: 0 }, }), @@ -2566,9 +2673,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { statusCalls += 1; return { isRepo: true, - hasOriginRemote: true, - isDefaultBranch: true, - branch: "main", + hasPrimaryRemote: true, + isDefaultRef: true, + refName: "main", hasWorkingTreeChanges: true, workingTree: { files: [], insertions: 0, deletions: 0 }, hasUpstream: true, @@ -2583,7 +2690,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const wsUrl = yield* getWsServerUrl("/ws"); const result = yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => client[WS_METHODS.gitPull]({ cwd: "/tmp/repo" })).pipe( + withWsRpcClient(wsUrl, (client) => client[WS_METHODS.vcsPull]({ cwd: "/tmp/repo" })).pipe( Effect.result, ), ); @@ -2622,9 +2729,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { localStatus: () => Effect.succeed({ isRepo: true, - hasOriginRemote: true, - isDefaultBranch: false, - branch: "feature/demo", + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "feature/demo", hasWorkingTreeChanges: true, workingTree: { files: [], insertions: 0, deletions: 0 }, }), @@ -2643,9 +2750,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { statusCalls += 1; return { isRepo: true, - hasOriginRemote: true, - isDefaultBranch: false, - branch: "feature/demo", + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "feature/demo", hasWorkingTreeChanges: true, workingTree: { files: [], insertions: 0, deletions: 0 }, hasUpstream: true, @@ -2680,12 +2787,12 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.gen(function* () { yield* buildAppUnderTest({ layers: { - gitCore: { + gitVcsDriver: { pullCurrentBranch: () => Effect.succeed({ status: "pulled" as const, - branch: "main", - upstreamBranch: "origin/main", + refName: "main", + upstreamRef: "origin/main", }), }, gitManager: { @@ -2694,9 +2801,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { localStatus: () => Effect.succeed({ isRepo: true, - hasOriginRemote: true, - isDefaultBranch: true, - branch: "main", + hasPrimaryRemote: true, + isDefaultRef: true, + refName: "main", hasWorkingTreeChanges: false, workingTree: { files: [], insertions: 0, deletions: 0 }, }), @@ -2716,7 +2823,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const wsUrl = yield* getWsServerUrl("/ws"); const startedAt = Date.now(); const result = yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => client[WS_METHODS.gitPull]({ cwd: "/tmp/repo" })), + withWsRpcClient(wsUrl, (client) => client[WS_METHODS.vcsPull]({ cwd: "/tmp/repo" })), ); const elapsedMs = Date.now() - startedAt; @@ -2731,15 +2838,18 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.gen(function* () { yield* buildAppUnderTest({ layers: { + vcsDriver: { + isInsideWorkTree: () => Effect.succeed(true), + }, gitManager: { invalidateLocalStatus: () => Effect.void, invalidateRemoteStatus: () => Effect.void, localStatus: () => Effect.succeed({ isRepo: true, - hasOriginRemote: true, - isDefaultBranch: false, - branch: "feature/demo", + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "feature/demo", hasWorkingTreeChanges: false, workingTree: { files: [], insertions: 0, deletions: 0 }, }), @@ -2804,6 +2914,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest({ layers: { + vcsDriver: { + isInsideWorkTree: () => Effect.succeed(true), + }, gitManager: { invalidateLocalStatus: () => Effect.void, invalidateRemoteStatus: () => Effect.void, @@ -2813,9 +2926,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { Effect.andThen( Effect.succeed({ isRepo: true, - hasOriginRemote: true, - isDefaultBranch: false, - branch: "feature/demo", + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "feature/demo", hasWorkingTreeChanges: false, workingTree: { files: [], insertions: 0, deletions: 0 }, }), @@ -3479,9 +3592,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const refreshStatus = vi.fn((_: string) => Effect.succeed({ isRepo: true, - hasOriginRemote: true, - isDefaultBranch: false, - branch: "t3code/bootstrap-branch", + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "t3code/bootstrap-refName", hasWorkingTreeChanges: false, workingTree: { files: [], @@ -3494,13 +3607,14 @@ it.layer(NodeServices.layer)("server router seam", (it) => { pr: null, }), ); - const createWorktree = vi.fn((_: Parameters[0]) => - Effect.succeed({ - worktree: { - branch: "t3code/bootstrap-branch", - path: "/tmp/bootstrap-worktree", - }, - }), + const createWorktree = vi.fn( + (_: Parameters[0]) => + Effect.succeed({ + worktree: { + refName: "t3code/bootstrap-refName", + path: "/tmp/bootstrap-worktree", + }, + }), ); const runForThread = vi.fn( (_: Parameters[0]) => @@ -3515,10 +3629,10 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest({ layers: { - gitCore: { + gitVcsDriver: { createWorktree, }, - gitStatusBroadcaster: { + vcsStatusBroadcaster: { refreshStatus, }, orchestrationEngine: { @@ -3566,7 +3680,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { prepareWorktree: { projectCwd: "/tmp/project", baseBranch: "main", - branch: "t3code/bootstrap-branch", + branch: "t3code/bootstrap-refName", }, runSetupScript: true, }, @@ -3588,8 +3702,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ); assert.deepEqual(createWorktree.mock.calls[0]?.[0], { cwd: "/tmp/project", - branch: "main", - newBranch: "t3code/bootstrap-branch", + refName: "main", + newRefName: "t3code/bootstrap-refName", path: null, }); assert.deepEqual(runForThread.mock.calls[0]?.[0], { @@ -3619,13 +3733,14 @@ it.layer(NodeServices.layer)("server router seam", (it) => { it.effect("records setup-script failures without aborting bootstrap turn start", () => Effect.gen(function* () { const dispatchedCommands: Array = []; - const createWorktree = vi.fn((_: Parameters[0]) => - Effect.succeed({ - worktree: { - branch: "t3code/bootstrap-branch", - path: "/tmp/bootstrap-worktree", - }, - }), + const createWorktree = vi.fn( + (_: Parameters[0]) => + Effect.succeed({ + worktree: { + refName: "t3code/bootstrap-refName", + path: "/tmp/bootstrap-worktree", + }, + }), ); const runForThread = vi.fn( (_: Parameters[0]) => @@ -3634,7 +3749,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest({ layers: { - gitCore: { + gitVcsDriver: { createWorktree, }, orchestrationEngine: { @@ -3682,7 +3797,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { prepareWorktree: { projectCwd: "/tmp/project", baseBranch: "main", - branch: "t3code/bootstrap-branch", + branch: "t3code/bootstrap-refName", }, runSetupScript: true, }, @@ -3712,13 +3827,14 @@ it.layer(NodeServices.layer)("server router seam", (it) => { it.effect("does not misattribute setup activity dispatch failures as setup launch failures", () => Effect.gen(function* () { const dispatchedCommands: Array = []; - const createWorktree = vi.fn((_: Parameters[0]) => - Effect.succeed({ - worktree: { - branch: "t3code/bootstrap-branch", - path: "/tmp/bootstrap-worktree", - }, - }), + const createWorktree = vi.fn( + (_: Parameters[0]) => + Effect.succeed({ + worktree: { + refName: "t3code/bootstrap-refName", + path: "/tmp/bootstrap-worktree", + }, + }), ); const runForThread = vi.fn( (_: Parameters[0]) => @@ -3734,7 +3850,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest({ layers: { - gitCore: { + gitVcsDriver: { createWorktree, }, orchestrationEngine: { @@ -3798,7 +3914,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { prepareWorktree: { projectCwd: "/tmp/project", baseBranch: "main", - branch: "t3code/bootstrap-branch", + branch: "t3code/bootstrap-refName", }, runSetupScript: true, }, @@ -3830,13 +3946,14 @@ it.layer(NodeServices.layer)("server router seam", (it) => { it.effect("cleans up created bootstrap threads when worktree creation defects", () => Effect.gen(function* () { const dispatchedCommands: Array = []; - const createWorktree = vi.fn((_: Parameters[0]) => - Effect.die(new Error("worktree exploded")), + const createWorktree = vi.fn( + (_: Parameters[0]) => + Effect.die(new Error("worktree exploded")), ); yield* buildAppUnderTest({ layers: { - gitCore: { + gitVcsDriver: { createWorktree, }, orchestrationEngine: { @@ -3881,7 +3998,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { prepareWorktree: { projectCwd: "/tmp/project", baseBranch: "main", - branch: "t3code/bootstrap-branch", + branch: "t3code/bootstrap-refName", }, runSetupScript: false, }, diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 85f2d84ad28c9459215ef4d57d368e31a9d1daa6..4b31543e5948630f5bbb843be67fc33571eacbcf 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -25,13 +25,11 @@ import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReap import { OpenCodeRuntimeLive } from "./provider/opencodeRuntime.ts"; import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery.ts"; import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore.ts"; -import { GitCoreLive } from "./git/Layers/GitCore.ts"; -import { GitHubCliLive } from "./git/Layers/GitHubCli.ts"; -import { GitStatusBroadcasterLive } from "./git/Layers/GitStatusBroadcaster.ts"; -import { TextGenerationLive } from "./git/Layers/TextGenerationLive.ts"; +import * as GitHubCli from "./sourceControl/GitHubCli.ts"; +import * as TextGeneration from "./textGeneration/TextGeneration.ts"; import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/ProviderInstanceRegistryHydration.ts"; import { TerminalManagerLive } from "./terminal/Layers/Manager.ts"; -import { GitManagerLive } from "./git/Layers/GitManager.ts"; +import * as GitManager from "./git/GitManager.ts"; import { KeybindingsLive } from "./keybindings.ts"; import { ServerRuntimeStartup, ServerRuntimeStartupLive } from "./serverRuntimeStartup.ts"; import { OrchestrationReactorLive } from "./orchestration/Layers/OrchestrationReactor.ts"; @@ -47,6 +45,14 @@ import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdent import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries.ts"; import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts"; import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; +import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; +import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts"; +import * as VcsProjectConfig from "./vcs/VcsProjectConfig.ts"; +import * as VcsProcess from "./vcs/VcsProcess.ts"; +import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; +import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; +import * as GitWorkflowService from "./git/GitWorkflowService.ts"; +import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; import { ProjectSetupScriptRunnerLive } from "./project/Layers/ProjectSetupScriptRunner.ts"; import { ObservabilityLive } from "./observability/Layers/Observability.ts"; import { ServerEnvironmentLive } from "./environment/Layers/ServerEnvironment.ts"; @@ -133,11 +139,6 @@ const ReactorLayerLive = Layer.empty.pipe( Layer.provideMerge(RuntimeReceiptBusLive), ); -const CheckpointingLayerLive = Layer.empty.pipe( - Layer.provideMerge(CheckpointDiffQueryLive), - Layer.provideMerge(CheckpointStoreLive), -); - const ProviderSessionDirectoryLayerLive = ProviderSessionDirectoryLive.pipe( Layer.provide(ProviderSessionRuntimeRepositoryLive), ); @@ -155,24 +156,50 @@ const ProviderLayerLive = ProviderServiceLive.pipe( const PersistenceLayerLive = Layer.empty.pipe(Layer.provideMerge(SqlitePersistenceLayerLive)); -const GitManagerLayerLive = GitManagerLive.pipe( +const VcsDriverRegistryLayerLive = VcsDriverRegistry.layer.pipe( + Layer.provide(VcsProjectConfig.layer), +); + +const GitManagerLayerLive = GitManager.layer.pipe( Layer.provideMerge(ProjectSetupScriptRunnerLive), - Layer.provideMerge(GitCoreLive), - Layer.provideMerge(GitHubCliLive), - Layer.provideMerge(TextGenerationLive), + Layer.provideMerge(GitVcsDriver.layer), + Layer.provideMerge( + SourceControlProviderRegistry.layer.pipe( + Layer.provide(GitHubCli.layer), + Layer.provideMerge(VcsDriverRegistryLayerLive), + ), + ), + Layer.provideMerge(TextGeneration.layer), ); const GitLayerLive = Layer.empty.pipe( Layer.provideMerge(GitManagerLayerLive), - Layer.provideMerge(GitStatusBroadcasterLive.pipe(Layer.provide(GitManagerLayerLive))), - Layer.provideMerge(GitCoreLive), + Layer.provideMerge(GitVcsDriver.layer), +); + +const GitWorkflowLayerLive = GitWorkflowService.layer.pipe( + Layer.provideMerge(VcsDriverRegistryLayerLive), + Layer.provideMerge(GitLayerLive), +); + +const VcsLayerLive = Layer.empty.pipe( + Layer.provideMerge(VcsProjectConfig.layer), + Layer.provideMerge(VcsDriverRegistryLayerLive), + Layer.provideMerge(VcsProvisioningService.layer.pipe(Layer.provide(VcsDriverRegistryLayerLive))), + Layer.provideMerge(GitWorkflowLayerLive), + Layer.provideMerge(VcsStatusBroadcaster.layer.pipe(Layer.provide(GitWorkflowLayerLive))), +); + +const CheckpointingLayerLive = Layer.empty.pipe( + Layer.provideMerge(CheckpointDiffQueryLive), + Layer.provideMerge(CheckpointStoreLive.pipe(Layer.provide(VcsDriverRegistryLayerLive))), ); const TerminalLayerLive = TerminalManagerLive.pipe(Layer.provide(PtyAdapterLive)); const WorkspaceEntriesLayerLive = WorkspaceEntriesLive.pipe( Layer.provide(WorkspacePathsLive), - Layer.provideMerge(GitCoreLive), + Layer.provideMerge(VcsDriverRegistryLayerLive), ); const WorkspaceFileSystemLayerLive = WorkspaceFileSystemLive.pipe( @@ -196,10 +223,11 @@ const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe( Layer.provideMerge(OrchestrationLayerLive), ); -const RuntimeDependenciesLive = ReactorLayerLive.pipe( +const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( // Core Services Layer.provideMerge(CheckpointingLayerLive), Layer.provideMerge(GitLayerLive), + Layer.provideMerge(VcsLayerLive), Layer.provideMerge(ProviderRuntimeLayerLive), Layer.provideMerge(TerminalLayerLive), Layer.provideMerge(PersistenceLayerLive), @@ -229,7 +257,9 @@ const RuntimeDependenciesLive = ReactorLayerLive.pipe( Layer.provideMerge(RepositoryIdentityResolverLive), Layer.provideMerge(ServerEnvironmentLive), Layer.provideMerge(AuthLayerLive), +); +const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe( // Misc. Layer.provideMerge(AnalyticsServiceLayerLive), Layer.provideMerge(OpenLive), @@ -310,6 +340,7 @@ export const makeServerLayer = Layer.unwrap( Layer.provideMerge(HttpServerLive), Layer.provide(ObservabilityLive), Layer.provideMerge(FetchHttpClient.layer), + Layer.provideMerge(VcsProcess.layer), Layer.provideMerge(PlatformServicesLive), ); }), diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..c4675fa2f16411c56a3e84e9f36fd52bd7962bc5 --- /dev/null +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -0,0 +1,240 @@ +import { assert, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { VcsProcessExitError, type VcsError } from "@t3tools/contracts"; +import { afterEach, describe, expect, vi } from "vitest"; + +import { VcsProcess, type VcsProcessInput, type VcsProcessOutput } from "../vcs/VcsProcess.ts"; +import * as GitHubCli from "./GitHubCli.ts"; + +const processOutput = (stdout: string): VcsProcessOutput => ({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, +}); + +const mockRun = vi.fn<(input: VcsProcessInput) => Effect.Effect>(); + +const layer = GitHubCli.layer.pipe( + Layer.provide( + Layer.mock(VcsProcess)({ + run: mockRun, + }), + ), +); + +afterEach(() => { + mockRun.mockReset(); +}); + +describe("GitHubCli.layer", () => { + it.effect("parses pull request view output", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + JSON.stringify({ + number: 42, + title: "Add PR thread creation", + url: "https://github.com/pingdotgg/codething-mvp/pull/42", + baseRefName: "main", + headRefName: "feature/pr-threads", + state: "OPEN", + mergedAt: null, + isCrossRepository: true, + headRepository: { + nameWithOwner: "octocat/codething-mvp", + }, + headRepositoryOwner: { + login: "octocat", + }, + }), + ), + ), + ); + + const gh = yield* GitHubCli.GitHubCli; + const result = yield* gh.getPullRequest({ + cwd: "/repo", + reference: "#42", + }); + + assert.deepStrictEqual(result, { + number: 42, + title: "Add PR thread creation", + url: "https://github.com/pingdotgg/codething-mvp/pull/42", + baseRefName: "main", + headRefName: "feature/pr-threads", + state: "open", + isCrossRepository: true, + headRepositoryNameWithOwner: "octocat/codething-mvp", + headRepositoryOwnerLogin: "octocat", + }); + expect(mockRun).toHaveBeenCalledWith({ + operation: "GitHubCli.execute", + command: "gh", + args: [ + "pr", + "view", + "#42", + "--json", + "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", + ], + cwd: "/repo", + timeoutMs: 30_000, + }); + }).pipe(Effect.provide(layer)), + ); + + it.effect("trims pull request fields decoded from gh json", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + JSON.stringify({ + number: 42, + title: " Add PR thread creation \n", + url: " https://github.com/pingdotgg/codething-mvp/pull/42 ", + baseRefName: " main ", + headRefName: "\tfeature/pr-threads\t", + state: "OPEN", + mergedAt: null, + isCrossRepository: true, + headRepository: { + nameWithOwner: " octocat/codething-mvp ", + }, + headRepositoryOwner: { + login: " octocat ", + }, + }), + ), + ), + ); + + const gh = yield* GitHubCli.GitHubCli; + const result = yield* gh.getPullRequest({ + cwd: "/repo", + reference: "#42", + }); + + assert.deepStrictEqual(result, { + number: 42, + title: "Add PR thread creation", + url: "https://github.com/pingdotgg/codething-mvp/pull/42", + baseRefName: "main", + headRefName: "feature/pr-threads", + state: "open", + isCrossRepository: true, + headRepositoryNameWithOwner: "octocat/codething-mvp", + headRepositoryOwnerLogin: "octocat", + }); + }).pipe(Effect.provide(layer)), + ); + + it.effect("skips invalid entries when parsing pr lists", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + JSON.stringify([ + { + number: 0, + title: "invalid", + url: "https://github.com/pingdotgg/codething-mvp/pull/0", + baseRefName: "main", + headRefName: "feature/invalid", + }, + { + number: 43, + title: " Valid PR ", + url: " https://github.com/pingdotgg/codething-mvp/pull/43 ", + baseRefName: " main ", + headRefName: " feature/pr-list ", + headRepository: { + nameWithOwner: " ", + }, + headRepositoryOwner: { + login: " ", + }, + }, + ]), + ), + ), + ); + + const gh = yield* GitHubCli.GitHubCli; + const result = yield* gh.listOpenPullRequests({ + cwd: "/repo", + headSelector: "feature/pr-list", + }); + + assert.deepStrictEqual(result, [ + { + number: 43, + title: "Valid PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/43", + baseRefName: "main", + headRefName: "feature/pr-list", + state: "open", + }, + ]); + }).pipe(Effect.provide(layer)), + ); + + it.effect("reads repository clone URLs", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + JSON.stringify({ + nameWithOwner: "octocat/codething-mvp", + url: "https://github.com/octocat/codething-mvp", + sshUrl: "git@github.com:octocat/codething-mvp.git", + }), + ), + ), + ); + + const gh = yield* GitHubCli.GitHubCli; + const result = yield* gh.getRepositoryCloneUrls({ + cwd: "/repo", + repository: "octocat/codething-mvp", + }); + + assert.deepStrictEqual(result, { + nameWithOwner: "octocat/codething-mvp", + url: "https://github.com/octocat/codething-mvp", + sshUrl: "git@github.com:octocat/codething-mvp.git", + }); + }).pipe(Effect.provide(layer)), + ); + + it.effect("surfaces a friendly error when the pull request is not found", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.fail( + new VcsProcessExitError({ + operation: "GitHubCli.execute", + command: "gh pr view", + cwd: "/repo", + exitCode: 1, + detail: + "GraphQL: Could not resolve to a PullRequest with the number of 4888. (repository.pullRequest)", + }), + ), + ); + + const gh = yield* GitHubCli.GitHubCli; + const error = yield* gh + .getPullRequest({ + cwd: "/repo", + reference: "4888", + }) + .pipe(Effect.flip); + + assert.equal(error.message.includes("Pull request not found"), true); + }).pipe(Effect.provide(layer)), + ); +}); diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts new file mode 100644 index 0000000000000000000000000000000000000000..bb90aeba0145cb90cc850c8c15260b195219ba54 --- /dev/null +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -0,0 +1,308 @@ +import { Context, Effect, Layer, Result, Schema, SchemaIssue } from "effect"; + +import { GitHubCliError, TrimmedNonEmptyString, type VcsError } from "@t3tools/contracts"; + +import { VcsProcess, type VcsProcessOutput } from "../vcs/VcsProcess.ts"; +import { + decodeGitHubPullRequestJson, + decodeGitHubPullRequestListJson, + formatGitHubJsonDecodeError, +} from "../git/githubPullRequests.ts"; + +const DEFAULT_TIMEOUT_MS = 30_000; + +export interface GitHubPullRequestSummary { + readonly number: number; + readonly title: string; + readonly url: string; + readonly baseRefName: string; + readonly headRefName: string; + readonly state?: "open" | "closed" | "merged"; + readonly isCrossRepository?: boolean; + readonly headRepositoryNameWithOwner?: string | null; + readonly headRepositoryOwnerLogin?: string | null; +} + +export interface GitHubRepositoryCloneUrls { + readonly nameWithOwner: string; + readonly url: string; + readonly sshUrl: string; +} + +export interface GitHubCliShape { + readonly execute: (input: { + readonly cwd: string; + readonly args: ReadonlyArray; + readonly timeoutMs?: number; + }) => Effect.Effect; + + readonly listOpenPullRequests: (input: { + readonly cwd: string; + readonly headSelector: string; + readonly limit?: number; + }) => Effect.Effect, GitHubCliError>; + + readonly getPullRequest: (input: { + readonly cwd: string; + readonly reference: string; + }) => Effect.Effect; + + readonly getRepositoryCloneUrls: (input: { + readonly cwd: string; + readonly repository: string; + }) => Effect.Effect; + + readonly createPullRequest: (input: { + readonly cwd: string; + readonly baseBranch: string; + readonly headSelector: string; + readonly title: string; + readonly bodyFile: string; + }) => Effect.Effect; + + readonly getDefaultBranch: (input: { + readonly cwd: string; + }) => Effect.Effect; + + readonly checkoutPullRequest: (input: { + readonly cwd: string; + readonly reference: string; + readonly force?: boolean; + }) => Effect.Effect; +} + +export class GitHubCli extends Context.Service()( + "t3/source-control/GitHubCli", +) {} + +function errorText(error: VcsError | unknown): string { + if (typeof error === "object" && error !== null) { + const tag = "_tag" in error && typeof error._tag === "string" ? error._tag : ""; + const detail = "detail" in error && typeof error.detail === "string" ? error.detail : ""; + const message = "message" in error && typeof error.message === "string" ? error.message : ""; + return [tag, detail, message].filter(Boolean).join("\n"); + } + + return String(error); +} + +function normalizeGitHubCliError( + operation: "execute" | "stdout", + error: VcsError | unknown, +): GitHubCliError { + const text = errorText(error); + const lower = text.toLowerCase(); + + if (lower.includes("command not found: gh") || lower.includes("enoent")) { + return new GitHubCliError({ + operation, + detail: "GitHub CLI (`gh`) is required but not available on PATH.", + cause: error, + }); + } + + if ( + lower.includes("authentication failed") || + lower.includes("not logged in") || + lower.includes("gh auth login") || + lower.includes("no oauth token") + ) { + return new GitHubCliError({ + operation, + detail: "GitHub CLI is not authenticated. Run `gh auth login` and retry.", + cause: error, + }); + } + + if ( + lower.includes("could not resolve to a pullrequest") || + lower.includes("repository.pullrequest") || + lower.includes("no pull requests found for branch") || + lower.includes("pull request not found") + ) { + return new GitHubCliError({ + operation, + detail: "Pull request not found. Check the PR number or URL and try again.", + cause: error, + }); + } + + return new GitHubCliError({ + operation, + detail: text, + cause: error, + }); +} + +const RawGitHubRepositoryCloneUrlsSchema = Schema.Struct({ + nameWithOwner: TrimmedNonEmptyString, + url: TrimmedNonEmptyString, + sshUrl: TrimmedNonEmptyString, +}); + +function normalizeRepositoryCloneUrls( + raw: Schema.Schema.Type, +): GitHubRepositoryCloneUrls { + return { + nameWithOwner: raw.nameWithOwner, + url: raw.url, + sshUrl: raw.sshUrl, + }; +} + +function decodeGitHubJson( + raw: string, + schema: S, + operation: "listOpenPullRequests" | "getPullRequest" | "getRepositoryCloneUrls", + invalidDetail: string, +): Effect.Effect { + return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( + Effect.mapError( + (error) => + new GitHubCliError({ + operation, + detail: `${invalidDetail}: ${SchemaIssue.makeFormatterDefault()(error.issue)}`, + cause: error, + }), + ), + ); +} + +export const make = Effect.fn("makeGitHubCli")(function* () { + const process = yield* VcsProcess; + + const execute: GitHubCliShape["execute"] = (input) => + process + .run({ + operation: "GitHubCli.execute", + command: "gh", + args: input.args, + cwd: input.cwd, + timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, + }) + .pipe(Effect.mapError((error) => normalizeGitHubCliError("execute", error))); + + return GitHubCli.of({ + execute, + listOpenPullRequests: (input) => + execute({ + cwd: input.cwd, + args: [ + "pr", + "list", + "--head", + input.headSelector, + "--state", + "open", + "--limit", + String(input.limit ?? 1), + "--json", + "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", + ], + }).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((raw) => + raw.length === 0 + ? Effect.succeed([]) + : Effect.sync(() => decodeGitHubPullRequestListJson(raw)).pipe( + Effect.flatMap((decoded) => { + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new GitHubCliError({ + operation: "listOpenPullRequests", + detail: `GitHub CLI returned invalid PR list JSON: ${formatGitHubJsonDecodeError(decoded.failure)}`, + cause: decoded.failure, + }), + ); + } + + return Effect.succeed( + decoded.success.map(({ updatedAt: _updatedAt, ...summary }) => summary), + ); + }), + ), + ), + ), + getPullRequest: (input) => + execute({ + cwd: input.cwd, + args: [ + "pr", + "view", + input.reference, + "--json", + "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", + ], + }).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((raw) => + Effect.sync(() => decodeGitHubPullRequestJson(raw)).pipe( + Effect.flatMap((decoded) => { + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new GitHubCliError({ + operation: "getPullRequest", + detail: `GitHub CLI returned invalid pull request JSON: ${formatGitHubJsonDecodeError(decoded.failure)}`, + cause: decoded.failure, + }), + ); + } + + return Effect.succeed( + (({ updatedAt: _updatedAt, ...summary }) => summary)(decoded.success), + ); + }), + ), + ), + ), + getRepositoryCloneUrls: (input) => + execute({ + cwd: input.cwd, + args: ["repo", "view", input.repository, "--json", "nameWithOwner,url,sshUrl"], + }).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((raw) => + decodeGitHubJson( + raw, + RawGitHubRepositoryCloneUrlsSchema, + "getRepositoryCloneUrls", + "GitHub CLI returned invalid repository JSON.", + ), + ), + Effect.map(normalizeRepositoryCloneUrls), + ), + createPullRequest: (input) => + execute({ + cwd: input.cwd, + args: [ + "pr", + "create", + "--base", + input.baseBranch, + "--head", + input.headSelector, + "--title", + input.title, + "--body-file", + input.bodyFile, + ], + }).pipe(Effect.asVoid), + getDefaultBranch: (input) => + execute({ + cwd: input.cwd, + args: ["repo", "view", "--json", "defaultBranchRef", "--jq", ".defaultBranchRef.name"], + }).pipe( + Effect.map((value) => { + const trimmed = value.stdout.trim(); + return trimmed.length > 0 ? trimmed : null; + }), + ), + checkoutPullRequest: (input) => + execute({ + cwd: input.cwd, + args: ["pr", "checkout", input.reference, ...(input.force ? ["--force"] : [])], + }).pipe(Effect.asVoid), + }); +}); + +export const layer = Layer.effect(GitHubCli, make()); diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..ac3de1d9747acce5a9d193344e3b7abf08452b57 --- /dev/null +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts @@ -0,0 +1,154 @@ +import { assert, it } from "@effect/vitest"; +import { DateTime, Effect, Layer, Option } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { GitHubCli, type GitHubCliShape } from "./GitHubCli.ts"; +import type { VcsProcessOutput } from "../vcs/VcsProcess.ts"; +import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; + +const processResult = (stdout: string): VcsProcessOutput => ({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, +}); + +function makeProvider(github: Partial) { + return GitHubSourceControlProvider.make().pipe(Effect.provide(Layer.mock(GitHubCli)(github))); +} + +it.effect("maps GitHub PR summaries into provider-neutral change requests", () => + Effect.gen(function* () { + const provider = yield* makeProvider({ + getPullRequest: () => + Effect.succeed({ + number: 42, + title: "Add GitHub provider", + url: "https://github.com/pingdotgg/t3code/pull/42", + baseRefName: "main", + headRefName: "feature/source-control", + state: "open", + isCrossRepository: true, + headRepositoryNameWithOwner: "fork/t3code", + headRepositoryOwnerLogin: "fork", + }), + }); + + const changeRequest = yield* provider.getChangeRequest({ + cwd: "/repo", + reference: "42", + }); + + assert.deepStrictEqual(changeRequest, { + provider: "github", + number: 42, + title: "Add GitHub provider", + url: "https://github.com/pingdotgg/t3code/pull/42", + baseRefName: "main", + headRefName: "feature/source-control", + state: "open", + updatedAt: Option.none(), + isCrossRepository: true, + headRepositoryNameWithOwner: "fork/t3code", + headRepositoryOwnerLogin: "fork", + }); + }), +); + +it.effect("uses gh json listing for non-open change request state queries", () => + Effect.gen(function* () { + let executeArgs: ReadonlyArray = []; + const provider = yield* makeProvider({ + execute: (input) => { + executeArgs = input.args; + return Effect.succeed( + processResult( + JSON.stringify([ + { + number: 7, + title: "Merged work", + url: "https://github.com/pingdotgg/t3code/pull/7", + baseRefName: "main", + headRefName: "feature/merged", + state: "merged", + updatedAt: "2026-01-02T00:00:00.000Z", + }, + ]), + ), + ); + }, + }); + + const changeRequests = yield* provider.listChangeRequests({ + cwd: "/repo", + headSelector: "feature/merged", + state: "all", + limit: 10, + }); + + assert.deepStrictEqual(executeArgs, [ + "pr", + "list", + "--head", + "feature/merged", + "--state", + "all", + "--limit", + "10", + "--json", + "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + ]); + assert.strictEqual(changeRequests[0]?.provider, "github"); + assert.strictEqual(changeRequests[0]?.state, "merged"); + assert.deepStrictEqual( + changeRequests[0]?.updatedAt, + Option.some(DateTime.makeUnsafe("2026-01-02T00:00:00.000Z")), + ); + }), +); + +it.effect("treats empty non-open change request listing output as no results", () => + Effect.gen(function* () { + const provider = yield* makeProvider({ + execute: () => Effect.succeed(processResult("")), + }); + + const changeRequests = yield* provider.listChangeRequests({ + cwd: "/repo", + headSelector: "feature/empty", + state: "all", + limit: 10, + }); + + assert.deepStrictEqual(changeRequests, []); + }), +); + +it.effect("creates GitHub PRs through provider-neutral input names", () => + Effect.gen(function* () { + let createInput: Parameters[0] | null = null; + const provider = yield* makeProvider({ + createPullRequest: (input) => { + createInput = input; + return Effect.void; + }, + }); + + yield* provider.createChangeRequest({ + cwd: "/repo", + baseRefName: "main", + headSelector: "owner:feature/provider", + title: "Provider PR", + bodyFile: "/tmp/body.md", + }); + + assert.deepStrictEqual(createInput, { + cwd: "/repo", + baseBranch: "main", + headSelector: "owner:feature/provider", + title: "Provider PR", + bodyFile: "/tmp/body.md", + }); + }), +); diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts new file mode 100644 index 0000000000000000000000000000000000000000..3bdccbf97d16a7f0df6260c3cfca6ae09944be94 --- /dev/null +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -0,0 +1,145 @@ +import { Effect, Layer, Option, Result, Schema } from "effect"; +import { + SourceControlProviderError, + type ChangeRequest, + type ChangeRequestState, + type GitHubCliError, +} from "@t3tools/contracts"; + +import { GitHubCli, type GitHubPullRequestSummary } from "./GitHubCli.ts"; +import { decodeGitHubPullRequestListJson } from "../git/githubPullRequests.ts"; +import { SourceControlProvider, type SourceControlProviderShape } from "./SourceControlProvider.ts"; + +function providerError(operation: string, cause: GitHubCliError): SourceControlProviderError { + return new SourceControlProviderError({ + provider: "github", + operation, + detail: cause.detail, + cause, + }); +} + +function toChangeRequest(summary: GitHubPullRequestSummary): ChangeRequest { + return { + provider: "github", + number: summary.number, + title: summary.title, + url: summary.url, + baseRefName: summary.baseRefName, + headRefName: summary.headRefName, + state: summary.state ?? "open", + updatedAt: Option.none(), + ...(summary.isCrossRepository !== undefined + ? { isCrossRepository: summary.isCrossRepository } + : {}), + ...(summary.headRepositoryNameWithOwner !== undefined + ? { headRepositoryNameWithOwner: summary.headRepositoryNameWithOwner } + : {}), + ...(summary.headRepositoryOwnerLogin !== undefined + ? { headRepositoryOwnerLogin: summary.headRepositoryOwnerLogin } + : {}), + }; +} + +export const make = Effect.fn("makeGitHubSourceControlProvider")(function* () { + const github = yield* GitHubCli; + + const listChangeRequests: SourceControlProviderShape["listChangeRequests"] = (input) => { + if (input.state === "open") { + return github + .listOpenPullRequests({ + cwd: input.cwd, + headSelector: input.headSelector, + ...(input.limit !== undefined ? { limit: input.limit } : {}), + }) + .pipe( + Effect.map((items) => items.map(toChangeRequest)), + Effect.mapError((error) => providerError("listChangeRequests", error)), + ); + } + + const stateArg: ChangeRequestState | "all" = input.state; + return github + .execute({ + cwd: input.cwd, + args: [ + "pr", + "list", + "--head", + input.headSelector, + "--state", + stateArg, + "--limit", + String(input.limit ?? 20), + "--json", + "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + ], + }) + .pipe( + Effect.flatMap((result) => { + const raw = result.stdout.trim(); + if (raw.length === 0) { + return Effect.succeed([]); + } + return Effect.sync(() => decodeGitHubPullRequestListJson(raw)).pipe( + Effect.flatMap((decoded) => + Result.isSuccess(decoded) + ? Effect.succeed( + decoded.success.map((item) => ({ + ...toChangeRequest(item), + updatedAt: item.updatedAt, + })), + ) + : Effect.fail( + new SourceControlProviderError({ + provider: "github", + operation: "listChangeRequests", + detail: "GitHub CLI returned invalid change request JSON.", + cause: decoded.failure, + }), + ), + ), + ); + }), + Effect.mapError((error) => + Schema.is(SourceControlProviderError)(error) + ? error + : providerError("listChangeRequests", error), + ), + ); + }; + + return SourceControlProvider.of({ + kind: "github", + listChangeRequests, + getChangeRequest: (input) => + github.getPullRequest(input).pipe( + Effect.map(toChangeRequest), + Effect.mapError((error) => providerError("getChangeRequest", error)), + ), + createChangeRequest: (input) => + github + .createPullRequest({ + cwd: input.cwd, + baseBranch: input.baseRefName, + headSelector: input.headSelector, + title: input.title, + bodyFile: input.bodyFile, + }) + .pipe(Effect.mapError((error) => providerError("createChangeRequest", error))), + getRepositoryCloneUrls: (input) => + github + .getRepositoryCloneUrls(input) + .pipe(Effect.mapError((error) => providerError("getRepositoryCloneUrls", error))), + getDefaultBranch: (input) => + github + .getDefaultBranch(input) + .pipe(Effect.mapError((error) => providerError("getDefaultBranch", error))), + checkoutChangeRequest: (input) => + github + .checkoutPullRequest(input) + .pipe(Effect.mapError((error) => providerError("checkoutChangeRequest", error))), + }); +}); + +export const layer = Layer.effect(SourceControlProvider, make()); diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..53ab48065937ce9b06d25f777a30a07defadd420 --- /dev/null +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -0,0 +1,214 @@ +import { assert, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { Effect, Layer, Option } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { VcsProcessSpawnError } from "@t3tools/contracts"; + +import { ServerConfig } from "../config.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import { SourceControlDiscovery, layer } from "./SourceControlDiscovery.ts"; + +const processOutput = ( + stdout: string, + options?: { + readonly stderr?: string; + readonly exitCode?: ChildProcessSpawner.ExitCode; + }, +): VcsProcess.VcsProcessOutput => ({ + exitCode: options?.exitCode ?? ChildProcessSpawner.ExitCode(0), + stdout, + stderr: options?.stderr ?? "", + stdoutTruncated: false, + stderrTruncated: false, +}); + +it.effect("reports implemented tools separately from locally available CLIs", () => { + const testLayer = layer.pipe( + Layer.provide( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-discovery-" }), + ), + Layer.provide( + Layer.mock(VcsProcess.VcsProcess)({ + run: (input) => { + if (input.command === "git") { + return Effect.succeed(processOutput("git version 2.51.0\n")); + } + if (input.command === "gh" && input.args[0] === "--version") { + return Effect.succeed(processOutput("gh version 2.83.0\n")); + } + if (input.command === "gh" && input.args.join(" ") === "auth status") { + return Effect.succeed( + processOutput(`github.com +Logged in to github.com account juliusmarminge (keyring) +- Active account: true +- Git operations protocol: ssh +- Token: gho_************************************ +- Token scopes: 'admin:public_key', 'gist', 'read:org', 'repo' +`), + ); + } + return Effect.fail( + new VcsProcessSpawnError({ + operation: input.operation, + command: input.command, + cwd: input.cwd, + cause: new Error(`${input.command} not found`), + }), + ); + }, + }), + ), + Layer.provideMerge(NodeServices.layer), + ); + + return Effect.gen(function* () { + const discovery = yield* SourceControlDiscovery; + const result = yield* discovery.discover; + + assert.deepStrictEqual( + result.versionControlSystems.map((item) => ({ + kind: item.kind, + implemented: item.implemented, + status: item.status, + })), + [ + { kind: "git", implemented: true, status: "available" }, + { kind: "jj", implemented: false, status: "missing" }, + ], + ); + assert.deepStrictEqual( + result.sourceControlProviders.map((item) => ({ + kind: item.kind, + implemented: item.implemented, + status: item.status, + auth: item.auth.status, + account: item.auth.account, + })), + [ + { + kind: "github", + implemented: true, + status: "available", + auth: "authenticated", + account: Option.some("juliusmarminge"), + }, + { + kind: "gitlab", + implemented: false, + status: "missing", + auth: "unknown", + account: Option.none(), + }, + { + kind: "azure-devops", + implemented: false, + status: "missing", + auth: "unknown", + account: Option.none(), + }, + { + kind: "bitbucket", + implemented: false, + status: "missing", + auth: "unknown", + account: Option.none(), + }, + ], + ); + }).pipe(Effect.provide(testLayer)); +}); + +it.effect("probes provider authentication without exposing token details", () => { + const testLayer = layer.pipe( + Layer.provide( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-auth-discovery-" }), + ), + Layer.provide( + Layer.mock(VcsProcess.VcsProcess)({ + run: (input) => { + if (input.args[0] === "--version") { + return Effect.succeed(processOutput(`${input.command} version test\n`)); + } + if (input.command === "gh" && input.args.join(" ") === "auth status") { + return Effect.succeed( + processOutput(`github.com +Logged in to github.com account octocat (keyring) +- Token: gho_************************************ +- Token scopes: 'repo' +`), + ); + } + if (input.command === "glab" && input.args.join(" ") === "auth status") { + return Effect.succeed( + processOutput(`gitlab.com +Logged in to gitlab.com as gitlab-user +`), + ); + } + if ( + input.command === "az" && + input.args.join(" ") === "account show --query user.name -o tsv" + ) { + return Effect.succeed(processOutput("azure-user@example.com\n")); + } + if (input.command === "bb" && input.args.join(" ") === "auth status") { + return Effect.succeed( + processOutput(`bitbucket.org +Logged in as bitbucket-user +`), + ); + } + return Effect.fail( + new VcsProcessSpawnError({ + operation: input.operation, + command: input.command, + cwd: input.cwd, + cause: new Error(`${input.command} not found`), + }), + ); + }, + }), + ), + Layer.provideMerge(NodeServices.layer), + ); + + return Effect.gen(function* () { + const discovery = yield* SourceControlDiscovery; + const result = yield* discovery.discover; + + assert.deepStrictEqual( + result.sourceControlProviders.map((item) => ({ + kind: item.kind, + auth: item.auth.status, + account: item.auth.account, + detail: item.auth.detail, + })), + [ + { + kind: "github", + auth: "authenticated", + account: Option.some("octocat"), + detail: Option.none(), + }, + { + kind: "gitlab", + auth: "authenticated", + account: Option.some("gitlab-user"), + detail: Option.none(), + }, + { + kind: "azure-devops", + auth: "authenticated", + account: Option.some("azure-user@example.com"), + detail: Option.none(), + }, + { + kind: "bitbucket", + auth: "authenticated", + account: Option.some("bitbucket-user"), + detail: Option.none(), + }, + ], + ); + }).pipe(Effect.provide(testLayer)); +}); diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.ts b/apps/server/src/sourceControl/SourceControlDiscovery.ts new file mode 100644 index 0000000000000000000000000000000000000000..7d8b5bc2d8cc1b2307b04d0ce7349b187e016d5c --- /dev/null +++ b/apps/server/src/sourceControl/SourceControlDiscovery.ts @@ -0,0 +1,412 @@ +import { + type SourceControlProviderAuth, + type SourceControlDiscoveryResult, + type SourceControlProviderDiscoveryItem, + type SourceControlProviderKind, + type VcsDiscoveryItem, + type VcsDriverKind, +} from "@t3tools/contracts"; +import { Context, Effect, Layer, Option } from "effect"; + +import { ServerConfig } from "../config.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; + +interface DiscoveryProbe { + readonly label: string; + readonly executable: string; + readonly versionArgs: ReadonlyArray; + readonly implemented: boolean; + readonly installHint: string; +} + +type VcsProbe = DiscoveryProbe & { + readonly kind: VcsDriverKind; +}; + +type ProviderProbe = DiscoveryProbe & { + readonly kind: SourceControlProviderKind; + readonly authArgs: ReadonlyArray; + readonly parseAuth: (input: AuthProbeInput) => SourceControlProviderAuth; +}; + +interface AuthProbeInput { + readonly stdout: string; + readonly stderr: string; + readonly exitCode: VcsProcess.VcsProcessOutput["exitCode"]; +} + +interface DiscoveryProbeResult { + readonly kind: Kind; + readonly label: string; + readonly executable: string; + readonly implemented: boolean; + readonly status: "available" | "missing"; + readonly version: Option.Option; + readonly installHint: string; + readonly detail: Option.Option; +} + +const VCS_PROBES: ReadonlyArray = [ + { + kind: "git", + label: "Git", + executable: "git", + versionArgs: ["--version"], + implemented: true, + installHint: "Install Git from https://git-scm.com/downloads or with your package manager.", + }, + { + kind: "jj", + label: "Jujutsu", + executable: "jj", + versionArgs: ["--version"], + implemented: false, + installHint: "Install Jujutsu with `brew install jj` or from https://github.com/jj-vcs/jj.", + }, +]; + +const SOURCE_CONTROL_PROVIDER_PROBES: ReadonlyArray = [ + { + kind: "github", + label: "GitHub", + executable: "gh", + versionArgs: ["--version"], + authArgs: ["auth", "status"], + parseAuth: parseGitHubAuth, + implemented: true, + installHint: "Install GitHub CLI with `brew install gh` or from https://cli.github.com/.", + }, + { + kind: "gitlab", + label: "GitLab", + executable: "glab", + versionArgs: ["--version"], + authArgs: ["auth", "status"], + parseAuth: parseGitLabAuth, + implemented: false, + installHint: + "Install GitLab CLI with `brew install glab` or from https://gitlab.com/gitlab-org/cli.", + }, + { + kind: "azure-devops", + label: "Azure DevOps", + executable: "az", + versionArgs: ["--version"], + authArgs: ["account", "show", "--query", "user.name", "-o", "tsv"], + parseAuth: parseAzureAuth, + implemented: false, + installHint: + "Install Azure CLI with `brew install azure-cli`, then add Azure DevOps support with `az extension add --name azure-devops`.", + }, + { + kind: "bitbucket", + label: "Bitbucket", + executable: "bb", + versionArgs: ["--version"], + authArgs: ["auth", "status"], + parseAuth: parseBitbucketAuth, + implemented: false, + installHint: "Install a Bitbucket CLI (`bb`) and authenticate it for your Bitbucket workspace.", + }, +]; + +function firstNonEmptyLine(text: string): Option.Option { + const line = text + .split(/\r?\n/) + .map((entry) => entry.trim()) + .find((entry) => entry.length > 0); + return line === undefined ? Option.none() : Option.some(line); +} + +function detailFromCause(cause: unknown): Option.Option { + if (cause instanceof Error && cause.message.trim().length > 0) { + return Option.some(cause.message.trim()); + } + return Option.none(); +} + +function authAccount(account: string | undefined): Option.Option { + const trimmed = account?.trim(); + return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed); +} + +function authHost(host: string | undefined): Option.Option { + const trimmed = host?.trim(); + return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed); +} + +function authDetail(detail: string | undefined): Option.Option { + const trimmed = detail?.trim(); + return trimmed === undefined || trimmed.length === 0 ? Option.none() : Option.some(trimmed); +} + +function providerAuth(input: { + readonly status: SourceControlProviderAuth["status"]; + readonly account?: string | undefined; + readonly host?: string | undefined; + readonly detail?: string | undefined; +}): SourceControlProviderAuth { + return { + status: input.status, + account: authAccount(input.account), + host: authHost(input.host), + detail: authDetail(input.detail), + }; +} + +function unknownAuth(detail?: string): SourceControlProviderAuth { + return providerAuth({ status: "unknown", detail }); +} + +function combinedAuthOutput(input: AuthProbeInput): string { + return [input.stdout, input.stderr].filter((entry) => entry.trim().length > 0).join("\n"); +} + +function sanitizedAuthLines(text: string): ReadonlyArray { + return text + .split(/\r?\n/) + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0) + .filter((entry) => !/^[-\s]*token(?:\s+scopes?)?:/iu.test(entry)); +} + +function firstSafeAuthLine(text: string): string | undefined { + return sanitizedAuthLines(text)[0]; +} + +function parseCliHost(text: string): string | undefined { + return sanitizedAuthLines(text) + .map((line) => line.replace(/^[^a-z0-9]+/iu, "")) + .find((line) => /^[a-z0-9][a-z0-9.-]*(?::\d+)?$/iu.test(line)); +} + +function matchFirst(text: string, patterns: ReadonlyArray): string | undefined { + for (const pattern of patterns) { + const match = pattern.exec(text); + const value = match?.[1]?.trim(); + if (value && value.length > 0) return value; + } + return undefined; +} + +function parseGitHubAuth(input: AuthProbeInput): SourceControlProviderAuth { + const output = combinedAuthOutput(input); + const account = matchFirst(output, [ + /Logged in to .* account\s+([^\s(]+)/iu, + /Logged in to .* as\s+([^\s(]+)/iu, + ]); + const host = parseCliHost(output); + + if (input.exitCode !== 0) { + return providerAuth({ + status: "unauthenticated", + host, + detail: firstSafeAuthLine(output) ?? "Run `gh auth login` to authenticate GitHub CLI.", + }); + } + + if (account) { + return providerAuth({ status: "authenticated", account, host }); + } + + return providerAuth({ + status: "unknown", + host, + detail: firstSafeAuthLine(output) ?? "GitHub CLI auth status could not be parsed.", + }); +} + +function parseGitLabAuth(input: AuthProbeInput): SourceControlProviderAuth { + const output = combinedAuthOutput(input); + const account = matchFirst(output, [ + /Logged in to .* as\s+([^\s(]+)/iu, + /Logged in to .* account\s+([^\s(]+)/iu, + /account:\s*([^\s(]+)/iu, + ]); + const host = parseCliHost(output); + + if (input.exitCode !== 0) { + return providerAuth({ + status: "unauthenticated", + host, + detail: firstSafeAuthLine(output) ?? "Run `glab auth login` to authenticate GitLab CLI.", + }); + } + + if (account) { + return providerAuth({ status: "authenticated", account, host }); + } + + return providerAuth({ + status: "unknown", + host, + detail: firstSafeAuthLine(output) ?? "GitLab CLI auth status could not be parsed.", + }); +} + +function parseAzureAuth(input: AuthProbeInput): SourceControlProviderAuth { + const account = input.stdout.trim().split(/\r?\n/)[0]?.trim(); + + if (input.exitCode !== 0) { + return providerAuth({ + status: "unauthenticated", + detail: + firstSafeAuthLine(combinedAuthOutput(input)) ?? "Run `az login` to authenticate Azure CLI.", + }); + } + + if (account && account.length > 0) { + return providerAuth({ status: "authenticated", account, host: "dev.azure.com" }); + } + + return providerAuth({ + status: "unknown", + host: "dev.azure.com", + detail: "Azure CLI account status could not be parsed.", + }); +} + +function parseBitbucketAuth(input: AuthProbeInput): SourceControlProviderAuth { + const output = combinedAuthOutput(input); + const account = matchFirst(output, [ + /Logged in to .* as\s+([^\s(]+)/iu, + /Logged in as\s+([^\s(]+)/iu, + /account:\s*([^\s(]+)/iu, + /user:\s*([^\s(]+)/iu, + /username:\s*([^\s(]+)/iu, + ]); + const host = parseCliHost(output) ?? "bitbucket.org"; + + if (input.exitCode !== 0) { + return providerAuth({ + status: "unauthenticated", + host, + detail: + firstSafeAuthLine(output) ?? "Authenticate the Bitbucket CLI before enabling Bitbucket.", + }); + } + + if (account) { + return providerAuth({ status: "authenticated", account, host }); + } + + return providerAuth({ + status: "unknown", + host, + detail: firstSafeAuthLine(output) ?? "Bitbucket CLI auth status could not be parsed.", + }); +} + +export interface SourceControlDiscoveryShape { + readonly discover: Effect.Effect; +} + +export class SourceControlDiscovery extends Context.Service< + SourceControlDiscovery, + SourceControlDiscoveryShape +>()("t3/source-control/SourceControlDiscovery") {} + +export const layer = Layer.effect( + SourceControlDiscovery, + Effect.gen(function* () { + const config = yield* ServerConfig; + const process = yield* VcsProcess.VcsProcess; + + const probe = ( + input: DiscoveryProbe & { readonly kind: Kind }, + ): Effect.Effect> => + process + .run({ + operation: "source-control.discovery.probe", + command: input.executable, + args: input.versionArgs, + cwd: config.cwd, + timeoutMs: 5_000, + maxOutputBytes: 8_000, + truncateOutputAtMaxBytes: true, + }) + .pipe( + Effect.map( + (result) => + ({ + kind: input.kind, + label: input.label, + executable: input.executable, + implemented: input.implemented, + status: "available" as const, + version: Option.orElse(firstNonEmptyLine(result.stdout), () => + firstNonEmptyLine(result.stderr), + ), + installHint: input.installHint, + detail: Option.none(), + }) satisfies DiscoveryProbeResult, + ), + Effect.catch((cause) => + Effect.succeed({ + kind: input.kind, + label: input.label, + executable: input.executable, + implemented: input.implemented, + status: "missing" as const, + version: Option.none(), + installHint: input.installHint, + detail: detailFromCause(cause), + } satisfies DiscoveryProbeResult), + ), + ); + + const probeProvider = (input: ProviderProbe) => + probe(input).pipe( + Effect.flatMap((item) => { + if (item.status !== "available") { + return Effect.succeed({ + ...item, + auth: unknownAuth("CLI is not installed."), + } satisfies SourceControlProviderDiscoveryItem); + } + + return process + .run({ + operation: "source-control.discovery.auth", + command: input.executable, + args: input.authArgs, + cwd: config.cwd, + allowNonZeroExit: true, + timeoutMs: 5_000, + maxOutputBytes: 8_000, + truncateOutputAtMaxBytes: true, + }) + .pipe( + Effect.map( + (result) => + ({ + ...item, + auth: input.parseAuth(result), + }) satisfies SourceControlProviderDiscoveryItem, + ), + Effect.catch((cause) => + Effect.succeed({ + ...item, + auth: unknownAuth(Option.getOrUndefined(detailFromCause(cause))), + } satisfies SourceControlProviderDiscoveryItem), + ), + ); + }), + ); + + return SourceControlDiscovery.of({ + discover: Effect.all({ + versionControlSystems: Effect.all( + VCS_PROBES.map((entry) => probe(entry)) as ReadonlyArray>, + { concurrency: "unbounded" }, + ), + sourceControlProviders: Effect.all( + SOURCE_CONTROL_PROVIDER_PROBES.map((entry) => probeProvider(entry)) as ReadonlyArray< + Effect.Effect + >, + { concurrency: "unbounded" }, + ), + }), + }); + }), +); diff --git a/apps/server/src/sourceControl/SourceControlProvider.ts b/apps/server/src/sourceControl/SourceControlProvider.ts new file mode 100644 index 0000000000000000000000000000000000000000..baa18cb9c4358049afdfdb4525d4f32570eb8c24 --- /dev/null +++ b/apps/server/src/sourceControl/SourceControlProvider.ts @@ -0,0 +1,68 @@ +import { Context, Effect } from "effect"; +import type { + ChangeRequest, + ChangeRequestState, + SourceControlProviderError, + SourceControlProviderInfo, + SourceControlProviderKind, + SourceControlRepositoryCloneUrls, +} from "@t3tools/contracts"; + +export interface SourceControlProviderContext { + readonly provider: SourceControlProviderInfo; + readonly remoteName: string; + readonly remoteUrl: string; +} + +export interface SourceControlRefSelector { + readonly refName: string; + readonly owner?: string; + readonly repository?: string; +} + +export interface SourceControlProviderShape { + readonly kind: SourceControlProviderKind; + readonly listChangeRequests: (input: { + readonly cwd: string; + readonly context?: SourceControlProviderContext; + readonly source?: SourceControlRefSelector; + readonly headSelector: string; + readonly state: ChangeRequestState | "all"; + readonly limit?: number; + }) => Effect.Effect, SourceControlProviderError>; + readonly getChangeRequest: (input: { + readonly cwd: string; + readonly context?: SourceControlProviderContext; + readonly reference: string; + }) => Effect.Effect; + readonly createChangeRequest: (input: { + readonly cwd: string; + readonly context?: SourceControlProviderContext; + readonly source?: SourceControlRefSelector; + readonly target?: SourceControlRefSelector; + readonly baseRefName: string; + readonly headSelector: string; + readonly title: string; + readonly bodyFile: string; + }) => Effect.Effect; + readonly getRepositoryCloneUrls: (input: { + readonly cwd: string; + readonly context?: SourceControlProviderContext; + readonly repository: string; + }) => Effect.Effect; + readonly getDefaultBranch: (input: { + readonly cwd: string; + readonly context?: SourceControlProviderContext; + }) => Effect.Effect; + readonly checkoutChangeRequest: (input: { + readonly cwd: string; + readonly context?: SourceControlProviderContext; + readonly reference: string; + readonly force?: boolean; + }) => Effect.Effect; +} + +export class SourceControlProvider extends Context.Service< + SourceControlProvider, + SourceControlProviderShape +>()("t3/source-control/SourceControlProvider") {} diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..23cdc3e1fd28d537726778215355fe4405efea5c --- /dev/null +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -0,0 +1,114 @@ +import { assert, it } from "@effect/vitest"; +import { DateTime, Effect, Layer, Option } from "effect"; + +import { GitHubCli } from "./GitHubCli.ts"; +import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; +import { VcsDriverRegistry } from "../vcs/VcsDriverRegistry.ts"; +import type { VcsDriverShape } from "../vcs/VcsDriver.ts"; + +const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); + +function makeRegistry(input: { + readonly remotes: ReadonlyArray<{ + readonly name: string; + readonly url: string; + }>; +}) { + const driver = { + listRemotes: () => + Effect.succeed({ + remotes: input.remotes.map((remote) => ({ + ...remote, + pushUrl: Option.none(), + isPrimary: remote.name === "origin", + })), + freshness: { + source: "live-local" as const, + observedAt: TEST_EPOCH, + expiresAt: Option.none(), + }, + }), + } satisfies Partial; + + const registryLayer = Layer.mock(VcsDriverRegistry)({ + get: () => Effect.succeed(driver as unknown as VcsDriverShape), + resolve: () => + Effect.succeed({ + kind: "git", + repository: { + kind: "git", + rootPath: "/repo", + metadataPath: null, + freshness: { + source: "live-local" as const, + observedAt: TEST_EPOCH, + expiresAt: Option.none(), + }, + }, + driver: driver as unknown as VcsDriverShape, + }), + }); + + return SourceControlProviderRegistry.make().pipe( + Effect.provide(Layer.mergeAll(registryLayer, Layer.mock(GitHubCli)({}))), + ); +} + +it.effect("routes GitHub remotes to the GitHub provider", () => + Effect.gen(function* () { + const registry = yield* makeRegistry({ + remotes: [{ name: "origin", url: "git@github.com:pingdotgg/t3code.git" }], + }); + + const provider = yield* registry.resolve({ cwd: "/repo" }); + + assert.strictEqual(provider.kind, "github"); + }), +); + +it.effect("routes directly by provider kind for remote-first workflows", () => + Effect.gen(function* () { + const registry = yield* makeRegistry({ + remotes: [], + }); + + const provider = yield* registry.get("github"); + + assert.strictEqual(provider.kind, "github"); + }), +); + +it.effect( + "detects GitLab remotes and returns an unsupported provider until one is registered", + () => + Effect.gen(function* () { + const registry = yield* makeRegistry({ + remotes: [{ name: "origin", url: "git@gitlab.com:group/project.git" }], + }); + + const provider = yield* registry.resolve({ cwd: "/repo" }); + + assert.strictEqual(provider.kind, "gitlab"); + const error = yield* Effect.flip( + provider.listChangeRequests({ + cwd: "/repo", + headSelector: "feature/source-control", + state: "open", + }), + ); + + assert.strictEqual(error.provider, "gitlab"); + }), +); + +it.effect("falls back to a non-origin remote when origin is not configured", () => + Effect.gen(function* () { + const registry = yield* makeRegistry({ + remotes: [{ name: "upstream", url: "https://dev.azure.com/acme/project/_git/repo" }], + }); + + const provider = yield* registry.resolve({ cwd: "/repo" }); + + assert.strictEqual(provider.kind, "azure-devops"); + }), +); diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts new file mode 100644 index 0000000000000000000000000000000000000000..bddbca15ee4c09d45dc89b83a5368bffd9568606 --- /dev/null +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts @@ -0,0 +1,162 @@ +import { Cache, Context, Duration, Effect, Exit, Layer } from "effect"; +import { SourceControlProviderError } from "@t3tools/contracts"; +import type { SourceControlProviderKind } from "@t3tools/contracts"; +import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; + +import { + SourceControlProvider, + type SourceControlProviderContext, + type SourceControlProviderShape, +} from "./SourceControlProvider.ts"; +import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; +import { VcsDriverRegistry } from "../vcs/VcsDriverRegistry.ts"; + +const PROVIDER_DETECTION_CACHE_CAPACITY = 2_048; +const PROVIDER_DETECTION_CACHE_TTL = Duration.seconds(5); + +export interface SourceControlProviderRegistration { + readonly kind: SourceControlProviderKind; + readonly provider: SourceControlProviderShape; +} + +export interface SourceControlProviderHandle { + readonly provider: SourceControlProviderShape; + readonly context: SourceControlProviderContext | null; +} + +export interface SourceControlProviderRegistryShape { + readonly get: ( + kind: SourceControlProviderKind, + ) => Effect.Effect; + readonly resolveHandle: (input: { + readonly cwd: string; + }) => Effect.Effect; + readonly resolve: (input: { + readonly cwd: string; + }) => Effect.Effect; +} + +export class SourceControlProviderRegistry extends Context.Service< + SourceControlProviderRegistry, + SourceControlProviderRegistryShape +>()("t3/source-control/SourceControlProviderRegistry") {} + +function unsupportedProvider(kind: SourceControlProviderKind): SourceControlProviderShape { + const unsupported = (operation: string) => + Effect.fail( + new SourceControlProviderError({ + provider: kind, + operation, + detail: `No ${kind} source control provider is registered.`, + }), + ); + + return SourceControlProvider.of({ + kind, + listChangeRequests: () => unsupported("listChangeRequests"), + getChangeRequest: () => unsupported("getChangeRequest"), + createChangeRequest: () => unsupported("createChangeRequest"), + getRepositoryCloneUrls: () => unsupported("getRepositoryCloneUrls"), + getDefaultBranch: () => unsupported("getDefaultBranch"), + checkoutChangeRequest: () => unsupported("checkoutChangeRequest"), + }); +} + +function providerDetectionError(operation: string, cwd: string, cause: unknown) { + return new SourceControlProviderError({ + provider: "unknown", + operation, + detail: `Failed to detect source control provider for ${cwd}.`, + cause, + }); +} + +function selectProviderContext( + remotes: ReadonlyArray<{ + readonly name: string; + readonly url: string; + }>, +): SourceControlProviderContext | null { + const candidates = remotes + .map((remote) => { + const provider = detectSourceControlProviderFromRemoteUrl(remote.url); + return provider + ? { + provider, + remoteName: remote.name, + remoteUrl: remote.url, + } + : null; + }) + .filter((value): value is SourceControlProviderContext => value !== null); + + return ( + candidates.find((candidate) => candidate.remoteName === "origin") ?? + candidates.find((candidate) => candidate.provider.kind !== "unknown") ?? + candidates[0] ?? + null + ); +} + +export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWithProviders")( + function* (registrations: ReadonlyArray) { + const vcsRegistry = yield* VcsDriverRegistry; + const providers = new Map( + registrations.map((registration) => [registration.kind, registration.provider]), + ); + + const get: SourceControlProviderRegistryShape["get"] = (kind) => + Effect.succeed(providers.get(kind) ?? unsupportedProvider(kind)); + + const detectProviderContext = Effect.fn("SourceControlProviderRegistry.detectProviderContext")( + function* (cwd: string) { + const handle = yield* vcsRegistry + .resolve({ cwd }) + .pipe(Effect.mapError((error) => providerDetectionError("detectProvider", cwd, error))); + const remotes = yield* handle.driver + .listRemotes(cwd) + .pipe(Effect.mapError((error) => providerDetectionError("detectProvider", cwd, error))); + + return selectProviderContext(remotes.remotes); + }, + ); + + const providerContextCache = yield* Cache.makeWith< + string, + SourceControlProviderContext | null, + SourceControlProviderError + >(detectProviderContext, { + capacity: PROVIDER_DETECTION_CACHE_CAPACITY, + timeToLive: (exit) => (Exit.isSuccess(exit) ? PROVIDER_DETECTION_CACHE_TTL : Duration.zero), + }); + + const resolveHandle: SourceControlProviderRegistryShape["resolveHandle"] = (input) => + Cache.get(providerContextCache, input.cwd).pipe( + Effect.map((context) => { + const kind = context?.provider.kind ?? "unknown"; + return { + provider: providers.get(kind) ?? unsupportedProvider(kind), + context, + } satisfies SourceControlProviderHandle; + }), + ); + + return SourceControlProviderRegistry.of({ + get, + resolveHandle, + resolve: (input) => resolveHandle(input).pipe(Effect.map((handle) => handle.provider)), + }); + }, +); + +export const make = Effect.fn("makeSourceControlProviderRegistry")(function* () { + const github = yield* GitHubSourceControlProvider.make(); + return yield* makeWithProviders([ + { + kind: "github", + provider: github, + }, + ]); +}); + +export const layer = Layer.effect(SourceControlProviderRegistry, make()); diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..19cff6f0344e74fce0f8715d36267c5b350b8afa --- /dev/null +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts @@ -0,0 +1,339 @@ +import { ClaudeSettings, ProviderInstanceId } from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it } from "@effect/vitest"; +import { Effect, FileSystem, Layer, Path, Schema } from "effect"; +import { createModelSelection } from "@t3tools/shared/model"; +import { expect } from "vitest"; + +import { ServerConfig } from "../config.ts"; +import { type TextGenerationShape } from "./TextGeneration.ts"; +import { sanitizeThreadTitle } from "./TextGenerationUtils.ts"; +import { makeClaudeTextGeneration } from "./ClaudeTextGeneration.ts"; + +const ClaudeTextGenerationTestLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-claude-text-generation-test-", +}).pipe(Layer.provideMerge(NodeServices.layer)); + +function makeFakeClaudeBinary(dir: string) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = path.join(dir, "bin"); + const claudePath = path.join(binDir, "claude"); + yield* fs.makeDirectory(binDir, { recursive: true }); + + yield* fs.writeFileString( + claudePath, + [ + "#!/bin/sh", + 'args="$*"', + 'stdin_content="$(cat)"', + 'if [ -n "$T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN" ]; then', + ' printf "%s" "$args" | grep -F -- "$T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN" >/dev/null || {', + ' printf "%s\\n" "args missing expected content" >&2', + " exit 2", + " }", + "fi", + 'if [ -n "$T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN" ]; then', + ' if printf "%s" "$args" | grep -F -- "$T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN" >/dev/null; then', + ' printf "%s\\n" "args contained forbidden content" >&2', + " exit 3", + " fi", + "fi", + 'if [ -n "$T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN" ]; then', + ' printf "%s" "$stdin_content" | grep -F -- "$T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN" >/dev/null || {', + ' printf "%s\\n" "stdin missing expected content" >&2', + " exit 4", + " }", + "fi", + 'if [ -n "$T3_FAKE_CLAUDE_HOME_MUST_BE" ] && [ "$HOME" != "$T3_FAKE_CLAUDE_HOME_MUST_BE" ]; then', + ' printf "%s\\n" "HOME was $HOME" >&2', + " exit 5", + "fi", + 'if [ -n "$T3_FAKE_CLAUDE_STDERR" ]; then', + ' printf "%s\\n" "$T3_FAKE_CLAUDE_STDERR" >&2', + "fi", + 'printf "%s" "$T3_FAKE_CLAUDE_OUTPUT"', + 'exit "${T3_FAKE_CLAUDE_EXIT_CODE:-0}"', + "", + ].join("\n"), + ); + yield* fs.chmod(claudePath, 0o755); + return binDir; + }); +} + +function withFakeClaudeEnv( + input: { + output: string; + exitCode?: number; + stderr?: string; + argsMustContain?: string; + argsMustNotContain?: string; + stdinMustContain?: string; + homeMustBe?: string; + claudeConfig?: Partial; + }, + effectFn: (textGeneration: TextGenerationShape) => Effect.Effect, +) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-claude-text-" }); + const binDir = yield* makeFakeClaudeBinary(tempDir); + const previousPath = process.env.PATH; + const previousOutput = process.env.T3_FAKE_CLAUDE_OUTPUT; + const previousExitCode = process.env.T3_FAKE_CLAUDE_EXIT_CODE; + const previousStderr = process.env.T3_FAKE_CLAUDE_STDERR; + const previousArgsMustContain = process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN; + const previousArgsMustNotContain = process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN; + const previousStdinMustContain = process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN; + const previousHomeMustBe = process.env.T3_FAKE_CLAUDE_HOME_MUST_BE; + + yield* Effect.acquireRelease( + Effect.sync(() => { + process.env.PATH = `${binDir}:${previousPath ?? ""}`; + process.env.T3_FAKE_CLAUDE_OUTPUT = input.output; + + if (input.exitCode !== undefined) { + process.env.T3_FAKE_CLAUDE_EXIT_CODE = String(input.exitCode); + } else { + delete process.env.T3_FAKE_CLAUDE_EXIT_CODE; + } + + if (input.stderr !== undefined) { + process.env.T3_FAKE_CLAUDE_STDERR = input.stderr; + } else { + delete process.env.T3_FAKE_CLAUDE_STDERR; + } + + if (input.argsMustContain !== undefined) { + process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN = input.argsMustContain; + } else { + delete process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN; + } + + if (input.argsMustNotContain !== undefined) { + process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN = input.argsMustNotContain; + } else { + delete process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN; + } + + if (input.stdinMustContain !== undefined) { + process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN = input.stdinMustContain; + } else { + delete process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN; + } + + if (input.homeMustBe !== undefined) { + process.env.T3_FAKE_CLAUDE_HOME_MUST_BE = input.homeMustBe; + } else { + delete process.env.T3_FAKE_CLAUDE_HOME_MUST_BE; + } + }), + () => + Effect.sync(() => { + process.env.PATH = previousPath; + + if (previousOutput === undefined) { + delete process.env.T3_FAKE_CLAUDE_OUTPUT; + } else { + process.env.T3_FAKE_CLAUDE_OUTPUT = previousOutput; + } + + if (previousExitCode === undefined) { + delete process.env.T3_FAKE_CLAUDE_EXIT_CODE; + } else { + process.env.T3_FAKE_CLAUDE_EXIT_CODE = previousExitCode; + } + + if (previousStderr === undefined) { + delete process.env.T3_FAKE_CLAUDE_STDERR; + } else { + process.env.T3_FAKE_CLAUDE_STDERR = previousStderr; + } + + if (previousArgsMustContain === undefined) { + delete process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN; + } else { + process.env.T3_FAKE_CLAUDE_ARGS_MUST_CONTAIN = previousArgsMustContain; + } + + if (previousArgsMustNotContain === undefined) { + delete process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN; + } else { + process.env.T3_FAKE_CLAUDE_ARGS_MUST_NOT_CONTAIN = previousArgsMustNotContain; + } + + if (previousStdinMustContain === undefined) { + delete process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN; + } else { + process.env.T3_FAKE_CLAUDE_STDIN_MUST_CONTAIN = previousStdinMustContain; + } + + if (previousHomeMustBe === undefined) { + delete process.env.T3_FAKE_CLAUDE_HOME_MUST_BE; + } else { + process.env.T3_FAKE_CLAUDE_HOME_MUST_BE = previousHomeMustBe; + } + }), + ); + + const config = Schema.decodeSync(ClaudeSettings)(input.claudeConfig ?? {}); + const textGeneration = yield* makeClaudeTextGeneration(config); + return yield* effectFn(textGeneration); + }).pipe(Effect.scoped); +} + +it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => { + it.effect("forwards Claude thinking settings for Haiku without passing effort", () => + withFakeClaudeEnv( + { + output: JSON.stringify({ + structured_output: { + subject: "Add important change", + body: "", + }, + }), + argsMustContain: '--settings {"alwaysThinkingEnabled":false}', + argsMustNotContain: "--effort", + }, + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/claude-effect", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: { + ...createModelSelection(ProviderInstanceId.make("claudeAgent"), "claude-haiku-4-5", [ + { id: "thinking", value: false }, + { id: "effort", value: "high" }, + ]), + }, + }); + + expect(generated.subject).toBe("Add important change"); + }), + ), + ); + + it.effect("forwards Claude fast mode and supported effort", () => + withFakeClaudeEnv( + { + output: JSON.stringify({ + structured_output: { + title: "Improve orchestration flow", + body: "Body", + }, + }), + argsMustContain: '--effort max --settings {"fastMode":true}', + }, + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generatePrContent({ + cwd: process.cwd(), + baseBranch: "main", + headBranch: "feature/claude-effect", + commitSummary: "Improve orchestration", + diffSummary: "1 file changed", + diffPatch: "diff --git a/README.md b/README.md", + modelSelection: { + ...createModelSelection(ProviderInstanceId.make("claudeAgent"), "claude-opus-4-6", [ + { id: "effort", value: "max" }, + { id: "fastMode", value: true }, + ]), + }, + }); + + expect(generated.title).toBe("Improve orchestration flow"); + }), + ), + ); + + it.effect("generates thread titles through the Claude provider", () => + withFakeClaudeEnv( + { + output: JSON.stringify({ + structured_output: { + title: + ' "Reconnect failures after restart because the session state does not recover" ', + }, + }), + stdinMustContain: "You write concise thread titles for coding conversations.", + }, + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "Please investigate reconnect failures after restarting the session.", + modelSelection: { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-sonnet-4-6", + }, + }); + + expect(generated.title).toBe( + sanitizeThreadTitle( + '"Reconnect failures after restart because the session state does not recover"', + ), + ); + }), + ), + ); + + it.effect("runs Claude text generation with the configured Claude HOME", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHome = path.join(process.cwd(), ".claude-work-test"); + return yield* withFakeClaudeEnv( + { + output: JSON.stringify({ + structured_output: { + title: "Use Claude home", + }, + }), + homeMustBe: claudeHome, + claudeConfig: { homePath: claudeHome }, + }, + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "thread title", + modelSelection: { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-sonnet-4-6", + }, + }); + + expect(generated.title).toBe(sanitizeThreadTitle("Use Claude home")); + }), + ); + }), + ); + + it.effect("falls back when Claude thread title normalization becomes whitespace-only", () => + withFakeClaudeEnv( + { + output: JSON.stringify({ + structured_output: { + title: ' """ """ ', + }, + }), + }, + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "Name this thread.", + modelSelection: { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-sonnet-4-6", + }, + }); + + expect(generated.title).toBe("New thread"); + }), + ), + ); +}); diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.ts new file mode 100644 index 0000000000000000000000000000000000000000..2f0cbc509b6ea136f8b2c8acae71e67f8fb888fc --- /dev/null +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.ts @@ -0,0 +1,324 @@ +/** + * ClaudeTextGeneration – Text generation layer using the Claude CLI. + * + * Implements the same TextGenerationShape contract as CodexTextGeneration but + * delegates to the `claude` CLI (`claude -p`) with structured JSON output + * instead of the `codex exec` CLI. + * + * @module ClaudeTextGeneration + */ +import { Effect, Option, Schema, Stream } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { type ClaudeSettings, type ModelSelection } from "@t3tools/contracts"; +import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; + +import { TextGenerationError } from "@t3tools/contracts"; +import { type TextGenerationShape } from "./TextGeneration.ts"; +import { + buildBranchNamePrompt, + buildCommitMessagePrompt, + buildPrContentPrompt, + buildThreadTitlePrompt, +} from "./TextGenerationPrompts.ts"; +import { + normalizeCliError, + sanitizeCommitSubject, + sanitizePrTitle, + sanitizeThreadTitle, + toJsonSchemaObject, +} from "./TextGenerationUtils.ts"; +import { + getModelSelectionStringOptionValue, + getProviderOptionDescriptors, +} from "@t3tools/shared/model"; +import { + getClaudeModelCapabilities, + normalizeClaudeCliEffort, + resolveClaudeApiModelId, + resolveClaudeEffort, +} from "../provider/Layers/ClaudeProvider.ts"; +import { makeClaudeEnvironment } from "../provider/Drivers/ClaudeHome.ts"; + +const CLAUDE_TIMEOUT_MS = 180_000; + +/** + * Schema for the wrapper JSON returned by `claude -p --output-format json`. + * We only care about `structured_output`. + */ +const ClaudeOutputEnvelope = Schema.Struct({ + structured_output: Schema.Unknown, +}); + +export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(function* ( + claudeSettings: ClaudeSettings, + environment: NodeJS.ProcessEnv = process.env, +) { + const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment); + + const readStreamAsString = ( + operation: string, + stream: Stream.Stream, + ): Effect.Effect => + stream.pipe( + Stream.decodeText(), + Stream.runFold( + () => "", + (acc, chunk) => acc + chunk, + ), + Effect.mapError((cause) => + normalizeCliError("claude", operation, cause, "Failed to collect process output"), + ), + ); + + /** + * Spawn the Claude CLI with structured JSON output and return the parsed, + * schema-validated result. + */ + const runClaudeJson = Effect.fn("runClaudeJson")(function* ({ + operation, + cwd, + prompt, + outputSchemaJson, + modelSelection, + }: { + operation: + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle"; + cwd: string; + prompt: string; + outputSchemaJson: S; + modelSelection: ModelSelection; + }): Effect.fn.Return { + const jsonSchemaStr = JSON.stringify(toJsonSchemaObject(outputSchemaJson)); + const caps = getClaudeModelCapabilities(modelSelection.model); + const descriptors = getProviderOptionDescriptors({ + caps, + selections: modelSelection.options, + }); + const findDescriptor = (id: string) => descriptors.find((descriptor) => descriptor.id === id); + const rawEffortSelection = getModelSelectionStringOptionValue(modelSelection, "effort"); + const resolvedEffort = resolveClaudeEffort(caps, rawEffortSelection); + const cliEffort = normalizeClaudeCliEffort(resolvedEffort); + const thinkingDescriptor = findDescriptor("thinking"); + const fastModeDescriptor = findDescriptor("fastMode"); + const thinking = + thinkingDescriptor?.type === "boolean" ? thinkingDescriptor.currentValue : undefined; + const fastMode = + fastModeDescriptor?.type === "boolean" ? fastModeDescriptor.currentValue : undefined; + const settings = { + ...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}), + ...(fastMode ? { fastMode: true } : {}), + }; + + const runClaudeCommand = Effect.fn("runClaudeJson.runClaudeCommand")(function* () { + const command = ChildProcess.make( + claudeSettings.binaryPath || "claude", + [ + "-p", + "--output-format", + "json", + "--json-schema", + jsonSchemaStr, + "--model", + resolveClaudeApiModelId(modelSelection), + ...(cliEffort ? ["--effort", cliEffort] : []), + ...(Object.keys(settings).length > 0 ? ["--settings", JSON.stringify(settings)] : []), + "--dangerously-skip-permissions", + ], + { + env: claudeEnvironment, + cwd, + shell: process.platform === "win32", + stdin: { + stream: Stream.encodeText(Stream.make(prompt)), + }, + }, + ); + + const child = yield* commandSpawner + .spawn(command) + .pipe( + Effect.mapError((cause) => + normalizeCliError("claude", operation, cause, "Failed to spawn Claude CLI process"), + ), + ); + + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + readStreamAsString(operation, child.stdout), + readStreamAsString(operation, child.stderr), + child.exitCode.pipe( + Effect.mapError((cause) => + normalizeCliError("claude", operation, cause, "Failed to read Claude CLI exit code"), + ), + ), + ], + { concurrency: "unbounded" }, + ); + + if (exitCode !== 0) { + const stderrDetail = stderr.trim(); + const stdoutDetail = stdout.trim(); + const detail = stderrDetail.length > 0 ? stderrDetail : stdoutDetail; + return yield* new TextGenerationError({ + operation, + detail: + detail.length > 0 + ? `Claude CLI command failed: ${detail}` + : `Claude CLI command failed with code ${exitCode}.`, + }); + } + + return stdout; + }); + + const rawStdout = yield* runClaudeCommand().pipe( + Effect.scoped, + Effect.timeoutOption(CLAUDE_TIMEOUT_MS), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new TextGenerationError({ operation, detail: "Claude CLI request timed out." }), + ), + onSome: (value) => Effect.succeed(value), + }), + ), + ); + + const envelope = yield* Schema.decodeEffect(Schema.fromJsonString(ClaudeOutputEnvelope))( + rawStdout, + ).pipe( + Effect.catchTag("SchemaError", (cause) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Claude CLI returned unexpected output format.", + cause, + }), + ), + ), + ); + + return yield* Schema.decodeEffect(outputSchemaJson)(envelope.structured_output).pipe( + Effect.catchTag("SchemaError", (cause) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Claude returned invalid structured output.", + cause, + }), + ), + ), + ); + }); + + // --------------------------------------------------------------------------- + // TextGenerationShape methods + // --------------------------------------------------------------------------- + + const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn( + "ClaudeTextGeneration.generateCommitMessage", + )(function* (input) { + const { prompt, outputSchema } = buildCommitMessagePrompt({ + branch: input.branch, + stagedSummary: input.stagedSummary, + stagedPatch: input.stagedPatch, + includeBranch: input.includeBranch === true, + }); + + const generated = yield* runClaudeJson({ + operation: "generateCommitMessage", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + subject: sanitizeCommitSubject(generated.subject), + body: generated.body.trim(), + ...("branch" in generated && typeof generated.branch === "string" + ? { branch: sanitizeFeatureBranchName(generated.branch) } + : {}), + }; + }); + + const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn( + "ClaudeTextGeneration.generatePrContent", + )(function* (input) { + const { prompt, outputSchema } = buildPrContentPrompt({ + baseBranch: input.baseBranch, + headBranch: input.headBranch, + commitSummary: input.commitSummary, + diffSummary: input.diffSummary, + diffPatch: input.diffPatch, + }); + + const generated = yield* runClaudeJson({ + operation: "generatePrContent", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + title: sanitizePrTitle(generated.title), + body: generated.body.trim(), + }; + }); + + const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn( + "ClaudeTextGeneration.generateBranchName", + )(function* (input) { + const { prompt, outputSchema } = buildBranchNamePrompt({ + message: input.message, + attachments: input.attachments, + }); + + const generated = yield* runClaudeJson({ + operation: "generateBranchName", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + branch: sanitizeBranchFragment(generated.branch), + }; + }); + + const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn( + "ClaudeTextGeneration.generateThreadTitle", + )(function* (input) { + const { prompt, outputSchema } = buildThreadTitlePrompt({ + message: input.message, + attachments: input.attachments, + }); + + const generated = yield* runClaudeJson({ + operation: "generateThreadTitle", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + title: sanitizeThreadTitle(generated.title), + }; + }); + + return { + generateCommitMessage, + generatePrContent, + generateBranchName, + generateThreadTitle, + } satisfies TextGenerationShape; +}); diff --git a/apps/server/src/textGeneration/CodexTextGeneration.test.ts b/apps/server/src/textGeneration/CodexTextGeneration.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..07123e921b1eb8407875c42e8e8fa5795153737d --- /dev/null +++ b/apps/server/src/textGeneration/CodexTextGeneration.test.ts @@ -0,0 +1,592 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it } from "@effect/vitest"; +import { Effect, FileSystem, Layer, Path, Result, Schema } from "effect"; +import { createModelSelection } from "@t3tools/shared/model"; +import { expect } from "vitest"; + +import { CodexSettings, ProviderInstanceId, TextGenerationError } from "@t3tools/contracts"; + +import { ServerConfig } from "../config.ts"; +import { type TextGenerationShape } from "./TextGeneration.ts"; +import { makeCodexTextGeneration } from "./CodexTextGeneration.ts"; + +const DEFAULT_TEST_MODEL_SELECTION = createModelSelection( + ProviderInstanceId.make("codex"), + "gpt-5.4-mini", +); + +const CodexTextGenerationTestLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-codex-text-generation-test-", +}).pipe(Layer.provideMerge(NodeServices.layer)); + +function makeFakeCodexBinary( + dir: string, + input: { + output: string; + exitCode?: number; + stderr?: string; + requireImage?: boolean; + requireFastServiceTier?: boolean; + requireReasoningEffort?: string; + forbidReasoningEffort?: boolean; + stdinMustContain?: string; + stdinMustNotContain?: string; + }, +) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = path.join(dir, "bin"); + const codexPath = path.join(binDir, "codex"); + yield* fs.makeDirectory(binDir, { recursive: true }); + + yield* fs.writeFileString( + codexPath, + [ + "#!/bin/sh", + 'output_path=""', + 'seen_image="0"', + 'seen_fast_service_tier="0"', + 'seen_reasoning_effort=""', + "while [ $# -gt 0 ]; do", + ' if [ "$1" = "--image" ]; then', + " shift", + ' if [ -n "$1" ]; then', + ' seen_image="1"', + " fi", + " shift", + " continue", + " fi", + ' if [ "$1" = "--config" ]; then', + " shift", + ' if [ "$1" = "service_tier=\\"fast\\"" ]; then', + ' seen_fast_service_tier="1"', + " fi", + ' case "$1" in', + " model_reasoning_effort=*)", + ' seen_reasoning_effort="$1"', + " ;;", + " esac", + " shift", + " continue", + " fi", + ' if [ "$1" = "--output-last-message" ]; then', + " shift", + ' output_path="$1"', + " shift", + " continue", + " fi", + " shift", + "done", + 'stdin_content="$(cat)"', + ...(input.requireImage + ? [ + 'if [ "$seen_image" != "1" ]; then', + ' printf "%s\\n" "missing --image input" >&2', + ` exit 2`, + "fi", + ] + : []), + ...(input.requireFastServiceTier + ? [ + 'if [ "$seen_fast_service_tier" != "1" ]; then', + ' printf "%s\\n" "missing fast service tier config" >&2', + ` exit 5`, + "fi", + ] + : []), + ...(input.requireReasoningEffort !== undefined + ? [ + `if [ "$seen_reasoning_effort" != "model_reasoning_effort=\\"${input.requireReasoningEffort}\\"" ]; then`, + ' printf "%s\\n" "unexpected reasoning effort config: $seen_reasoning_effort" >&2', + ` exit 6`, + "fi", + ] + : []), + ...(input.forbidReasoningEffort + ? [ + 'if [ -n "$seen_reasoning_effort" ]; then', + ' printf "%s\\n" "reasoning effort config should be omitted: $seen_reasoning_effort" >&2', + ` exit 7`, + "fi", + ] + : []), + ...(input.stdinMustContain !== undefined + ? [ + `if ! printf "%s" "$stdin_content" | grep -F -- ${JSON.stringify(input.stdinMustContain)} >/dev/null; then`, + ' printf "%s\\n" "stdin missing expected content" >&2', + ` exit 3`, + "fi", + ] + : []), + ...(input.stdinMustNotContain !== undefined + ? [ + `if printf "%s" "$stdin_content" | grep -F -- ${JSON.stringify(input.stdinMustNotContain)} >/dev/null; then`, + ' printf "%s\\n" "stdin contained forbidden content" >&2', + ` exit 4`, + "fi", + ] + : []), + ...(input.stderr !== undefined + ? [`printf "%s\\n" ${JSON.stringify(input.stderr)} >&2`] + : []), + 'if [ -n "$output_path" ]; then', + " cat > \"$output_path\" <<'__T3CODE_FAKE_CODEX_OUTPUT__'", + input.output, + "__T3CODE_FAKE_CODEX_OUTPUT__", + "fi", + `exit ${input.exitCode ?? 0}`, + "", + ].join("\n"), + ); + yield* fs.chmod(codexPath, 0o755); + return codexPath; + }); +} + +function withFakeCodexEnv( + input: { + output: string; + exitCode?: number; + stderr?: string; + requireImage?: boolean; + requireFastServiceTier?: boolean; + requireReasoningEffort?: string; + forbidReasoningEffort?: boolean; + stdinMustContain?: string; + stdinMustNotContain?: string; + }, + effectFn: (textGeneration: TextGenerationShape) => Effect.Effect, +) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-codex-text-" }); + const codexPath = yield* makeFakeCodexBinary(tempDir, input); + const config = Schema.decodeSync(CodexSettings)({ binaryPath: codexPath }); + const textGeneration = yield* makeCodexTextGeneration(config); + return yield* effectFn(textGeneration); + }).pipe(Effect.scoped); +} + +it.layer(CodexTextGenerationTestLayer)("CodexTextGeneration", (it) => { + it.effect("generates and sanitizes commit messages without branch by default", () => + withFakeCodexEnv( + { + output: JSON.stringify({ + subject: + " Add important change to the system with too much detail and a trailing period.\nsecondary line", + body: "\n- added migration\n- updated tests\n", + }), + stdinMustNotContain: "branch must be a short semantic git branch fragment", + }, + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/codex-effect", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); + + expect(generated.subject.length).toBeLessThanOrEqual(72); + expect(generated.subject.endsWith(".")).toBe(false); + expect(generated.body).toBe("- added migration\n- updated tests"); + expect(generated.branch).toBeUndefined(); + }), + ), + ); + + it.effect( + "forwards codex fast mode and non-default reasoning effort into codex exec config", + () => + withFakeCodexEnv( + { + output: JSON.stringify({ + subject: "Add important change", + body: "", + }), + requireFastServiceTier: true, + requireReasoningEffort: "xhigh", + stdinMustNotContain: "branch must be a short semantic git branch fragment", + }, + (textGeneration) => + textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/codex-effect", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [ + { id: "reasoningEffort", value: "xhigh" }, + { id: "fastMode", value: true }, + ]), + }), + ), + ); + + it.effect("defaults git text generation codex effort to low", () => + withFakeCodexEnv( + { + output: JSON.stringify({ + subject: "Add important change", + body: "", + }), + requireReasoningEffort: "low", + }, + (textGeneration) => + textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/codex-effect", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }), + ), + ); + + it.effect("generates commit message with branch when includeBranch is true", () => + withFakeCodexEnv( + { + output: JSON.stringify({ + subject: "Add important change", + body: "", + branch: "fix/important-system-change", + }), + stdinMustContain: "branch must be a short semantic git branch fragment", + }, + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/codex-effect", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + includeBranch: true, + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); + + expect(generated.subject).toBe("Add important change"); + expect(generated.branch).toBe("feature/fix/important-system-change"); + }), + ), + ); + + it.effect("generates PR content and trims markdown body", () => + withFakeCodexEnv( + { + output: JSON.stringify({ + title: " Improve orchestration flow\nwith ignored suffix", + body: "\n## Summary\n- improve flow\n\n## Testing\n- bun test\n\n", + }), + }, + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generatePrContent({ + cwd: process.cwd(), + baseBranch: "main", + headBranch: "feature/codex-effect", + commitSummary: "feat: improve orchestration flow", + diffSummary: "2 files changed", + diffPatch: "diff --git a/a.ts b/a.ts", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); + + expect(generated.title).toBe("Improve orchestration flow"); + expect(generated.body.startsWith("## Summary")).toBe(true); + expect(generated.body.endsWith("\n\n")).toBe(false); + }), + ), + ); + + it.effect("generates branch names and normalizes branch fragments", () => + withFakeCodexEnv( + { + output: JSON.stringify({ + branch: " Feat/Session ", + }), + stdinMustNotContain: "Image attachments supplied to the model", + }, + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generateBranchName({ + cwd: process.cwd(), + message: "Please update session handling.", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); + + expect(generated.branch).toBe("feat/session"); + }), + ), + ); + + it.effect("generates thread titles and trims them for sidebar use", () => + withFakeCodexEnv( + { + output: JSON.stringify({ + title: + ' "Investigate websocket reconnect regressions after worktree restore" \nignored line', + }), + }, + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "Please investigate websocket reconnect regressions after a worktree restore.", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); + + expect(generated.title).toBe("Investigate websocket reconnect regressions aft..."); + }), + ), + ); + + it.effect("falls back when thread title normalization becomes whitespace-only", () => + withFakeCodexEnv( + { + output: JSON.stringify({ + title: ' """ """ ', + }), + }, + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "Name this thread.", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); + + expect(generated.title).toBe("New thread"); + }), + ), + ); + + it.effect("trims whitespace exposed after quote removal in thread titles", () => + withFakeCodexEnv( + { + output: JSON.stringify({ + title: ` "' hello world '" `, + }), + }, + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "Name this thread.", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); + + expect(generated.title).toBe("hello world"); + }), + ), + ); + + it.effect("omits attachment metadata section when no attachments are provided", () => + withFakeCodexEnv( + { + output: JSON.stringify({ + branch: "fix/session-timeout", + }), + stdinMustNotContain: "Attachment metadata:", + }, + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generateBranchName({ + cwd: process.cwd(), + message: "Fix timeout behavior.", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); + + expect(generated.branch).toBe("fix/session-timeout"); + }), + ), + ); + + it.effect("passes image attachments through as codex image inputs", () => + withFakeCodexEnv( + { + output: JSON.stringify({ + branch: "fix/ui-regression", + }), + requireImage: true, + stdinMustContain: "Attachment metadata:", + }, + (textGeneration) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { attachmentsDir } = yield* ServerConfig; + const attachmentId = `thread-branch-image-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; + const attachmentPath = path.join(attachmentsDir, `${attachmentId}.png`); + yield* fs.makeDirectory(attachmentsDir, { recursive: true }); + yield* fs.writeFile(attachmentPath, Buffer.from("hello")); + + const generated = yield* textGeneration.generateBranchName({ + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + cwd: process.cwd(), + message: "Fix layout bug from screenshot.", + attachments: [ + { + type: "image", + id: attachmentId, + name: "bug.png", + mimeType: "image/png", + sizeBytes: 5, + }, + ], + }); + + expect(generated.branch).toBe("fix/ui-regression"); + }), + ), + ); + + it.effect("resolves persisted attachment ids to files for codex image inputs", () => + withFakeCodexEnv( + { + output: JSON.stringify({ + branch: "fix/ui-regression", + }), + requireImage: true, + }, + (textGeneration) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { attachmentsDir } = yield* ServerConfig; + const attachmentId = `thread-1-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; + const imagePath = path.join(attachmentsDir, `${attachmentId}.png`); + yield* fs.makeDirectory(attachmentsDir, { recursive: true }); + yield* fs.writeFile(imagePath, Buffer.from("hello")); + + const generated = yield* textGeneration + .generateBranchName({ + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + cwd: process.cwd(), + message: "Fix layout bug from screenshot.", + attachments: [ + { + type: "image", + id: attachmentId, + name: "bug.png", + mimeType: "image/png", + sizeBytes: 5, + }, + ], + }) + .pipe( + Effect.tap(() => + fs.stat(imagePath).pipe( + Effect.map((fileInfo) => { + expect(fileInfo.type).toBe("File"); + }), + ), + ), + Effect.ensuring(fs.remove(imagePath).pipe(Effect.catch(() => Effect.void))), + ); + + expect(generated.branch).toBe("fix/ui-regression"); + }), + ), + ); + + it.effect("ignores missing attachment ids for codex image inputs", () => + withFakeCodexEnv( + { + output: JSON.stringify({ + branch: "fix/ui-regression", + }), + requireImage: true, + }, + (textGeneration) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { attachmentsDir } = yield* ServerConfig; + const missingAttachmentId = `thread-missing-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; + const missingPath = path.join(attachmentsDir, `${missingAttachmentId}.png`); + yield* fs.remove(missingPath).pipe(Effect.catch(() => Effect.void)); + + const result = yield* textGeneration + .generateBranchName({ + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + cwd: process.cwd(), + message: "Fix layout bug from screenshot.", + attachments: [ + { + type: "image", + id: missingAttachmentId, + name: "outside.png", + mimeType: "image/png", + sizeBytes: 5, + }, + ], + }) + .pipe(Effect.result); + + expect(Result.isFailure(result)).toBe(true); + if (Result.isFailure(result)) { + expect(result.failure).toBeInstanceOf(TextGenerationError); + expect(result.failure.message).toContain("missing --image input"); + } + }), + ), + ); + + it.effect( + "fails with typed TextGenerationError when codex returns wrong branch payload shape", + () => + withFakeCodexEnv( + { + output: JSON.stringify({ + title: "This is not a branch payload", + }), + }, + (textGeneration) => + Effect.gen(function* () { + const result = yield* textGeneration + .generateBranchName({ + cwd: process.cwd(), + message: "Fix websocket reconnect flake", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }) + .pipe(Effect.result); + + expect(Result.isFailure(result)).toBe(true); + if (Result.isFailure(result)) { + expect(result.failure).toBeInstanceOf(TextGenerationError); + expect(result.failure.message).toContain("Codex returned invalid structured output"); + } + }), + ), + ); + + it.effect("returns typed TextGenerationError when codex exits non-zero", () => + withFakeCodexEnv( + { + output: JSON.stringify({ subject: "ignored", body: "" }), + exitCode: 1, + stderr: "codex execution failed", + }, + (textGeneration) => + Effect.gen(function* () { + const result = yield* textGeneration + .generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/codex-error", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }) + .pipe(Effect.result); + + expect(Result.isFailure(result)).toBe(true); + if (Result.isFailure(result)) { + expect(result.failure).toBeInstanceOf(TextGenerationError); + expect(result.failure.message).toContain( + "Codex CLI command failed: codex execution failed", + ); + } + }), + ), + ); +}); diff --git a/apps/server/src/textGeneration/CodexTextGeneration.ts b/apps/server/src/textGeneration/CodexTextGeneration.ts new file mode 100644 index 0000000000000000000000000000000000000000..786a0be4c49354f5fb316e3652ea654a27dc68f3 --- /dev/null +++ b/apps/server/src/textGeneration/CodexTextGeneration.ts @@ -0,0 +1,388 @@ +import { Effect, FileSystem, Option, Path, Random, Schema, Scope, Stream } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { type CodexSettings, type ModelSelection } from "@t3tools/contracts"; +import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; + +import { resolveAttachmentPath } from "../attachmentStore.ts"; +import { ServerConfig } from "../config.ts"; +import { expandHomePath } from "../pathExpansion.ts"; +import { TextGenerationError } from "@t3tools/contracts"; +import { + type BranchNameGenerationInput, + type ThreadTitleGenerationResult, + type TextGenerationShape, +} from "./TextGeneration.ts"; +import { + buildBranchNamePrompt, + buildCommitMessagePrompt, + buildPrContentPrompt, + buildThreadTitlePrompt, +} from "./TextGenerationPrompts.ts"; +import { + normalizeCliError, + sanitizeCommitSubject, + sanitizePrTitle, + sanitizeThreadTitle, + toJsonSchemaObject, +} from "./TextGenerationUtils.ts"; +import { + getModelSelectionBooleanOptionValue, + getModelSelectionStringOptionValue, +} from "@t3tools/shared/model"; + +const CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT = "low"; +const CODEX_TIMEOUT_MS = 180_000; +/** + * Build a Codex text-generation closure bound to a specific `CodexSettings` + * payload. See `makeCodexAdapter` for the overall per-instance rationale. + */ +export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(function* ( + codexConfig: CodexSettings, + environment: NodeJS.ProcessEnv = process.env, +) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const serverConfig = yield* Effect.service(ServerConfig); + + type MaterializedImageAttachments = { + readonly imagePaths: ReadonlyArray; + }; + + const readStreamAsString = ( + operation: string, + stream: Stream.Stream, + ): Effect.Effect => + stream.pipe( + Stream.decodeText(), + Stream.runFold( + () => "", + (acc, chunk) => acc + chunk, + ), + Effect.mapError((cause) => + normalizeCliError("codex", operation, cause, "Failed to collect process output"), + ), + ); + + const writeTempFile = ( + operation: string, + prefix: string, + content: string, + ): Effect.Effect => { + return Effect.gen(function* () { + const tempFileId = yield* Random.nextUUIDv4; + return yield* fileSystem + .makeTempFileScoped({ + prefix: `t3code-${prefix}-${process.pid}-${tempFileId}.tmp`, + }) + .pipe(Effect.tap((filePath) => fileSystem.writeFileString(filePath, content))); + }).pipe( + Effect.mapError( + (cause) => + new TextGenerationError({ + operation, + detail: `Failed to write temp file`, + cause, + }), + ), + ); + }; + + const safeUnlink = (filePath: string): Effect.Effect => + fileSystem.remove(filePath).pipe(Effect.catch(() => Effect.void)); + + const materializeImageAttachments = Effect.fn("materializeImageAttachments")(function* ( + _operation: + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle", + attachments: BranchNameGenerationInput["attachments"], + ): Effect.fn.Return { + if (!attachments || attachments.length === 0) { + return { imagePaths: [] }; + } + + const imagePaths: string[] = []; + for (const attachment of attachments) { + if (attachment.type !== "image") { + continue; + } + + const resolvedPath = resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment, + }); + if (!resolvedPath || !path.isAbsolute(resolvedPath)) { + continue; + } + const fileInfo = yield* fileSystem + .stat(resolvedPath) + .pipe(Effect.catch(() => Effect.succeed(null))); + if (!fileInfo || fileInfo.type !== "File") { + continue; + } + imagePaths.push(resolvedPath); + } + return { imagePaths }; + }); + + const runCodexJson = Effect.fn("runCodexJson")(function* ({ + operation, + cwd, + prompt, + outputSchemaJson, + imagePaths = [], + cleanupPaths = [], + modelSelection, + }: { + operation: + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle"; + cwd: string; + prompt: string; + outputSchemaJson: S; + imagePaths?: ReadonlyArray; + cleanupPaths?: ReadonlyArray; + modelSelection: ModelSelection; + }): Effect.fn.Return { + const schemaPath = yield* writeTempFile( + operation, + "codex-schema", + JSON.stringify(toJsonSchemaObject(outputSchemaJson)), + ); + const outputPath = yield* writeTempFile(operation, "codex-output", ""); + + const runCodexCommand = Effect.fn("runCodexJson.runCodexCommand")(function* () { + const reasoningEffort = + getModelSelectionStringOptionValue(modelSelection, "reasoningEffort") ?? + CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT; + const command = ChildProcess.make( + codexConfig.binaryPath || "codex", + [ + "exec", + "--ephemeral", + "--skip-git-repo-check", + "-s", + "read-only", + "--model", + modelSelection.model, + "--config", + `model_reasoning_effort="${reasoningEffort}"`, + ...(getModelSelectionBooleanOptionValue(modelSelection, "fastMode") === true + ? ["--config", `service_tier="fast"`] + : []), + "--output-schema", + schemaPath, + "--output-last-message", + outputPath, + ...imagePaths.flatMap((imagePath) => ["--image", imagePath]), + "-", + ], + { + env: { + ...environment, + ...(codexConfig.homePath ? { CODEX_HOME: expandHomePath(codexConfig.homePath) } : {}), + }, + cwd, + shell: process.platform === "win32", + stdin: { + stream: Stream.encodeText(Stream.make(prompt)), + }, + }, + ); + + const child = yield* commandSpawner + .spawn(command) + .pipe( + Effect.mapError((cause) => + normalizeCliError("codex", operation, cause, "Failed to spawn Codex CLI process"), + ), + ); + + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + readStreamAsString(operation, child.stdout), + readStreamAsString(operation, child.stderr), + child.exitCode.pipe( + Effect.mapError((cause) => + normalizeCliError("codex", operation, cause, "Failed to read Codex CLI exit code"), + ), + ), + ], + { concurrency: "unbounded" }, + ); + + if (exitCode !== 0) { + const stderrDetail = stderr.trim(); + const stdoutDetail = stdout.trim(); + const detail = stderrDetail.length > 0 ? stderrDetail : stdoutDetail; + return yield* new TextGenerationError({ + operation, + detail: + detail.length > 0 + ? `Codex CLI command failed: ${detail}` + : `Codex CLI command failed with code ${exitCode}.`, + }); + } + }); + + const cleanup = Effect.all( + [schemaPath, outputPath, ...cleanupPaths].map((filePath) => safeUnlink(filePath)), + { + concurrency: "unbounded", + }, + ).pipe(Effect.asVoid); + + return yield* Effect.gen(function* () { + yield* runCodexCommand().pipe( + Effect.scoped, + Effect.timeoutOption(CODEX_TIMEOUT_MS), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new TextGenerationError({ operation, detail: "Codex CLI request timed out." }), + ), + onSome: () => Effect.void, + }), + ), + ); + + return yield* fileSystem.readFileString(outputPath).pipe( + Effect.mapError( + (cause) => + new TextGenerationError({ + operation, + detail: "Failed to read Codex output file.", + cause, + }), + ), + Effect.flatMap(Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson))), + Effect.catchTag("SchemaError", (cause) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Codex returned invalid structured output.", + cause, + }), + ), + ), + ); + }).pipe(Effect.ensuring(cleanup)); + }); + + const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn( + "CodexTextGeneration.generateCommitMessage", + )(function* (input) { + const { prompt, outputSchema } = buildCommitMessagePrompt({ + branch: input.branch, + stagedSummary: input.stagedSummary, + stagedPatch: input.stagedPatch, + includeBranch: input.includeBranch === true, + }); + + const generated = yield* runCodexJson({ + operation: "generateCommitMessage", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + subject: sanitizeCommitSubject(generated.subject), + body: generated.body.trim(), + ...("branch" in generated && typeof generated.branch === "string" + ? { branch: sanitizeFeatureBranchName(generated.branch) } + : {}), + }; + }); + + const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn( + "CodexTextGeneration.generatePrContent", + )(function* (input) { + const { prompt, outputSchema } = buildPrContentPrompt({ + baseBranch: input.baseBranch, + headBranch: input.headBranch, + commitSummary: input.commitSummary, + diffSummary: input.diffSummary, + diffPatch: input.diffPatch, + }); + + const generated = yield* runCodexJson({ + operation: "generatePrContent", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + title: sanitizePrTitle(generated.title), + body: generated.body.trim(), + }; + }); + + const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn( + "CodexTextGeneration.generateBranchName", + )(function* (input) { + const { imagePaths } = yield* materializeImageAttachments( + "generateBranchName", + input.attachments, + ); + const { prompt, outputSchema } = buildBranchNamePrompt({ + message: input.message, + attachments: input.attachments, + }); + + const generated = yield* runCodexJson({ + operation: "generateBranchName", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + imagePaths, + modelSelection: input.modelSelection, + }); + + return { + branch: sanitizeBranchFragment(generated.branch), + }; + }); + + const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn( + "CodexTextGeneration.generateThreadTitle", + )(function* (input) { + const { imagePaths } = yield* materializeImageAttachments( + "generateThreadTitle", + input.attachments, + ); + const { prompt, outputSchema } = buildThreadTitlePrompt({ + message: input.message, + attachments: input.attachments, + }); + + const generated = yield* runCodexJson({ + operation: "generateThreadTitle", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + imagePaths, + modelSelection: input.modelSelection, + }); + + return { + title: sanitizeThreadTitle(generated.title), + } satisfies ThreadTitleGenerationResult; + }); + + return { + generateCommitMessage, + generatePrContent, + generateBranchName, + generateThreadTitle, + } satisfies TextGenerationShape; +}); diff --git a/apps/server/src/textGeneration/CursorTextGeneration.test.ts b/apps/server/src/textGeneration/CursorTextGeneration.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..0de135c84659f955b705599cce9b01bba26f9dd9 --- /dev/null +++ b/apps/server/src/textGeneration/CursorTextGeneration.test.ts @@ -0,0 +1,264 @@ +import * as path from "node:path"; +import * as os from "node:os"; +import { fileURLToPath } from "node:url"; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it } from "@effect/vitest"; +import { Effect, Layer, Schema } from "effect"; +import { createModelSelection } from "@t3tools/shared/model"; +import { expect } from "vitest"; + +import { CursorSettings, ProviderInstanceId } from "@t3tools/contracts"; + +import { ServerConfig } from "../config.ts"; +import { type TextGenerationShape } from "./TextGeneration.ts"; +import { makeCursorTextGeneration } from "./CursorTextGeneration.ts"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const mockAgentPath = path.join(__dirname, "../../scripts/acp-mock-agent.ts"); + +function shellSingleQuote(value: string): string { + return `'${value.replaceAll("'", `'"'"'`)}'`; +} + +const CursorTextGenerationTestLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-cursor-text-generation-test-", +}).pipe(Layer.provideMerge(NodeServices.layer)); + +function makeAcpAgentWrapper(dir: string, env: Record): string { + const binDir = path.join(dir, "bin"); + const agentPath = path.join(binDir, "agent"); + mkdirSync(binDir, { recursive: true }); + writeFileSync( + agentPath, + [ + "#!/bin/sh", + ...Object.entries(env).map(([key, value]) => `export ${key}=${shellSingleQuote(value)}`), + 'if [ "$1" != "acp" ]; then', + ' printf "%s\\n" "unexpected args: $*" >&2', + " exit 11", + "fi", + `exec bun ${JSON.stringify(mockAgentPath)}`, + "", + ].join("\n"), + "utf8", + ); + chmodSync(agentPath, 0o755); + return agentPath; +} + +function withFakeAcpAgent( + env: Record, + effectFn: (textGeneration: TextGenerationShape) => Effect.Effect, +) { + return Effect.gen(function* () { + const tempDir = mkdtempSync(path.join(os.tmpdir(), "t3code-cursor-text-acp-")); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + rmSync(tempDir, { recursive: true, force: true }); + }), + ); + const agentPath = makeAcpAgentWrapper(tempDir, env); + const config = Schema.decodeSync(CursorSettings)({ binaryPath: agentPath }); + const textGeneration = yield* makeCursorTextGeneration(config); + return yield* effectFn(textGeneration); + }).pipe(Effect.scoped); +} + +function waitForFileContent(path: string): Effect.Effect { + return Effect.promise(async () => { + const deadline = Date.now() + 5_000; + for (;;) { + try { + return readFileSync(path, "utf8"); + } catch (error) { + if (Date.now() >= deadline) { + throw error instanceof Error ? error : new Error(String(error)); + } + } + await new Promise((resolve) => setTimeout(resolve, 25)); + } + }); +} + +it.layer(CursorTextGenerationTestLayer)("CursorTextGeneration", (it) => { + it.effect("uses ACP model config options instead of raw CLI model ids", () => { + const requestLogDir = mkdtempSync(path.join(os.tmpdir(), "t3code-cursor-text-log-")); + const requestLogPath = path.join(requestLogDir, "requests.ndjson"); + + return withFakeAcpAgent( + { + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({ + subject: "Add generated commit message", + body: "- verify cursor acp model config path", + }), + }, + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/cursor-text-generation", + stagedSummary: "M apps/server/src/textGeneration/CursorTextGeneration.ts", + stagedPatch: + "diff --git a/apps/server/src/textGeneration/CursorTextGeneration.ts b/apps/server/src/textGeneration/CursorTextGeneration.ts", + modelSelection: { + ...createModelSelection(ProviderInstanceId.make("cursor"), "gpt-5.4", [ + { id: "reasoning", value: "xhigh" }, + { id: "fastMode", value: true }, + { id: "contextWindow", value: "1m" }, + ]), + }, + }); + + expect(generated.subject).toBe("Add generated commit message"); + expect(generated.body).toBe("- verify cursor acp model config path"); + + const requests = readFileSync(requestLogPath, "utf8") + .trim() + .split("\n") + .filter((line) => line.length > 0) + .map( + (line) => JSON.parse(line) as { method?: string; params?: Record }, + ); + + expect( + requests.find((request) => request.method === "initialize")?.params?.clientCapabilities, + ).toMatchObject({ + _meta: { + parameterizedModelPicker: true, + }, + }); + expect( + requests.some( + (request) => + request.method === "session/set_config_option" && + request.params?.configId === "model" && + request.params?.value === "gpt-5.4", + ), + ).toBe(true); + expect( + requests.some( + (request) => + request.method === "session/set_config_option" && + request.params?.configId === "reasoning" && + request.params?.value === "extra-high", + ), + ).toBe(true); + expect( + requests.some( + (request) => + request.method === "session/set_config_option" && + request.params?.configId === "context" && + request.params?.value === "1m", + ), + ).toBe(true); + expect( + requests.some( + (request) => + request.method === "session/set_config_option" && + request.params?.configId === "fast" && + request.params?.value === "true", + ), + ).toBe(true); + expect( + requests.find((request) => request.method === "session/prompt")?.params?.prompt, + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "text", + text: expect.stringContaining("Staged patch:"), + }), + ]), + ); + + rmSync(requestLogDir, { recursive: true, force: true }); + }), + ); + }); + + it.effect("accepts json objects with extra assistant text around them", () => + withFakeAcpAgent( + { + T3_ACP_PROMPT_RESPONSE_TEXT: + 'Sure, here is the JSON:\n```json\n{\n "subject": "Update README dummy comment with attribution and date",\n "body": ""\n}\n```\nDone.', + }, + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/cursor-noisy-json", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: { + instanceId: ProviderInstanceId.make("cursor"), + model: "composer-2", + }, + }); + + expect(generated.subject).toBe("Update README dummy comment with attribution and date"); + expect(generated.body).toBe(""); + }), + ), + ); + + it.effect("generates thread titles through Cursor ACP text generation", () => + withFakeAcpAgent( + { + T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({ + title: '"Trim reconnect spinner status after resume."', + }), + }, + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "Fix the reconnect spinner after a resumed session.", + modelSelection: { + instanceId: ProviderInstanceId.make("cursor"), + model: "composer-2", + }, + }); + + expect(generated.title).toBe("Trim reconnect spinner status after resume."); + }), + ), + ); + + it.effect("closes the ACP child process after text generation completes", () => { + const exitLogDir = mkdtempSync(path.join(os.tmpdir(), "t3code-cursor-text-exit-log-")); + const exitLogPath = path.join(exitLogDir, "exit.log"); + + return withFakeAcpAgent( + { + T3_ACP_EXIT_LOG_PATH: exitLogPath, + T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({ + subject: "Close runtime after generation", + body: "", + }), + }, + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/cursor-runtime-close", + stagedSummary: "M apps/server/src/textGeneration/CursorTextGeneration.ts", + stagedPatch: + "diff --git a/apps/server/src/textGeneration/CursorTextGeneration.ts b/apps/server/src/textGeneration/CursorTextGeneration.ts", + modelSelection: { + instanceId: ProviderInstanceId.make("cursor"), + model: "composer-2", + }, + }); + + expect(generated.subject).toBe("Close runtime after generation"); + + const exitLog = yield* waitForFileContent(exitLogPath); + expect(exitLog).toContain("exit:0"); + + rmSync(exitLogDir, { recursive: true, force: true }); + }), + ); + }); +}); diff --git a/apps/server/src/textGeneration/CursorTextGeneration.ts b/apps/server/src/textGeneration/CursorTextGeneration.ts new file mode 100644 index 0000000000000000000000000000000000000000..1cde82d61b63d80d2fd66e6f6c79a9d6d932d77d --- /dev/null +++ b/apps/server/src/textGeneration/CursorTextGeneration.ts @@ -0,0 +1,276 @@ +import { Effect, Option, Ref, Schema } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { type CursorSettings, type ModelSelection } from "@t3tools/contracts"; +import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; + +import { TextGenerationError } from "@t3tools/contracts"; +import { type ThreadTitleGenerationResult, type TextGenerationShape } from "./TextGeneration.ts"; +import { + buildBranchNamePrompt, + buildCommitMessagePrompt, + buildPrContentPrompt, + buildThreadTitlePrompt, +} from "./TextGenerationPrompts.ts"; +import { + extractJsonObject, + sanitizeCommitSubject, + sanitizePrTitle, + sanitizeThreadTitle, +} from "./TextGenerationUtils.ts"; +import { + applyCursorAcpModelSelection, + makeCursorAcpRuntime, +} from "../provider/acp/CursorAcpSupport.ts"; + +const CURSOR_TIMEOUT_MS = 180_000; + +function mapCursorAcpError( + operation: + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle", + detail: string, + cause: unknown, +): TextGenerationError { + return new TextGenerationError({ + operation, + detail, + ...(cause !== undefined ? { cause } : {}), + }); +} + +function isTextGenerationError(error: unknown): error is TextGenerationError { + return ( + typeof error === "object" && + error !== null && + "_tag" in error && + error._tag === "TextGenerationError" + ); +} + +/** + * Build a Cursor text-generation closure bound to a specific `CursorSettings` + * payload. See `makeCodexAdapter` for the overall per-instance rationale. + */ +export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(function* ( + cursorSettings: CursorSettings, + environment: NodeJS.ProcessEnv = process.env, +) { + const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + + const runCursorJson = ({ + operation, + cwd, + prompt, + outputSchemaJson, + modelSelection, + }: { + operation: + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle"; + cwd: string; + prompt: string; + outputSchemaJson: S; + modelSelection: ModelSelection; + }): Effect.Effect => + Effect.gen(function* () { + const outputRef = yield* Ref.make(""); + const runtime = yield* makeCursorAcpRuntime({ + cursorSettings, + environment, + childProcessSpawner: commandSpawner, + cwd, + clientInfo: { name: "t3-code-git-text", version: "0.0.0" }, + }); + + yield* runtime.handleSessionUpdate((notification) => { + const update = notification.update; + if (update.sessionUpdate !== "agent_message_chunk") { + return Effect.void; + } + const content = update.content; + if (content.type !== "text") { + return Effect.void; + } + return Ref.update(outputRef, (current) => current + content.text); + }); + + const promptResult = yield* Effect.gen(function* () { + yield* runtime.start(); + yield* Effect.ignore(runtime.setMode("ask")); + yield* applyCursorAcpModelSelection({ + runtime, + model: modelSelection.model, + selections: modelSelection.options, + mapError: ({ cause, configId, step }) => + mapCursorAcpError( + operation, + step === "set-config-option" + ? `Failed to set Cursor ACP config option "${configId}" for text generation.` + : "Failed to set Cursor ACP base model for text generation.", + cause, + ), + }); + + return yield* runtime.prompt({ + prompt: [{ type: "text", text: prompt }], + }); + }).pipe( + Effect.timeoutOption(CURSOR_TIMEOUT_MS), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Cursor Agent request timed out.", + }), + ), + onSome: (value) => Effect.succeed(value), + }), + ), + Effect.mapError((cause) => + isTextGenerationError(cause) + ? cause + : mapCursorAcpError(operation, "Cursor ACP request failed.", cause), + ), + ); + + const rawResult = (yield* Ref.get(outputRef)).trim(); + if (!rawResult) { + return yield* new TextGenerationError({ + operation, + detail: + promptResult.stopReason === "cancelled" + ? "Cursor ACP request was cancelled." + : "Cursor Agent returned empty output.", + }); + } + + return yield* Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson))( + extractJsonObject(rawResult), + ).pipe( + Effect.catchTag("SchemaError", (cause) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Cursor Agent returned invalid structured output.", + cause, + }), + ), + ), + ); + }).pipe( + Effect.mapError((cause) => + isTextGenerationError(cause) + ? cause + : mapCursorAcpError(operation, "Cursor ACP text generation failed.", cause), + ), + Effect.scoped, + ); + + const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn( + "CursorTextGeneration.generateCommitMessage", + )(function* (input) { + const { prompt, outputSchema } = buildCommitMessagePrompt({ + branch: input.branch, + stagedSummary: input.stagedSummary, + stagedPatch: input.stagedPatch, + includeBranch: input.includeBranch === true, + }); + + const generated = yield* runCursorJson({ + operation: "generateCommitMessage", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + subject: sanitizeCommitSubject(generated.subject), + body: generated.body.trim(), + ...("branch" in generated && typeof generated.branch === "string" + ? { branch: sanitizeFeatureBranchName(generated.branch) } + : {}), + }; + }); + + const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn( + "CursorTextGeneration.generatePrContent", + )(function* (input) { + const { prompt, outputSchema } = buildPrContentPrompt({ + baseBranch: input.baseBranch, + headBranch: input.headBranch, + commitSummary: input.commitSummary, + diffSummary: input.diffSummary, + diffPatch: input.diffPatch, + }); + + const generated = yield* runCursorJson({ + operation: "generatePrContent", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + title: sanitizePrTitle(generated.title), + body: generated.body.trim(), + }; + }); + + const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn( + "CursorTextGeneration.generateBranchName", + )(function* (input) { + const { prompt, outputSchema } = buildBranchNamePrompt({ + message: input.message, + attachments: input.attachments, + }); + + const generated = yield* runCursorJson({ + operation: "generateBranchName", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + branch: sanitizeBranchFragment(generated.branch), + }; + }); + + const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn( + "CursorTextGeneration.generateThreadTitle", + )(function* (input) { + const { prompt, outputSchema } = buildThreadTitlePrompt({ + message: input.message, + attachments: input.attachments, + }); + + const generated = yield* runCursorJson({ + operation: "generateThreadTitle", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + title: sanitizeThreadTitle(generated.title), + } satisfies ThreadTitleGenerationResult; + }); + + return { + generateCommitMessage, + generatePrContent, + generateBranchName, + generateThreadTitle, + } satisfies TextGenerationShape; +}); diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..907c749355fa66554ae98332eba636eaadf653f0 --- /dev/null +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts @@ -0,0 +1,346 @@ +import { OpenCodeSettings, ProviderInstanceId } from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it } from "@effect/vitest"; +import { Duration, Effect, Layer, Schema } from "effect"; +import { TestClock } from "effect/testing"; +import { NetService } from "@t3tools/shared/Net"; +import { beforeEach, expect } from "vitest"; + +import { ServerConfig } from "../config.ts"; +import { + OpenCodeRuntime, + OpenCodeRuntimeError, + type OpenCodeRuntimeShape, +} from "../provider/opencodeRuntime.ts"; +import { type TextGenerationShape } from "./TextGeneration.ts"; +import { makeOpenCodeTextGeneration } from "./OpenCodeTextGeneration.ts"; + +const runtimeMock = { + state: { + startCalls: [] as string[], + promptUrls: [] as string[], + authHeaders: [] as Array, + closeCalls: [] as string[], + promptResult: undefined as + | { data?: { info?: { error?: unknown }; parts?: Array<{ type: string; text?: string }> } } + | undefined, + }, + reset() { + this.state.startCalls.length = 0; + this.state.promptUrls.length = 0; + this.state.authHeaders.length = 0; + this.state.closeCalls.length = 0; + this.state.promptResult = undefined; + }, +}; + +const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { + startOpenCodeServerProcess: ({ binaryPath }) => + Effect.gen(function* () { + const index = runtimeMock.state.startCalls.length + 1; + const url = `http://127.0.0.1:${4_300 + index}`; + runtimeMock.state.startCalls.push(binaryPath); + // The production runtime binds server lifetime to the caller's scope. + // Mirror that here so the closeCalls probe observes scope close. + yield* Effect.addFinalizer(() => + Effect.sync(() => { + runtimeMock.state.closeCalls.push(url); + }), + ); + return { + url, + exitCode: Effect.never, + }; + }), + connectToOpenCodeServer: ({ serverUrl }) => + Effect.succeed({ + url: serverUrl ?? "http://127.0.0.1:4301", + exitCode: null, + external: Boolean(serverUrl), + }), + runOpenCodeCommand: () => Effect.succeed({ stdout: "", stderr: "", code: 0 }), + createOpenCodeSdkClient: ({ baseUrl, serverPassword }) => + ({ + session: { + create: async () => ({ data: { id: `${baseUrl}/session` } }), + prompt: async () => { + runtimeMock.state.promptUrls.push(baseUrl); + runtimeMock.state.authHeaders.push( + serverPassword ? `Basic ${btoa(`opencode:${serverPassword}`)}` : null, + ); + return ( + runtimeMock.state.promptResult ?? { + data: { + parts: [ + { + type: "text", + text: JSON.stringify({ + subject: "Improve OpenCode reuse", + body: "Reuse one server for the full action.", + }), + }, + ], + }, + } + ); + }, + }, + }) as unknown as ReturnType, + loadOpenCodeInventory: () => + Effect.fail( + new OpenCodeRuntimeError({ + operation: "loadOpenCodeInventory", + detail: "OpenCodeRuntimeTestDouble.loadOpenCodeInventory not used in this test", + cause: null, + }), + ), +}; + +const DEFAULT_TEST_MODEL_SELECTION = { + instanceId: ProviderInstanceId.make("opencode"), + model: "openai/gpt-5", +}; + +const OPENCODE_TEXT_GENERATION_IDLE_TTL_MS = 30_000; + +const OpenCodeTextGenerationTestLayer = Layer.succeed( + OpenCodeRuntime, + OpenCodeRuntimeTestDouble, +).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-opencode-text-generation-test-", + }), + ), + Layer.provideMerge(NetService.layer), + Layer.provideMerge(NodeServices.layer), +); + +const OpenCodeTextGenerationExistingServerTestLayer = Layer.succeed( + OpenCodeRuntime, + OpenCodeRuntimeTestDouble, +).pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-opencode-text-generation-existing-server-test-", + }), + ), + Layer.provideMerge(NetService.layer), + Layer.provideMerge(NodeServices.layer), +); + +const DEFAULT_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({ + binaryPath: "fake-opencode", +}); +const EXISTING_SERVER_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({ + binaryPath: "fake-opencode", + serverUrl: "http://127.0.0.1:9999", + serverPassword: "secret-password", +}); + +function withOpenCodeTextGeneration( + settings: OpenCodeSettings, + effectFn: (textGeneration: TextGenerationShape) => Effect.Effect, +) { + return Effect.gen(function* () { + const textGeneration = yield* makeOpenCodeTextGeneration(settings); + return yield* effectFn(textGeneration); + }).pipe(Effect.scoped); +} + +beforeEach(() => { + runtimeMock.reset(); +}); + +const advanceIdleClock = Effect.gen(function* () { + yield* Effect.yieldNow; + yield* TestClock.adjust(Duration.millis(OPENCODE_TEXT_GENERATION_IDLE_TTL_MS + 1)); + yield* Effect.yieldNow; +}); + +it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGeneration", (it) => { + it.effect("reuses a warm server across back-to-back requests and closes it after idling", () => + withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/opencode-reuse", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); + yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/opencode-reuse", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); + + expect(runtimeMock.state.startCalls).toEqual(["fake-opencode"]); + expect(runtimeMock.state.promptUrls).toEqual([ + "http://127.0.0.1:4301", + "http://127.0.0.1:4301", + ]); + expect(runtimeMock.state.closeCalls).toEqual([]); + + yield* advanceIdleClock; + + expect(runtimeMock.state.closeCalls).toEqual(["http://127.0.0.1:4301"]); + }), + ).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("starts a new server after the warm server idles out", () => + withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/opencode-reuse", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); + + yield* advanceIdleClock; + + yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/opencode-reuse", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); + + expect(runtimeMock.state.startCalls).toEqual(["fake-opencode", "fake-opencode"]); + expect(runtimeMock.state.promptUrls).toEqual([ + "http://127.0.0.1:4301", + "http://127.0.0.1:4302", + ]); + expect(runtimeMock.state.closeCalls).toEqual(["http://127.0.0.1:4301"]); + }), + ).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("returns a typed empty-output error when OpenCode returns no text parts", () => + withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + runtimeMock.state.promptResult = { data: {} }; + + const error = yield* textGeneration + .generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/opencode-reuse", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }) + .pipe(Effect.flip); + + expect(error.message).toContain("OpenCode returned empty output."); + }), + ), + ); + + it.effect("parses JSON returned as plain text output", () => + withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + runtimeMock.state.promptResult = { + data: { + parts: [ + { + type: "text", + text: 'Here is the result:\n{"subject":"Tighten OpenCode parsing","body":"Handle JSON text output locally."}', + }, + ], + }, + }; + + const result = yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/opencode-reuse", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); + + expect(result).toEqual({ + subject: "Tighten OpenCode parsing", + body: "Handle JSON text output locally.", + }); + }), + ), + ); + + it.effect("surfaces the upstream OpenCode structured-output error message", () => + withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + runtimeMock.state.promptResult = { + data: { + info: { + error: { + name: "StructuredOutputError", + data: { + message: "Model did not produce structured output", + retries: 2, + }, + }, + }, + }, + }; + + const error = yield* textGeneration + .generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/opencode-reuse", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }) + .pipe(Effect.flip); + + expect(error.message).toContain("Model did not produce structured output"); + }), + ), + ); +}); + +it.layer(OpenCodeTextGenerationExistingServerTestLayer)( + "OpenCodeTextGeneration with configured server URL", + (it) => { + it.effect("reuses a configured OpenCode server URL without spawning or applying idle TTL", () => + withOpenCodeTextGeneration(EXISTING_SERVER_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/opencode-reuse", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); + yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/opencode-reuse", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); + + expect(runtimeMock.state.startCalls).toEqual([]); + expect(runtimeMock.state.promptUrls).toEqual([ + "http://127.0.0.1:9999", + "http://127.0.0.1:9999", + ]); + expect(runtimeMock.state.authHeaders).toEqual([ + `Basic ${btoa("opencode:secret-password")}`, + `Basic ${btoa("opencode:secret-password")}`, + ]); + + yield* advanceIdleClock; + + expect(runtimeMock.state.closeCalls).toEqual([]); + }), + ).pipe(Effect.provide(TestClock.layer())), + ); + }, +); diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts new file mode 100644 index 0000000000000000000000000000000000000000..d646e4f2e5ae5ab9ae7901cbdcf0f0ad54cc4092 --- /dev/null +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts @@ -0,0 +1,464 @@ +import { Effect, Exit, Fiber, Schema, Scope } from "effect"; +import * as Semaphore from "effect/Semaphore"; + +import { + TextGenerationError, + type ChatAttachment, + type ModelSelection, + type OpenCodeSettings, +} from "@t3tools/contracts"; +import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; +import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; + +import { ServerConfig } from "../config.ts"; +import { resolveAttachmentPath } from "../attachmentStore.ts"; +import { + buildBranchNamePrompt, + buildCommitMessagePrompt, + buildPrContentPrompt, + buildThreadTitlePrompt, +} from "./TextGenerationPrompts.ts"; +import { type TextGenerationShape } from "./TextGeneration.ts"; +import { + extractJsonObject, + sanitizeCommitSubject, + sanitizePrTitle, + sanitizeThreadTitle, +} from "./TextGenerationUtils.ts"; +import { + OpenCodeRuntime, + type OpenCodeServerConnection, + type OpenCodeServerProcess, + openCodeRuntimeErrorDetail, + parseOpenCodeModelSlug, + toOpenCodeFileParts, +} from "../provider/opencodeRuntime.ts"; + +const OPENCODE_TEXT_GENERATION_IDLE_TTL = "30 seconds"; + +function getOpenCodePromptErrorMessage(error: unknown): string | null { + if (!error || typeof error !== "object") { + return null; + } + + const message = + "data" in error && + error.data && + typeof error.data === "object" && + "message" in error.data && + typeof error.data.message === "string" + ? error.data.message.trim() + : ""; + if (message.length > 0) { + return message; + } + + if ("name" in error && typeof error.name === "string") { + const name = error.name.trim(); + return name.length > 0 ? name : null; + } + + return null; +} + +function getOpenCodeTextResponse(parts: ReadonlyArray | undefined): string { + return (parts ?? []) + .flatMap((part) => { + if (!part || typeof part !== "object") { + return []; + } + if (!("type" in part) || part.type !== "text") { + return []; + } + if (!("text" in part) || typeof part.text !== "string") { + return []; + } + return [part.text]; + }) + .join("") + .trim(); +} + +interface SharedOpenCodeTextGenerationServerState { + server: OpenCodeServerProcess | null; + /** + * The scope that owns the shared server's lifetime. Closing this scope + * terminates the OpenCode child process and interrupts any fibers the + * runtime forked during startup. We don't hold a `close()` function on + * the server handle anymore — the scope is the only lifecycle handle. + */ + serverScope: Scope.Closeable | null; + binaryPath: string | null; + activeRequests: number; + idleCloseFiber: Fiber.Fiber | null; +} + +export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration")(function* ( + openCodeSettings: OpenCodeSettings, + environment: NodeJS.ProcessEnv = process.env, +) { + const serverConfig = yield* ServerConfig; + const openCodeRuntime = yield* OpenCodeRuntime; + const idleFiberScope = yield* Effect.acquireRelease(Scope.make(), (scope) => + Scope.close(scope, Exit.void), + ); + const sharedServerMutex = yield* Semaphore.make(1); + const sharedServerState: SharedOpenCodeTextGenerationServerState = { + server: null, + serverScope: null, + binaryPath: null, + activeRequests: 0, + idleCloseFiber: null, + }; + + const closeSharedServer = Effect.fn("closeSharedServer")(function* () { + const scope = sharedServerState.serverScope; + sharedServerState.server = null; + sharedServerState.serverScope = null; + sharedServerState.binaryPath = null; + if (scope !== null) { + yield* Scope.close(scope, Exit.void).pipe(Effect.ignore); + } + }); + + const cancelIdleCloseFiber = Effect.fn("cancelIdleCloseFiber")(function* () { + const idleCloseFiber = sharedServerState.idleCloseFiber; + sharedServerState.idleCloseFiber = null; + if (idleCloseFiber !== null) { + yield* Fiber.interrupt(idleCloseFiber).pipe(Effect.ignore); + } + }); + + const scheduleIdleClose = Effect.fn("scheduleIdleClose")(function* ( + server: OpenCodeServerProcess, + ) { + yield* cancelIdleCloseFiber(); + const fiber = yield* Effect.sleep(OPENCODE_TEXT_GENERATION_IDLE_TTL).pipe( + Effect.andThen( + sharedServerMutex.withPermit( + Effect.gen(function* () { + if (sharedServerState.server !== server || sharedServerState.activeRequests > 0) { + return; + } + sharedServerState.idleCloseFiber = null; + yield* closeSharedServer(); + }), + ), + ), + Effect.forkIn(idleFiberScope), + ); + sharedServerState.idleCloseFiber = fiber; + }); + + const acquireSharedServer = (input: { + readonly binaryPath: string; + readonly operation: + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle"; + }) => + sharedServerMutex.withPermit( + Effect.gen(function* () { + yield* cancelIdleCloseFiber(); + + const existingServer = sharedServerState.server; + if (existingServer !== null) { + if ( + sharedServerState.binaryPath !== input.binaryPath && + sharedServerState.activeRequests === 0 + ) { + yield* closeSharedServer(); + } else { + if (sharedServerState.binaryPath !== input.binaryPath) { + yield* Effect.logWarning( + "OpenCode shared server binary path mismatch: requested " + + input.binaryPath + + " but active server uses " + + sharedServerState.binaryPath + + "; reusing existing server because there are active requests", + ); + } + sharedServerState.activeRequests += 1; + return existingServer; + } + } + + // Create a fresh scope that owns this shared server. The runtime + // will attach its child-process and fiber finalizers to this scope; + // closing it kills the server and interrupts those fibers. + // + // The `Scope.make` / spawn / record-or-close transitions run inside + // `uninterruptibleMask` so an interrupt arriving between any two + // steps can't orphan the scope (and the child process attached to + // it) before we either close it on failure or hand ownership to + // `sharedServerState`. `restore` keeps the actual spawn + // interruptible; an interrupt during the spawn is captured by + // `Effect.exit` and drives us through the failure branch that + // closes the fresh scope. + return yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const serverScope = yield* Scope.make(); + const startedExit = yield* Effect.exit( + restore( + openCodeRuntime + .startOpenCodeServerProcess({ + binaryPath: input.binaryPath, + environment, + }) + .pipe( + Effect.provideService(Scope.Scope, serverScope), + Effect.mapError( + (cause) => + new TextGenerationError({ + operation: input.operation, + detail: openCodeRuntimeErrorDetail(cause), + cause, + }), + ), + ), + ), + ); + if (startedExit._tag === "Failure") { + yield* Scope.close(serverScope, Exit.void).pipe(Effect.ignore); + return yield* Effect.failCause(startedExit.cause); + } + + const server = startedExit.value; + sharedServerState.server = server; + sharedServerState.serverScope = serverScope; + sharedServerState.binaryPath = input.binaryPath; + sharedServerState.activeRequests = 1; + return server; + }), + ); + }), + ); + + const releaseSharedServer = (server: OpenCodeServerProcess) => + sharedServerMutex.withPermit( + Effect.gen(function* () { + if (sharedServerState.server !== server) { + return; + } + sharedServerState.activeRequests = Math.max(0, sharedServerState.activeRequests - 1); + if (sharedServerState.activeRequests === 0) { + yield* scheduleIdleClose(server); + } + }), + ); + + // Module-level finalizer: on layer shutdown, cancel the idle close fiber + // and close the shared server scope. Consumers therefore cannot leak + // the shared OpenCode server by forgetting to call anything. + yield* Effect.addFinalizer(() => + sharedServerMutex.withPermit( + Effect.gen(function* () { + yield* cancelIdleCloseFiber(); + sharedServerState.activeRequests = 0; + yield* closeSharedServer(); + }), + ), + ); + + const runOpenCodeJson = Effect.fn("runOpenCodeJson")(function* (input: { + readonly operation: + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle"; + readonly cwd: string; + readonly prompt: string; + readonly outputSchemaJson: S; + readonly modelSelection: ModelSelection; + readonly attachments?: ReadonlyArray | undefined; + }) { + const parsedModel = parseOpenCodeModelSlug(input.modelSelection.model); + if (!parsedModel) { + return yield* new TextGenerationError({ + operation: input.operation, + detail: "OpenCode model selection must use the 'provider/model' format.", + }); + } + + const fileParts = toOpenCodeFileParts({ + attachments: input.attachments, + resolveAttachmentPath: (attachment) => + resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }), + }); + + const runAgainstServer = (server: Pick) => + Effect.tryPromise({ + try: async () => { + const client = openCodeRuntime.createOpenCodeSdkClient({ + baseUrl: server.url, + directory: input.cwd, + ...(openCodeSettings.serverUrl.length > 0 && openCodeSettings.serverPassword + ? { serverPassword: openCodeSettings.serverPassword } + : {}), + }); + const session = await client.session.create({ + title: `T3 Code ${input.operation}`, + permission: [{ permission: "*", pattern: "*", action: "deny" }], + }); + if (!session.data) { + throw new Error("OpenCode session.create returned no session payload."); + } + const selectedAgent = getModelSelectionStringOptionValue(input.modelSelection, "agent"); + const selectedVariant = getModelSelectionStringOptionValue( + input.modelSelection, + "variant", + ); + + const result = await client.session.prompt({ + sessionID: session.data.id, + model: parsedModel, + ...(selectedAgent ? { agent: selectedAgent } : {}), + ...(selectedVariant ? { variant: selectedVariant } : {}), + parts: [{ type: "text", text: input.prompt }, ...fileParts], + }); + const info = result.data?.info; + const errorMessage = getOpenCodePromptErrorMessage(info?.error); + if (errorMessage) { + throw new Error(errorMessage); + } + const rawText = getOpenCodeTextResponse(result.data?.parts); + if (rawText.length === 0) { + throw new Error("OpenCode returned empty output."); + } + return rawText; + }, + catch: (cause) => + new TextGenerationError({ + operation: input.operation, + detail: openCodeRuntimeErrorDetail(cause), + cause, + }), + }); + + const rawOutput = + openCodeSettings.serverUrl.length > 0 + ? yield* runAgainstServer({ url: openCodeSettings.serverUrl }) + : yield* Effect.acquireUseRelease( + acquireSharedServer({ + binaryPath: openCodeSettings.binaryPath, + operation: input.operation, + }), + runAgainstServer, + releaseSharedServer, + ); + + return yield* Schema.decodeEffect(Schema.fromJsonString(input.outputSchemaJson))( + extractJsonObject(rawOutput), + ).pipe( + Effect.catchTag("SchemaError", (cause) => + Effect.fail( + new TextGenerationError({ + operation: input.operation, + detail: "OpenCode returned invalid structured output.", + cause, + }), + ), + ), + ); + }); + + const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn( + "OpenCodeTextGeneration.generateCommitMessage", + )(function* (input) { + const { prompt, outputSchema } = buildCommitMessagePrompt({ + branch: input.branch, + stagedSummary: input.stagedSummary, + stagedPatch: input.stagedPatch, + includeBranch: input.includeBranch === true, + }); + const generated = yield* runOpenCodeJson({ + operation: "generateCommitMessage", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + subject: sanitizeCommitSubject(generated.subject), + body: generated.body.trim(), + ...("branch" in generated && typeof generated.branch === "string" + ? { branch: sanitizeFeatureBranchName(generated.branch) } + : {}), + }; + }); + + const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn( + "OpenCodeTextGeneration.generatePrContent", + )(function* (input) { + const { prompt, outputSchema } = buildPrContentPrompt({ + baseBranch: input.baseBranch, + headBranch: input.headBranch, + commitSummary: input.commitSummary, + diffSummary: input.diffSummary, + diffPatch: input.diffPatch, + }); + const generated = yield* runOpenCodeJson({ + operation: "generatePrContent", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + title: sanitizePrTitle(generated.title), + body: generated.body.trim(), + }; + }); + + const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn( + "OpenCodeTextGeneration.generateBranchName", + )(function* (input) { + const { prompt, outputSchema } = buildBranchNamePrompt({ + message: input.message, + attachments: input.attachments, + }); + const generated = yield* runOpenCodeJson({ + operation: "generateBranchName", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + attachments: input.attachments, + }); + + return { + branch: sanitizeBranchFragment(generated.branch), + }; + }); + + const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn( + "OpenCodeTextGeneration.generateThreadTitle", + )(function* (input) { + const { prompt, outputSchema } = buildThreadTitlePrompt({ + message: input.message, + attachments: input.attachments, + }); + const generated = yield* runOpenCodeJson({ + operation: "generateThreadTitle", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + attachments: input.attachments, + }); + + return { + title: sanitizeThreadTitle(generated.title), + }; + }); + + return { + generateCommitMessage, + generatePrContent, + generateBranchName, + generateThreadTitle, + } satisfies TextGenerationShape; +}); diff --git a/apps/server/src/textGeneration/TextGeneration.test.ts b/apps/server/src/textGeneration/TextGeneration.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..4f3d44f92589c00c0f7b439640a86c048a422710 --- /dev/null +++ b/apps/server/src/textGeneration/TextGeneration.test.ts @@ -0,0 +1,117 @@ +import { it } from "@effect/vitest"; +import { Effect, PubSub, Result, Stream } from "effect"; +import { describe, expect } from "vitest"; + +import { ProviderInstanceId } from "@t3tools/contracts"; +import { createModelSelection } from "@t3tools/shared/model"; + +import type { ProviderInstance } from "../provider/ProviderDriver.ts"; +import type { ProviderInstanceRegistryShape } from "../provider/Services/ProviderInstanceRegistry.ts"; +import type { TextGenerationShape } from "./TextGeneration.ts"; + +import { makeTextGenerationFromRegistry } from "./TextGeneration.ts"; + +const makeStubTextGeneration = (overrides: Partial): TextGenerationShape => ({ + generateCommitMessage: () => + Effect.die("generateCommitMessage stub not configured for this test"), + generatePrContent: () => Effect.die("generatePrContent stub not configured for this test"), + generateBranchName: () => Effect.die("generateBranchName stub not configured for this test"), + generateThreadTitle: () => Effect.die("generateThreadTitle stub not configured for this test"), + ...overrides, +}); + +const makeStubInstance = ( + instanceId: ProviderInstanceId, + textGeneration: TextGenerationShape, +): ProviderInstance => + ({ + instanceId, + driverKind: instanceId as unknown as ProviderInstance["driverKind"], + continuationIdentity: { + driverKind: instanceId as unknown as ProviderInstance["driverKind"], + continuationKey: `${instanceId}:test`, + }, + displayName: undefined, + enabled: true, + snapshot: {} as ProviderInstance["snapshot"], + adapter: {} as ProviderInstance["adapter"], + textGeneration, + }) satisfies ProviderInstance; + +const makeStubRegistry = ( + instances: ReadonlyArray, +): ProviderInstanceRegistryShape => { + const byId = new Map(instances.map((instance) => [instance.instanceId, instance] as const)); + return { + getInstance: (id) => Effect.succeed(byId.get(id)), + listInstances: Effect.succeed(instances), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + // Tests never drive changes through this stub; acquire a throwaway + // subscription on an unused PubSub so the shape is satisfied. + subscribeChanges: Effect.flatMap(PubSub.unbounded(), (pubsub) => + PubSub.subscribe(pubsub), + ), + }; +}; + +describe("makeTextGenerationFromRegistry", () => { + it.effect("delegates to the matching instance's textGeneration closure", () => + Effect.gen(function* () { + const personalId = ProviderInstanceId.make("codex_personal"); + const personalCalls: string[] = []; + const personal = makeStubInstance( + personalId, + makeStubTextGeneration({ + generateBranchName: (input) => { + personalCalls.push(input.message); + return Effect.succeed({ branch: "personal-branch" }); + }, + }), + ); + + const workId = ProviderInstanceId.make("codex_work"); + const work = makeStubInstance( + workId, + makeStubTextGeneration({ + generateBranchName: () => Effect.succeed({ branch: "work-branch" }), + }), + ); + + const tg = makeTextGenerationFromRegistry(makeStubRegistry([personal, work])); + + const result = yield* tg.generateBranchName({ + cwd: process.cwd(), + message: "Refactor the routing layer", + modelSelection: createModelSelection(ProviderInstanceId.make("codex_personal"), "gpt-5"), + }); + + expect(result.branch).toBe("personal-branch"); + expect(personalCalls).toEqual(["Refactor the routing layer"]); + }), + ); + + it.effect("fails with TextGenerationError when the instance is unknown", () => + Effect.gen(function* () { + const tg = makeTextGenerationFromRegistry(makeStubRegistry([])); + + const result = yield* tg + .generateBranchName({ + cwd: process.cwd(), + message: "anything", + modelSelection: createModelSelection( + ProviderInstanceId.make("missing_instance"), + "gpt-5", + ), + }) + .pipe(Effect.result); + + expect(Result.isFailure(result)).toBe(true); + if (Result.isFailure(result)) { + expect(result.failure._tag).toBe("TextGenerationError"); + expect(result.failure.operation).toBe("generateBranchName"); + expect(result.failure.detail).toContain("missing_instance"); + } + }), + ); +}); diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts new file mode 100644 index 0000000000000000000000000000000000000000..51796faf8a74244e44e971ecb9a00c006301192c --- /dev/null +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -0,0 +1,171 @@ +import { Context, Effect, Layer } from "effect"; +import type { ChatAttachment, ModelSelection, ProviderInstanceId } from "@t3tools/contracts"; +import { TextGenerationError } from "@t3tools/contracts"; + +import { + ProviderInstanceRegistry, + type ProviderInstanceRegistryShape, +} from "../provider/Services/ProviderInstanceRegistry.ts"; +import type { ProviderInstance } from "../provider/ProviderDriver.ts"; + +export type TextGenerationProvider = "codex" | "claudeAgent" | "cursor" | "opencode"; + +export interface CommitMessageGenerationInput { + cwd: string; + branch: string | null; + stagedSummary: string; + stagedPatch: string; + /** When true, the model also returns a semantic branch name for the change. */ + includeBranch?: boolean; + /** What model and provider to use for generation. */ + modelSelection: ModelSelection; +} + +export interface CommitMessageGenerationResult { + subject: string; + body: string; + /** Only present when `includeBranch` was set on the input. */ + branch?: string | undefined; +} + +export interface PrContentGenerationInput { + cwd: string; + baseBranch: string; + headBranch: string; + commitSummary: string; + diffSummary: string; + diffPatch: string; + /** What model and provider to use for generation. */ + modelSelection: ModelSelection; +} + +export interface PrContentGenerationResult { + title: string; + body: string; +} + +export interface BranchNameGenerationInput { + cwd: string; + message: string; + attachments?: ReadonlyArray | undefined; + /** What model and provider to use for generation. */ + modelSelection: ModelSelection; +} + +export interface BranchNameGenerationResult { + branch: string; +} + +export interface ThreadTitleGenerationInput { + cwd: string; + message: string; + attachments?: ReadonlyArray | undefined; + /** What model and provider to use for generation. */ + modelSelection: ModelSelection; +} + +export interface ThreadTitleGenerationResult { + title: string; +} + +export interface TextGenerationService { + generateCommitMessage( + input: CommitMessageGenerationInput, + ): Promise; + generatePrContent(input: PrContentGenerationInput): Promise; + generateBranchName(input: BranchNameGenerationInput): Promise; + generateThreadTitle(input: ThreadTitleGenerationInput): Promise; +} + +/** + * TextGenerationShape - Service API for commit/PR text generation. + */ +export interface TextGenerationShape { + /** + * Generate a commit message from staged change context. + */ + readonly generateCommitMessage: ( + input: CommitMessageGenerationInput, + ) => Effect.Effect; + + /** + * Generate pull request title/body from branch and diff context. + */ + readonly generatePrContent: ( + input: PrContentGenerationInput, + ) => Effect.Effect; + + /** + * Generate a concise branch name from a user message. + */ + readonly generateBranchName: ( + input: BranchNameGenerationInput, + ) => Effect.Effect; + + /** + * Generate a concise thread title from a user's first message. + */ + readonly generateThreadTitle: ( + input: ThreadTitleGenerationInput, + ) => Effect.Effect; +} + +/** + * TextGeneration - Service tag for commit and PR text generation. + */ +export class TextGeneration extends Context.Service()( + "t3/text-generation/TextGeneration", +) {} + +type TextGenerationOp = + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle"; + +const resolveInstance = ( + registry: ProviderInstanceRegistryShape, + operation: TextGenerationOp, + instanceId: ProviderInstanceId, +): Effect.Effect => + registry.getInstance(instanceId).pipe( + Effect.flatMap((instance) => + instance + ? Effect.succeed(instance.textGeneration) + : Effect.fail( + new TextGenerationError({ + operation, + detail: `No provider instance registered for id '${instanceId}'.`, + }), + ), + ), + ); + +export const makeTextGenerationFromRegistry = ( + registry: ProviderInstanceRegistryShape, +): TextGenerationShape => ({ + generateCommitMessage: (input) => + resolveInstance(registry, "generateCommitMessage", input.modelSelection.instanceId).pipe( + Effect.flatMap((textGeneration) => textGeneration.generateCommitMessage(input)), + ), + generatePrContent: (input) => + resolveInstance(registry, "generatePrContent", input.modelSelection.instanceId).pipe( + Effect.flatMap((textGeneration) => textGeneration.generatePrContent(input)), + ), + generateBranchName: (input) => + resolveInstance(registry, "generateBranchName", input.modelSelection.instanceId).pipe( + Effect.flatMap((textGeneration) => textGeneration.generateBranchName(input)), + ), + generateThreadTitle: (input) => + resolveInstance(registry, "generateThreadTitle", input.modelSelection.instanceId).pipe( + Effect.flatMap((textGeneration) => textGeneration.generateThreadTitle(input)), + ), +}); + +export const layer = Layer.effect( + TextGeneration, + Effect.gen(function* () { + const registry = yield* ProviderInstanceRegistry; + return makeTextGenerationFromRegistry(registry); + }), +); diff --git a/apps/server/src/textGeneration/TextGenerationPolicy.ts b/apps/server/src/textGeneration/TextGenerationPolicy.ts new file mode 100644 index 0000000000000000000000000000000000000000..b0e020fa4fc4982e7eb066cd51a6d32638fac509 --- /dev/null +++ b/apps/server/src/textGeneration/TextGenerationPolicy.ts @@ -0,0 +1,19 @@ +import { Schema } from "effect"; + +export const TextGenerationPolicyKind = Schema.Literals([ + "default", + "conventional_commits", + "repo_conventions", + "custom", +]); +export type TextGenerationPolicyKind = typeof TextGenerationPolicyKind.Type; + +export const TextGenerationPolicy = Schema.Struct({ + kind: TextGenerationPolicyKind, + commitInstructions: Schema.optional(Schema.String), + changeRequestInstructions: Schema.optional(Schema.String), + branchInstructions: Schema.optional(Schema.String), + threadTitleInstructions: Schema.optional(Schema.String), + inferRepositoryConventions: Schema.Boolean, +}); +export type TextGenerationPolicy = typeof TextGenerationPolicy.Type; diff --git a/apps/server/src/textGeneration/TextGenerationPresets.ts b/apps/server/src/textGeneration/TextGenerationPresets.ts new file mode 100644 index 0000000000000000000000000000000000000000..70955742148ac9798db9fe735879ac84449b9dad --- /dev/null +++ b/apps/server/src/textGeneration/TextGenerationPresets.ts @@ -0,0 +1,41 @@ +import type { TextGenerationPolicy, TextGenerationPolicyKind } from "./TextGenerationPolicy.ts"; + +export const defaultTextGenerationPolicy: TextGenerationPolicy = { + kind: "default", + inferRepositoryConventions: false, +}; + +export const conventionalCommitsTextGenerationPolicy: TextGenerationPolicy = { + kind: "conventional_commits", + commitInstructions: + "Use Conventional Commits when generating commit subjects. Prefer the narrowest accurate type and include a scope only when it is obvious from the diff.", + changeRequestInstructions: + "Keep the change request title concise. Do not force Conventional Commit syntax into the title unless the repository already uses it.", + inferRepositoryConventions: false, +}; + +export const repositoryConventionsTextGenerationPolicy: TextGenerationPolicy = { + kind: "repo_conventions", + commitInstructions: + "Follow the repository's established commit message style when examples are available.", + changeRequestInstructions: + "Follow the repository's established change request title and body style when examples are available.", + inferRepositoryConventions: true, +}; + +export const customTextGenerationPolicy = ( + overrides: Omit, "kind">, +): TextGenerationPolicy => ({ + kind: "custom", + inferRepositoryConventions: false, + ...overrides, +}); + +export const textGenerationPresets: Record< + Exclude, + TextGenerationPolicy +> = { + default: defaultTextGenerationPolicy, + conventional_commits: conventionalCommitsTextGenerationPolicy, + repo_conventions: repositoryConventionsTextGenerationPolicy, +}; diff --git a/apps/server/src/textGeneration/TextGenerationPrompts.test.ts b/apps/server/src/textGeneration/TextGenerationPrompts.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..25fed64227029e0ec8d5248ae4db34a945017ae2 --- /dev/null +++ b/apps/server/src/textGeneration/TextGenerationPrompts.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it } from "vitest"; + +import { + buildBranchNamePrompt, + buildCommitMessagePrompt, + buildPrContentPrompt, + buildThreadTitlePrompt, +} from "./TextGenerationPrompts.ts"; +import { normalizeCliError, sanitizeThreadTitle } from "./TextGenerationUtils.ts"; +import { TextGenerationError } from "@t3tools/contracts"; + +describe("buildCommitMessagePrompt", () => { + it("includes staged patch and summary in the prompt", () => { + const result = buildCommitMessagePrompt({ + branch: "main", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md\n+hello", + includeBranch: false, + }); + + expect(result.prompt).toContain("Staged files:"); + expect(result.prompt).toContain("M README.md"); + expect(result.prompt).toContain("Staged patch:"); + expect(result.prompt).toContain("diff --git a/README.md b/README.md"); + expect(result.prompt).toContain("Branch: main"); + // Should NOT include the branch generation instruction + expect(result.prompt).not.toContain("branch must be a short semantic git branch fragment"); + }); + + it("includes branch generation instruction when includeBranch is true", () => { + const result = buildCommitMessagePrompt({ + branch: "feature/foo", + stagedSummary: "M README.md", + stagedPatch: "diff", + includeBranch: true, + }); + + expect(result.prompt).toContain("branch must be a short semantic git branch fragment"); + expect(result.prompt).toContain("Return a JSON object with keys: subject, body, branch."); + }); + + it("shows (detached) when branch is null", () => { + const result = buildCommitMessagePrompt({ + branch: null, + stagedSummary: "M a.ts", + stagedPatch: "diff", + includeBranch: false, + }); + + expect(result.prompt).toContain("Branch: (detached)"); + }); +}); + +describe("buildPrContentPrompt", () => { + it("includes branch names, commits, and diff in the prompt", () => { + const result = buildPrContentPrompt({ + baseBranch: "main", + headBranch: "feature/auth", + commitSummary: "feat: add login page", + diffSummary: "3 files changed", + diffPatch: "diff --git a/auth.ts b/auth.ts\n+export function login()", + }); + + expect(result.prompt).toContain("Base branch: main"); + expect(result.prompt).toContain("Head branch: feature/auth"); + expect(result.prompt).toContain("Commits:"); + expect(result.prompt).toContain("feat: add login page"); + expect(result.prompt).toContain("Diff stat:"); + expect(result.prompt).toContain("3 files changed"); + expect(result.prompt).toContain("Diff patch:"); + expect(result.prompt).toContain("export function login()"); + }); +}); + +describe("buildBranchNamePrompt", () => { + it("includes the user message in the prompt", () => { + const result = buildBranchNamePrompt({ + message: "Fix the login timeout bug", + }); + + expect(result.prompt).toContain("User message:"); + expect(result.prompt).toContain("Fix the login timeout bug"); + expect(result.prompt).not.toContain("Attachment metadata:"); + }); + + it("includes attachment metadata when attachments are provided", () => { + const result = buildBranchNamePrompt({ + message: "Fix the layout from screenshot", + attachments: [ + { + type: "image" as const, + id: "att-123", + name: "screenshot.png", + mimeType: "image/png", + sizeBytes: 12345, + }, + ], + }); + + expect(result.prompt).toContain("Attachment metadata:"); + expect(result.prompt).toContain("screenshot.png"); + expect(result.prompt).toContain("image/png"); + expect(result.prompt).toContain("12345 bytes"); + }); +}); + +describe("buildThreadTitlePrompt", () => { + it("includes the user message in the prompt", () => { + const result = buildThreadTitlePrompt({ + message: "Investigate reconnect regressions after session restore", + }); + + expect(result.prompt).toContain("User message:"); + expect(result.prompt).toContain("Investigate reconnect regressions after session restore"); + expect(result.prompt).not.toContain("Attachment metadata:"); + }); + + it("includes attachment metadata when attachments are provided", () => { + const result = buildThreadTitlePrompt({ + message: "Name this thread from the screenshot", + attachments: [ + { + type: "image" as const, + id: "att-456", + name: "thread.png", + mimeType: "image/png", + sizeBytes: 67890, + }, + ], + }); + + expect(result.prompt).toContain("Attachment metadata:"); + expect(result.prompt).toContain("thread.png"); + expect(result.prompt).toContain("image/png"); + expect(result.prompt).toContain("67890 bytes"); + }); +}); + +describe("sanitizeThreadTitle", () => { + it("truncates long titles with the shared sidebar-safe limit", () => { + expect( + sanitizeThreadTitle( + ' "Reconnect failures after restart because the session state does not recover" ', + ), + ).toBe("Reconnect failures after restart because the se..."); + }); +}); + +describe("normalizeCliError", () => { + it("detects 'Command not found' and includes CLI name in the message", () => { + const error = normalizeCliError( + "claude", + "generateCommitMessage", + new Error("Command not found: claude"), + "Something went wrong", + ); + + expect(error).toBeInstanceOf(TextGenerationError); + expect(error.detail).toContain("Claude CLI"); + expect(error.detail).toContain("not available on PATH"); + }); + + it("uses the CLI name from the first argument for codex", () => { + const error = normalizeCliError( + "codex", + "generateBranchName", + new Error("Command not found: codex"), + "Something went wrong", + ); + + expect(error).toBeInstanceOf(TextGenerationError); + expect(error.detail).toContain("Codex CLI"); + expect(error.detail).toContain("not available on PATH"); + }); + + it("returns the error as-is if it is already a TextGenerationError", () => { + const existing = new TextGenerationError({ + operation: "generatePrContent", + detail: "Already wrapped", + }); + + const result = normalizeCliError("claude", "generatePrContent", existing, "fallback"); + + expect(result).toBe(existing); + }); + + it("wraps unknown non-Error values with the fallback message", () => { + const result = normalizeCliError("codex", "generateCommitMessage", "string error", "fallback"); + + expect(result).toBeInstanceOf(TextGenerationError); + expect(result.detail).toBe("fallback"); + }); +}); diff --git a/apps/server/src/textGeneration/TextGenerationPrompts.ts b/apps/server/src/textGeneration/TextGenerationPrompts.ts new file mode 100644 index 0000000000000000000000000000000000000000..43ae62047b9977440f8b8f9be0f29e0524728537 --- /dev/null +++ b/apps/server/src/textGeneration/TextGenerationPrompts.ts @@ -0,0 +1,218 @@ +/** + * Shared prompt builders for text generation providers. + * + * Extracts the prompt construction logic that is identical across + * Codex, Claude, and any future CLI-based text generation backends. + * + * @module textGenerationPrompts + */ +import { Schema } from "effect"; +import type { ChatAttachment } from "@t3tools/contracts"; + +import { limitSection } from "./TextGenerationUtils.ts"; +import type { TextGenerationPolicy } from "./TextGenerationPolicy.ts"; + +function policyInstruction(instruction: string | undefined): ReadonlyArray { + const trimmed = instruction?.trim(); + return trimmed ? ["", "Additional instructions:", limitSection(trimmed, 4_000)] : []; +} + +// --------------------------------------------------------------------------- +// Commit message +// --------------------------------------------------------------------------- + +export interface CommitMessagePromptInput { + branch: string | null; + stagedSummary: string; + stagedPatch: string; + includeBranch: boolean; + policy?: TextGenerationPolicy | undefined; +} + +export function buildCommitMessagePrompt(input: CommitMessagePromptInput) { + const wantsBranch = input.includeBranch; + + const prompt = [ + "You write concise git commit messages.", + wantsBranch + ? "Return a JSON object with keys: subject, body, branch." + : "Return a JSON object with keys: subject, body.", + "Rules:", + "- subject must be imperative, <= 72 chars, and no trailing period", + "- body can be empty string or short bullet points", + ...(wantsBranch + ? ["- branch must be a short semantic git branch fragment for this change"] + : []), + "- capture the primary user-visible or developer-visible change", + ...policyInstruction(input.policy?.commitInstructions), + "", + `Branch: ${input.branch ?? "(detached)"}`, + "", + "Staged files:", + limitSection(input.stagedSummary, 6_000), + "", + "Staged patch:", + limitSection(input.stagedPatch, 40_000), + ].join("\n"); + + if (wantsBranch) { + return { + prompt, + outputSchema: Schema.Struct({ + subject: Schema.String, + body: Schema.String, + branch: Schema.String, + }), + }; + } + + return { + prompt, + outputSchema: Schema.Struct({ + subject: Schema.String, + body: Schema.String, + }), + }; +} + +// --------------------------------------------------------------------------- +// PR content +// --------------------------------------------------------------------------- + +export interface PrContentPromptInput { + baseBranch: string; + headBranch: string; + commitSummary: string; + diffSummary: string; + diffPatch: string; + policy?: TextGenerationPolicy | undefined; +} + +export function buildPrContentPrompt(input: PrContentPromptInput) { + const prompt = [ + "You write GitHub pull request content.", + "Return a JSON object with keys: title, body.", + "Rules:", + "- title should be concise and specific", + "- body must be markdown and include headings '## Summary' and '## Testing'", + "- under Summary, provide short bullet points", + "- under Testing, include bullet points with concrete checks or 'Not run' where appropriate", + ...policyInstruction(input.policy?.changeRequestInstructions), + "", + `Base branch: ${input.baseBranch}`, + `Head branch: ${input.headBranch}`, + "", + "Commits:", + limitSection(input.commitSummary, 12_000), + "", + "Diff stat:", + limitSection(input.diffSummary, 12_000), + "", + "Diff patch:", + limitSection(input.diffPatch, 40_000), + ].join("\n"); + + const outputSchema = Schema.Struct({ + title: Schema.String, + body: Schema.String, + }); + + return { prompt, outputSchema }; +} + +// --------------------------------------------------------------------------- +// Branch name +// --------------------------------------------------------------------------- + +export interface BranchNamePromptInput { + message: string; + attachments?: ReadonlyArray | undefined; + policy?: TextGenerationPolicy | undefined; +} + +interface PromptFromMessageInput { + instruction: string; + responseShape: string; + rules: ReadonlyArray; + message: string; + attachments?: ReadonlyArray | undefined; + additionalInstructions?: string | undefined; +} + +function buildPromptFromMessage(input: PromptFromMessageInput): string { + const attachmentLines = (input.attachments ?? []).map( + (attachment) => `- ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes)`, + ); + + const promptSections = [ + input.instruction, + input.responseShape, + "Rules:", + ...input.rules.map((rule) => `- ${rule}`), + "", + "User message:", + limitSection(input.message, 8_000), + ...policyInstruction(input.additionalInstructions), + ]; + if (attachmentLines.length > 0) { + promptSections.push( + "", + "Attachment metadata:", + limitSection(attachmentLines.join("\n"), 4_000), + ); + } + + return promptSections.join("\n"); +} + +export function buildBranchNamePrompt(input: BranchNamePromptInput) { + const prompt = buildPromptFromMessage({ + instruction: "You generate concise git branch names.", + responseShape: "Return a JSON object with key: branch.", + rules: [ + "Branch should describe the requested work from the user message.", + "Keep it short and specific (2-6 words).", + "Use plain words only, no issue prefixes and no punctuation-heavy text.", + "If images are attached, use them as primary context for visual/UI issues.", + ], + message: input.message, + attachments: input.attachments, + additionalInstructions: input.policy?.branchInstructions, + }); + const outputSchema = Schema.Struct({ + branch: Schema.String, + }); + + return { prompt, outputSchema }; +} + +// --------------------------------------------------------------------------- +// Thread title +// --------------------------------------------------------------------------- + +export interface ThreadTitlePromptInput { + message: string; + attachments?: ReadonlyArray | undefined; + policy?: TextGenerationPolicy | undefined; +} + +export function buildThreadTitlePrompt(input: ThreadTitlePromptInput) { + const prompt = buildPromptFromMessage({ + instruction: "You write concise thread titles for coding conversations.", + responseShape: "Return a JSON object with key: title.", + rules: [ + "Title should summarize the user's request, not restate it verbatim.", + "Keep it short and specific (3-8 words).", + "Avoid quotes, filler, prefixes, and trailing punctuation.", + "If images are attached, use them as primary context for visual/UI issues.", + ], + message: input.message, + attachments: input.attachments, + additionalInstructions: input.policy?.threadTitleInstructions, + }); + const outputSchema = Schema.Struct({ + title: Schema.String, + }); + + return { prompt, outputSchema }; +} diff --git a/apps/server/src/textGeneration/TextGenerationUtils.ts b/apps/server/src/textGeneration/TextGenerationUtils.ts new file mode 100644 index 0000000000000000000000000000000000000000..fcd8cc8b68980030f361fbfb20bd86d3e4adee72 --- /dev/null +++ b/apps/server/src/textGeneration/TextGenerationUtils.ts @@ -0,0 +1,159 @@ +import { Schema } from "effect"; + +import { TextGenerationError } from "@t3tools/contracts"; + +/** Convert an Effect Schema to a flat JSON Schema object, inlining `$defs` when present. */ +export function toJsonSchemaObject(schema: Schema.Top): unknown { + const document = Schema.toJsonSchemaDocument(schema); + if (document.definitions && Object.keys(document.definitions).length > 0) { + return { ...document.schema, $defs: document.definitions }; + } + return document.schema; +} + +/** Truncate a text section to `maxChars`, appending a `[truncated]` marker when needed. */ +export function limitSection(value: string, maxChars: number): string { + if (value.length <= maxChars) return value; + const truncated = value.slice(0, maxChars); + return `${truncated}\n\n[truncated]`; +} + +export function extractJsonObject(raw: string): string { + const trimmed = raw.trim(); + if (trimmed.length === 0) { + return trimmed; + } + + const start = trimmed.indexOf("{"); + if (start < 0) { + return trimmed; + } + + let depth = 0; + let inString = false; + let escaping = false; + for (let index = start; index < trimmed.length; index += 1) { + const char = trimmed[index]; + if (inString) { + if (escaping) { + escaping = false; + } else if (char === "\\") { + escaping = true; + } else if (char === '"') { + inString = false; + } + continue; + } + + if (char === '"') { + inString = true; + continue; + } + + if (char === "{") { + depth += 1; + continue; + } + + if (char === "}") { + depth -= 1; + if (depth === 0) { + return trimmed.slice(start, index + 1); + } + } + } + + return trimmed.slice(start); +} + +/** Normalise a raw commit subject to imperative-mood, ≤72 chars, no trailing period. */ +export function sanitizeCommitSubject(raw: string): string { + const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? ""; + const withoutTrailingPeriod = singleLine.replace(/[.]+$/g, "").trim(); + if (withoutTrailingPeriod.length === 0) { + return "Update project files"; + } + + if (withoutTrailingPeriod.length <= 72) { + return withoutTrailingPeriod; + } + return withoutTrailingPeriod.slice(0, 72).trimEnd(); +} + +/** Normalise a raw PR title to a single line with a sensible fallback. */ +export function sanitizePrTitle(raw: string): string { + const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? ""; + if (singleLine.length > 0) { + return singleLine; + } + return "Update project changes"; +} + +/** Normalise a raw thread title to a compact single-line sidebar-safe label. */ +export function sanitizeThreadTitle(raw: string): string { + const normalized = raw + .trim() + .split(/\r?\n/g)[0] + ?.trim() + .replace(/^['"`]+|['"`]+$/g, "") + .trim() + .replace(/\s+/g, " "); + + if (!normalized || normalized.trim().length === 0) { + return "New thread"; + } + + if (normalized.length <= 50) { + return normalized; + } + + return `${normalized.slice(0, 47).trimEnd()}...`; +} + +/** CLI name to human-readable label, e.g. "codex" → "Codex CLI (`codex`)" */ +function cliLabel(cliName: string): string { + const capitalized = cliName.charAt(0).toUpperCase() + cliName.slice(1); + return `${capitalized} CLI (\`${cliName}\`)`; +} + +/** + * Normalize an unknown error from a CLI text generation process into a + * typed `TextGenerationError`. Parameterized by CLI name so both Codex + * and Claude (and future providers) can share the same logic. + */ +export function normalizeCliError( + cliName: string, + operation: string, + error: unknown, + fallback: string, +): TextGenerationError { + if (Schema.is(TextGenerationError)(error)) { + return error; + } + + if (error instanceof Error) { + const lower = error.message.toLowerCase(); + if ( + error.message.includes(`Command not found: ${cliName}`) || + lower.includes(`spawn ${cliName}`) || + lower.includes("enoent") + ) { + return new TextGenerationError({ + operation, + detail: `${cliLabel(cliName)} is required but not available on PATH.`, + cause: error, + }); + } + return new TextGenerationError({ + operation, + detail: `${fallback}: ${error.message}`, + cause: error, + }); + } + + return new TextGenerationError({ + operation, + detail: fallback, + cause: error, + }); +} diff --git a/apps/server/src/vcs/GitVcsDriver.test.ts b/apps/server/src/vcs/GitVcsDriver.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..0e5ba5b82eccb769370bee03f09e120db654af42 --- /dev/null +++ b/apps/server/src/vcs/GitVcsDriver.test.ts @@ -0,0 +1,99 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { Effect, FileSystem, Layer, Path, PlatformError } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { assert, it } from "@effect/vitest"; + +import { GitCommandError } from "@t3tools/contracts"; +import { ServerConfig } from "../config.ts"; +import * as GitVcsDriver from "./GitVcsDriver.ts"; +import * as VcsProcess from "./VcsProcess.ts"; +import { runVcsDriverContractSuite } from "./testing/VcsDriverContractHarness.ts"; + +const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-git-vcs-contract-", +}); +const GitContractLayer = Layer.mergeAll(GitVcsDriver.vcsLayer, GitVcsDriver.layer).pipe( + Layer.provide(ServerConfigLayer), + Layer.provideMerge(VcsProcess.layer), + Layer.provideMerge(NodeServices.layer), +); + +const runGit = (cwd: string, args: ReadonlyArray) => + Effect.gen(function* () { + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* driver.execute({ + operation: "GitVcsDriver.contract.git", + cwd, + args, + timeoutMs: 10_000, + }); + }); + +type GitContractError = GitCommandError | PlatformError.PlatformError; + +runVcsDriverContractSuite({ + name: "Git", + kind: "git", + layer: GitContractLayer, + fixture: { + createRepo: (cwd) => + Effect.gen(function* () { + yield* runGit(cwd, ["init"]); + yield* runGit(cwd, ["config", "user.email", "test@test.com"]); + yield* runGit(cwd, ["config", "user.name", "Test"]); + }), + writeFile: (cwd, relativePath, contents) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const absolutePath = path.join(cwd, relativePath); + yield* fileSystem.makeDirectory(path.dirname(absolutePath), { recursive: true }); + yield* fileSystem.writeFileString(absolutePath, contents); + }), + trackFile: (cwd, relativePath) => runGit(cwd, ["add", relativePath]), + commit: (cwd, message) => runGit(cwd, ["commit", "-m", message]), + ignorePath: (cwd, pattern) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fileSystem.writeFileString(path.join(cwd, ".gitignore"), `${pattern}\n`); + }), + }, +}); + +it.effect("GitVcsDriver forwards execute env to the VCS process", () => { + let observedEnv: NodeJS.ProcessEnv | undefined; + + return Effect.gen(function* () { + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + + yield* driver.execute({ + operation: "GitVcsDriver.test.env", + cwd: "/repo", + args: ["status"], + env: { + GIT_INDEX_FILE: "/tmp/t3-index", + }, + }); + + assert.deepStrictEqual(observedEnv, { + GIT_INDEX_FILE: "/tmp/t3-index", + }); + }).pipe( + Effect.provide( + Layer.mock(VcsProcess.VcsProcess)({ + run: (input) => + Effect.sync(() => { + observedEnv = input.env; + return { + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: "", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }; + }), + }), + ), + ); +}); diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts new file mode 100644 index 0000000000000000000000000000000000000000..0cc73da5e674aad55188f5f0006a16f607df8c98 --- /dev/null +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -0,0 +1,543 @@ +import { Context, DateTime, Effect, Layer, Option } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { + GitCommandError, + VcsProcessExitError, + type VcsSwitchRefInput, + type VcsSwitchRefResult, + type VcsCreateRefInput, + type VcsCreateRefResult, + type VcsCreateWorktreeInput, + type VcsCreateWorktreeResult, + type VcsInitInput, + type VcsListRefsInput, + type VcsListRefsResult, + type VcsPullResult, + type VcsRemoveWorktreeInput, + type VcsStatusInput, + type VcsStatusResult, +} from "@t3tools/contracts"; +import { makeGitVcsDriverCore } from "./GitVcsDriverCore.ts"; +import { VcsDriver, type VcsDriverShape } from "./VcsDriver.ts"; +import { VcsProcess, type VcsProcessShape } from "./VcsProcess.ts"; + +export interface ExecuteGitInput { + readonly operation: string; + readonly cwd: string; + readonly args: ReadonlyArray; + readonly stdin?: string; + readonly env?: NodeJS.ProcessEnv; + readonly allowNonZeroExit?: boolean; + readonly timeoutMs?: number; + readonly maxOutputBytes?: number; + readonly truncateOutputAtMaxBytes?: boolean; + readonly progress?: ExecuteGitProgress; +} + +export interface ExecuteGitResult { + readonly exitCode: ChildProcessSpawner.ExitCode; + readonly stdout: string; + readonly stderr: string; + readonly stdoutTruncated: boolean; + readonly stderrTruncated: boolean; +} + +export interface GitStatusDetails { + isRepo: boolean; + sourceControlProvider?: VcsStatusResult["sourceControlProvider"]; + hasOriginRemote: boolean; + isDefaultBranch: boolean; + branch: string | null; + upstreamRef: string | null; + hasWorkingTreeChanges: boolean; + workingTree: VcsStatusResult["workingTree"]; + hasUpstream: boolean; + aheadCount: number; + behindCount: number; +} + +export interface GitPreparedCommitContext { + stagedSummary: string; + stagedPatch: string; +} + +export interface ExecuteGitProgress { + readonly onStdoutLine?: (line: string) => Effect.Effect; + readonly onStderrLine?: (line: string) => Effect.Effect; + readonly onHookStarted?: (hookName: string) => Effect.Effect; + readonly onHookFinished?: (input: { + hookName: string; + exitCode: number | null; + durationMs: number | null; + }) => Effect.Effect; +} + +export interface GitCommitProgress { + readonly onOutputLine?: (input: { + stream: "stdout" | "stderr"; + text: string; + }) => Effect.Effect; + readonly onHookStarted?: (hookName: string) => Effect.Effect; + readonly onHookFinished?: (input: { + hookName: string; + exitCode: number | null; + durationMs: number | null; + }) => Effect.Effect; +} + +export interface GitCommitOptions { + readonly timeoutMs?: number; + readonly progress?: GitCommitProgress; +} + +export interface GitPushResult { + status: "pushed" | "skipped_up_to_date"; + branch: string; + upstreamBranch?: string | undefined; + setUpstream?: boolean | undefined; +} + +export interface GitRangeContext { + commitSummary: string; + diffSummary: string; + diffPatch: string; +} + +export interface GitRenameBranchInput { + cwd: string; + oldBranch: string; + newBranch: string; +} + +export interface GitRenameBranchResult { + branch: string; +} + +export interface GitFetchPullRequestBranchInput { + cwd: string; + prNumber: number; + branch: string; +} + +export interface GitEnsureRemoteInput { + cwd: string; + preferredName: string; + url: string; +} + +export interface GitFetchRemoteBranchInput { + cwd: string; + remoteName: string; + remoteBranch: string; + localBranch: string; +} + +export interface GitSetBranchUpstreamInput { + cwd: string; + branch: string; + remoteName: string; + remoteBranch: string; +} + +export interface GitVcsDriverShape { + readonly execute: (input: ExecuteGitInput) => Effect.Effect; + readonly status: (input: VcsStatusInput) => Effect.Effect; + readonly statusDetails: (cwd: string) => Effect.Effect; + readonly statusDetailsLocal: (cwd: string) => Effect.Effect; + readonly prepareCommitContext: ( + cwd: string, + filePaths?: readonly string[], + ) => Effect.Effect; + readonly commit: ( + cwd: string, + subject: string, + body: string, + options?: GitCommitOptions, + ) => Effect.Effect<{ commitSha: string }, GitCommandError>; + readonly pushCurrentBranch: ( + cwd: string, + fallbackBranch: string | null, + ) => Effect.Effect; + readonly readRangeContext: ( + cwd: string, + baseRef: string, + ) => Effect.Effect; + readonly readConfigValue: ( + cwd: string, + key: string, + ) => Effect.Effect; + readonly listRefs: (input: VcsListRefsInput) => Effect.Effect; + readonly pullCurrentBranch: (cwd: string) => Effect.Effect; + readonly createWorktree: ( + input: VcsCreateWorktreeInput, + ) => Effect.Effect; + readonly fetchPullRequestBranch: ( + input: GitFetchPullRequestBranchInput, + ) => Effect.Effect; + readonly ensureRemote: (input: GitEnsureRemoteInput) => Effect.Effect; + readonly fetchRemoteBranch: ( + input: GitFetchRemoteBranchInput, + ) => Effect.Effect; + readonly setBranchUpstream: ( + input: GitSetBranchUpstreamInput, + ) => Effect.Effect; + readonly removeWorktree: (input: VcsRemoveWorktreeInput) => Effect.Effect; + readonly renameBranch: ( + input: GitRenameBranchInput, + ) => Effect.Effect; + readonly createRef: ( + input: VcsCreateRefInput, + ) => Effect.Effect; + readonly switchRef: ( + input: VcsSwitchRefInput, + ) => Effect.Effect; + readonly initRepo: (input: VcsInitInput) => Effect.Effect; + readonly listLocalBranchNames: (cwd: string) => Effect.Effect; +} + +export class GitVcsDriver extends Context.Service()( + "t3/vcs/GitVcsDriver", +) {} + +const WORKSPACE_FILES_MAX_OUTPUT_BYTES = 16 * 1024 * 1024; +const GIT_CHECK_IGNORE_MAX_STDIN_BYTES = 256 * 1024; +const WORKSPACE_GIT_HARDENED_CONFIG_ARGS = [ + "-c", + "core.fsmonitor=false", + "-c", + "core.untrackedCache=false", +] as const; + +const nowFreshness = Effect.fn("GitVcsDriver.nowFreshness")(function* () { + const now = yield* DateTime.now; + return { + source: "live-local" as const, + observedAt: now, + expiresAt: Option.none(), + }; +}); + +function splitNullSeparatedPaths(input: string, truncated: boolean): string[] { + const parts = input.split("\0"); + if (parts.length === 0) return []; + + if (truncated && parts[parts.length - 1]?.length) { + parts.pop(); + } + + return parts.filter((value) => value.length > 0); +} + +function chunkPathsForGitCheckIgnore(relativePaths: ReadonlyArray): string[][] { + const chunks: string[][] = []; + let chunk: string[] = []; + let chunkBytes = 0; + + for (const relativePath of relativePaths) { + const relativePathBytes = Buffer.byteLength(relativePath) + 1; + if (chunk.length > 0 && chunkBytes + relativePathBytes > GIT_CHECK_IGNORE_MAX_STDIN_BYTES) { + chunks.push(chunk); + chunk = []; + chunkBytes = 0; + } + + chunk.push(relativePath); + chunkBytes += relativePathBytes; + + if (chunkBytes >= GIT_CHECK_IGNORE_MAX_STDIN_BYTES) { + chunks.push(chunk); + chunk = []; + chunkBytes = 0; + } + } + + if (chunk.length > 0) { + chunks.push(chunk); + } + + return chunks; +} + +function parseGitRemoteVerboseOutput( + output: string, +): Map { + const remotes = new Map(); + for (const line of output.split("\n")) { + const trimmed = line.trim(); + if (trimmed.length === 0) { + continue; + } + + const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)$/.exec(trimmed); + if (!match) { + continue; + } + + const name = match[1]; + const url = match[2]; + const direction = match[3]; + if (!name || !url || !direction) { + continue; + } + const remote = remotes.get(name) ?? {}; + if (direction === "fetch") { + remote.url = url; + } else { + remote.pushUrl = url; + } + remotes.set(name, remote); + } + return remotes; +} + +const gitCommand = ( + process: VcsProcessShape, + operation: string, + cwd: string, + args: ReadonlyArray, + options?: { + readonly stdin?: string; + readonly env?: NodeJS.ProcessEnv; + readonly allowNonZeroExit?: boolean; + readonly timeoutMs?: number; + readonly maxOutputBytes?: number; + readonly truncateOutputAtMaxBytes?: boolean; + }, +) => + process.run({ + operation, + command: "git", + args, + cwd, + ...(options?.stdin !== undefined ? { stdin: options.stdin } : {}), + ...(options?.env !== undefined ? { env: options.env } : {}), + ...(options?.allowNonZeroExit !== undefined + ? { allowNonZeroExit: options.allowNonZeroExit } + : {}), + ...(options?.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), + ...(options?.maxOutputBytes !== undefined ? { maxOutputBytes: options.maxOutputBytes } : {}), + ...(options?.truncateOutputAtMaxBytes !== undefined + ? { truncateOutputAtMaxBytes: options.truncateOutputAtMaxBytes } + : {}), + }); + +export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* () { + const process = yield* VcsProcess; + const capabilities = { + kind: "git" as const, + supportsWorktrees: true, + supportsBookmarks: false, + supportsAtomicSnapshot: false, + supportsPushDefaultRemote: true, + ignoreClassifier: "native" as const, + }; + + const isInsideWorkTree: VcsDriverShape["isInsideWorkTree"] = (cwd) => + gitCommand( + process, + "GitVcsDriver.isInsideWorkTree", + cwd, + ["rev-parse", "--is-inside-work-tree"], + { + allowNonZeroExit: true, + timeoutMs: 5_000, + maxOutputBytes: 4_096, + }, + ).pipe(Effect.map((result) => result.exitCode === 0 && result.stdout.trim() === "true")); + + const execute: VcsDriverShape["execute"] = (input) => + gitCommand(process, input.operation, input.cwd, input.args, { + ...(input.stdin !== undefined ? { stdin: input.stdin } : {}), + ...(input.env !== undefined ? { env: input.env } : {}), + ...(input.allowNonZeroExit !== undefined ? { allowNonZeroExit: input.allowNonZeroExit } : {}), + ...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}), + ...(input.maxOutputBytes !== undefined ? { maxOutputBytes: input.maxOutputBytes } : {}), + ...(input.truncateOutputAtMaxBytes !== undefined + ? { truncateOutputAtMaxBytes: input.truncateOutputAtMaxBytes } + : {}), + }); + + const detectRepository: VcsDriverShape["detectRepository"] = Effect.fn("detectRepository")( + function* (cwd) { + if (!(yield* isInsideWorkTree(cwd))) { + return null; + } + + const root = yield* gitCommand(process, "GitVcsDriver.detectRepository.root", cwd, [ + "rev-parse", + "--show-toplevel", + ]); + const gitCommonDir = yield* gitCommand( + process, + "GitVcsDriver.detectRepository.commonDir", + cwd, + ["rev-parse", "--git-common-dir"], + ).pipe(Effect.catch(() => Effect.succeed(null))); + + return { + kind: "git" as const, + rootPath: root.stdout.trim(), + metadataPath: gitCommonDir?.stdout.trim() || null, + freshness: yield* nowFreshness(), + }; + }, + ); + + const listWorkspaceFiles: VcsDriverShape["listWorkspaceFiles"] = (cwd) => + gitCommand( + process, + "GitVcsDriver.listWorkspaceFiles", + cwd, + [ + ...WORKSPACE_GIT_HARDENED_CONFIG_ARGS, + "ls-files", + "--cached", + "--others", + "--exclude-standard", + "-z", + ], + { + allowNonZeroExit: true, + timeoutMs: 20_000, + maxOutputBytes: WORKSPACE_FILES_MAX_OUTPUT_BYTES, + truncateOutputAtMaxBytes: true, + }, + ).pipe( + Effect.flatMap((result) => + result.exitCode === 0 + ? Effect.gen(function* () { + const freshness = yield* nowFreshness(); + return { + paths: splitNullSeparatedPaths(result.stdout, result.stdoutTruncated), + truncated: result.stdoutTruncated, + freshness, + }; + }) + : Effect.fail( + new VcsProcessExitError({ + operation: "GitVcsDriver.listWorkspaceFiles", + command: "git ls-files", + cwd, + exitCode: result.exitCode, + detail: result.stderr.trim() || "git ls-files failed", + }), + ), + ), + ); + + const listRemotes: VcsDriverShape["listRemotes"] = Effect.fn("listRemotes")(function* (cwd) { + const result = yield* gitCommand(process, "GitVcsDriver.listRemotes", cwd, ["remote", "-v"], { + allowNonZeroExit: true, + timeoutMs: 5_000, + maxOutputBytes: 64 * 1024, + }); + + if (result.exitCode !== 0) { + return yield* new VcsProcessExitError({ + operation: "GitVcsDriver.listRemotes", + command: "git remote -v", + cwd, + exitCode: result.exitCode, + detail: result.stderr.trim() || "git remote -v failed", + }); + } + + const parsed = parseGitRemoteVerboseOutput(result.stdout); + const remotes = Array.from(parsed.entries()).flatMap(([name, remote]) => { + if (!remote.url) { + return []; + } + return [ + { + name, + url: remote.url, + pushUrl: remote.pushUrl ? Option.some(remote.pushUrl) : Option.none(), + isPrimary: name === "origin", + }, + ]; + }); + + return { + remotes, + freshness: yield* nowFreshness(), + }; + }); + + const filterIgnoredPaths: VcsDriverShape["filterIgnoredPaths"] = Effect.fn("filterIgnoredPaths")( + function* (cwd, relativePaths) { + if (relativePaths.length === 0) { + return relativePaths; + } + + const ignoredPaths = new Set(); + const chunks = chunkPathsForGitCheckIgnore(relativePaths); + + for (const chunk of chunks) { + const result = yield* gitCommand( + process, + "GitVcsDriver.filterIgnoredPaths", + cwd, + [...WORKSPACE_GIT_HARDENED_CONFIG_ARGS, "check-ignore", "--no-index", "-z", "--stdin"], + { + stdin: `${chunk.join("\0")}\0`, + allowNonZeroExit: true, + timeoutMs: 20_000, + maxOutputBytes: WORKSPACE_FILES_MAX_OUTPUT_BYTES, + truncateOutputAtMaxBytes: true, + }, + ); + + if (result.exitCode !== 0 && result.exitCode !== 1) { + return yield* new VcsProcessExitError({ + operation: "GitVcsDriver.filterIgnoredPaths", + command: "git check-ignore", + cwd, + exitCode: result.exitCode, + detail: result.stderr.trim() || "git check-ignore failed", + }); + } + + for (const ignoredPath of splitNullSeparatedPaths(result.stdout, result.stdoutTruncated)) { + ignoredPaths.add(ignoredPath); + } + } + + if (ignoredPaths.size === 0) { + return relativePaths; + } + + return relativePaths.filter((relativePath) => !ignoredPaths.has(relativePath)); + }, + ); + + const initRepository: VcsDriverShape["initRepository"] = (input) => + gitCommand(process, "GitVcsDriver.initRepository", input.cwd, ["init"], { + timeoutMs: 10_000, + maxOutputBytes: 64 * 1024, + }).pipe(Effect.asVoid); + + return { + capabilities, + execute, + detectRepository, + isInsideWorkTree, + listWorkspaceFiles, + listRemotes, + filterIgnoredPaths, + initRepository, + } satisfies VcsDriverShape; +}); + +export const makeVcsDriver = Effect.fn("makeGitVcsDriver")(function* () { + const driver = yield* makeVcsDriverShape(); + return VcsDriver.of(driver); +}); + +export const make = Effect.fn("makeGitVcsDriverService")(function* () { + const git = yield* makeGitVcsDriverCore(); + return GitVcsDriver.of(git); +}); + +export const vcsLayer = Layer.effect(VcsDriver, makeVcsDriver()); +export const layer = Layer.effect(GitVcsDriver, make()); diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..38472fd5b0db234056ca65f4f63def0b6554abfc --- /dev/null +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -0,0 +1,246 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { Effect, FileSystem, Layer, Path, PlatformError, Scope } from "effect"; +import { describe } from "vitest"; + +import { GitCommandError } from "@t3tools/contracts"; +import { ServerConfig } from "../config.ts"; +import * as GitVcsDriver from "./GitVcsDriver.ts"; + +const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-git-vcs-driver-test-", +}); +const TestLayer = GitVcsDriver.layer.pipe( + Layer.provide(ServerConfigLayer), + Layer.provideMerge(NodeServices.layer), +); + +const makeTmpDir = ( + prefix = "git-vcs-driver-test-", +): Effect.Effect => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + return yield* fileSystem.makeTempDirectoryScoped({ prefix }); + }); + +const writeTextFile = ( + cwd: string, + relativePath: string, + contents: string, +): Effect.Effect => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const filePath = pathService.join(cwd, relativePath); + yield* fileSystem.makeDirectory(pathService.dirname(filePath), { recursive: true }); + yield* fileSystem.writeFileString(filePath, contents); + }); + +const git = ( + cwd: string, + args: ReadonlyArray, + env?: NodeJS.ProcessEnv, +): Effect.Effect => + Effect.gen(function* () { + const driver = yield* GitVcsDriver.GitVcsDriver; + const result = yield* driver.execute({ + operation: "GitVcsDriver.test.git", + cwd, + args, + ...(env ? { env } : {}), + timeoutMs: 10_000, + }); + return result.stdout.trim(); + }); + +const initRepoWithCommit = ( + cwd: string, +): Effect.Effect< + { readonly initialBranch: string }, + GitCommandError | PlatformError.PlatformError, + GitVcsDriver.GitVcsDriver | FileSystem.FileSystem | Path.Path +> => + Effect.gen(function* () { + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* driver.initRepo({ cwd }); + yield* git(cwd, ["config", "user.email", "test@test.com"]); + yield* git(cwd, ["config", "user.name", "Test"]); + yield* writeTextFile(cwd, "README.md", "# test\n"); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "initial commit"]); + const initialBranch = yield* git(cwd, ["branch", "--show-current"]); + return { initialBranch }; + }); + +it.layer(TestLayer)("GitVcsDriver core integration", (it) => { + describe("repository status", () => { + it.effect("reports non-repository directories without failing", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const refs = yield* driver.listRefs({ cwd }); + assert.equal(refs.isRepo, false); + assert.deepStrictEqual(refs.refs, []); + }), + ); + + it.effect("reports refName and dirty state for a repository", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* writeTextFile(cwd, "feature.ts", "export const value = 1;\n"); + + const status = yield* (yield* GitVcsDriver.GitVcsDriver).statusDetails(cwd); + + assert.equal(status.isRepo, true); + assert.equal(status.branch, initialBranch); + assert.equal(status.hasWorkingTreeChanges, true); + assert.include( + status.workingTree.files.map((file) => file.path), + "feature.ts", + ); + }), + ); + }); + + describe("refName operations", () => { + it.effect("creates, checks out, renames, and lists refs", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + + yield* driver.createRef({ cwd, refName: "feature/original" }); + const switchRef = yield* driver.switchRef({ cwd, refName: "feature/original" }); + assert.equal(switchRef.refName, "feature/original"); + + const renamed = yield* driver.renameBranch({ + cwd, + oldBranch: "feature/original", + newBranch: "feature/renamed", + }); + assert.equal(renamed.branch, "feature/renamed"); + assert.equal(yield* git(cwd, ["branch", "--show-current"]), "feature/renamed"); + + const refs = yield* driver.listRefs({ cwd }); + assert.equal( + refs.refs.find((refName) => refName.name === "feature/renamed")?.current, + true, + ); + }), + ); + + it.effect("returns the existing refName when rename source and target match", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const current = yield* git(cwd, ["branch", "--show-current"]); + const result = yield* driver.renameBranch({ + cwd, + oldBranch: current, + newBranch: current, + }); + + assert.equal(result.branch, current); + }), + ); + }); + + describe("worktree operations", () => { + it.effect("creates and removes a worktree for a new refName", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const pathService = yield* Path.Path; + const worktreePath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "feature-worktree", + ); + const driver = yield* GitVcsDriver.GitVcsDriver; + + const created = yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/worktree", + }); + + assert.equal(created.worktree.path, worktreePath); + assert.equal(created.worktree.refName, "feature/worktree"); + assert.equal(yield* git(worktreePath, ["branch", "--show-current"]), "feature/worktree"); + + yield* driver.removeWorktree({ cwd, path: worktreePath }); + const fileSystem = yield* FileSystem.FileSystem; + assert.equal(yield* fileSystem.exists(worktreePath), false); + }), + ); + }); + + describe("commit context", () => { + it.effect("stages selected files and commits only those files", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + + yield* writeTextFile(cwd, "a.txt", "a\n"); + yield* writeTextFile(cwd, "b.txt", "b\n"); + + const context = yield* driver.prepareCommitContext(cwd, ["a.txt"]); + assert.include(context?.stagedSummary ?? "", "a.txt"); + assert.notInclude(context?.stagedSummary ?? "", "b.txt"); + + const commit = yield* driver.commit(cwd, "Add a", ""); + assert.match(commit.commitSha, /^[a-f0-9]{40}$/); + assert.equal(yield* git(cwd, ["log", "-1", "--pretty=%s"]), "Add a"); + + const status = yield* git(cwd, ["status", "--porcelain"]); + assert.include(status, "?? b.txt"); + assert.notInclude(status, "a.txt"); + }), + ); + }); + + describe("remote operations", () => { + it.effect("pushes with upstream setup and skips when already up to date", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-remote-"); + yield* initRepoWithCommit(cwd); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* (yield* GitVcsDriver.GitVcsDriver).createRef({ + cwd, + refName: "feature/push", + }); + yield* (yield* GitVcsDriver.GitVcsDriver).switchRef({ + cwd, + refName: "feature/push", + }); + yield* writeTextFile(cwd, "feature.txt", "feature\n"); + yield* (yield* GitVcsDriver.GitVcsDriver).prepareCommitContext(cwd); + yield* (yield* GitVcsDriver.GitVcsDriver).commit(cwd, "Add feature", ""); + + const pushed = yield* (yield* GitVcsDriver.GitVcsDriver).pushCurrentBranch(cwd, null); + assert.deepInclude(pushed, { + status: "pushed", + branch: "feature/push", + setUpstream: true, + }); + assert.equal( + yield* git(cwd, ["rev-parse", "--abbrev-ref", "@{upstream}"]), + "origin/feature/push", + ); + + const skipped = yield* (yield* GitVcsDriver.GitVcsDriver).pushCurrentBranch(cwd, null); + assert.deepInclude(skipped, { + status: "skipped_up_to_date", + branch: "feature/push", + }); + }), + ); + }); +}); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts new file mode 100644 index 0000000000000000000000000000000000000000..3ba1c27981e7fbf6be1c9b1b75ce7227ab97d403 --- /dev/null +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -0,0 +1,2087 @@ +import { + Cache, + Data, + DateTime, + Duration, + Effect, + Exit, + FileSystem, + Option, + Path, + PlatformError, + Ref, + Result, + Schema, + Scope, + Semaphore, + Stream, +} from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { GitCommandError, type VcsRef } from "@t3tools/contracts"; +import { dedupeRemoteBranchesWithLocalMatches } from "@t3tools/shared/git"; +import { compactTraceAttributes } from "../observability/Attributes.ts"; +import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../observability/Metrics.ts"; +import type { + ExecuteGitProgress, + GitCommitOptions, + GitVcsDriverShape, + GitStatusDetails, + ExecuteGitInput, + ExecuteGitResult, +} from "./GitVcsDriver.ts"; +import { + parseRemoteNames, + parseRemoteNamesInGitOrder, + parseRemoteRefWithRemoteNames, +} from "../git/remoteRefs.ts"; +import { ServerConfig } from "../config.ts"; +import { decodeJsonResult } from "@t3tools/shared/schemaJson"; + +const DEFAULT_TIMEOUT_MS = 30_000; +const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; +const OUTPUT_TRUNCATED_MARKER = "\n\n[truncated]"; +const PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES = 49_000; +const RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES = 19_000; +const RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES = 19_000; +const RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES = 59_000; +const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15); +const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5); +const STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN = Duration.seconds(5); +const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048; +const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const; +const GIT_LIST_BRANCHES_DEFAULT_LIMIT = 100; +const NON_REPOSITORY_STATUS_DETAILS = Object.freeze({ + isRepo: false, + hasOriginRemote: false, + isDefaultBranch: false, + branch: null, + upstreamRef: null, + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: false, + aheadCount: 0, + behindCount: 0, +}); + +type TraceTailState = { + processedChars: number; + remainder: string; +}; + +class StatusRemoteRefreshCacheKey extends Data.Class<{ + gitCommonDir: string; + remoteName: string; +}> {} + +interface ExecuteGitOptions { + stdin?: string | undefined; + timeoutMs?: number | undefined; + allowNonZeroExit?: boolean | undefined; + fallbackErrorMessage?: string | undefined; + maxOutputBytes?: number | undefined; + truncateOutputAtMaxBytes?: boolean | undefined; + progress?: ExecuteGitProgress | undefined; +} + +function parseBranchAb(value: string): { ahead: number; behind: number } { + const match = value.match(/^\+(\d+)\s+-(\d+)$/); + if (!match) return { ahead: 0, behind: 0 }; + return { + ahead: Number(match[1] ?? "0"), + behind: Number(match[2] ?? "0"), + }; +} + +function parseNumstatEntries( + stdout: string, +): Array<{ path: string; insertions: number; deletions: number }> { + const entries: Array<{ path: string; insertions: number; deletions: number }> = []; + for (const line of stdout.split(/\r?\n/g)) { + if (line.trim().length === 0) continue; + const [addedRaw, deletedRaw, ...pathParts] = line.split("\t"); + const rawPath = + pathParts.length > 1 ? (pathParts.at(-1) ?? "").trim() : pathParts.join("\t").trim(); + if (rawPath.length === 0) continue; + const added = Number.parseInt(addedRaw ?? "0", 10); + const deleted = Number.parseInt(deletedRaw ?? "0", 10); + const renameArrowIndex = rawPath.indexOf(" => "); + const normalizedPath = + renameArrowIndex >= 0 ? rawPath.slice(renameArrowIndex + " => ".length).trim() : rawPath; + entries.push({ + path: normalizedPath.length > 0 ? normalizedPath : rawPath, + insertions: Number.isFinite(added) ? added : 0, + deletions: Number.isFinite(deleted) ? deleted : 0, + }); + } + return entries; +} + +function parsePorcelainPath(line: string): string | null { + if (line.startsWith("? ") || line.startsWith("! ")) { + const simple = line.slice(2).trim(); + return simple.length > 0 ? simple : null; + } + + if (!(line.startsWith("1 ") || line.startsWith("2 ") || line.startsWith("u "))) { + return null; + } + + const tabIndex = line.indexOf("\t"); + if (tabIndex >= 0) { + const fromTab = line.slice(tabIndex + 1); + const [filePath] = fromTab.split("\t"); + return filePath?.trim().length ? filePath.trim() : null; + } + + const parts = line.trim().split(/\s+/g); + const filePath = parts.at(-1) ?? ""; + return filePath.length > 0 ? filePath : null; +} + +function parseBranchLine(line: string): { name: string; current: boolean } | null { + const trimmed = line.trim(); + if (trimmed.length === 0) return null; + + const name = trimmed.replace(/^[*+]\s+/, ""); + // Exclude symbolic refs like: "origin/HEAD -> origin/main". + // Exclude detached HEAD pseudo-refs like: "(HEAD detached at origin/main)". + if (name.includes(" -> ") || name.startsWith("(")) return null; + + return { + name, + current: trimmed.startsWith("* "), + }; +} + +function filterBranchesForListQuery( + refs: ReadonlyArray, + query?: string, +): ReadonlyArray { + if (!query) { + return refs; + } + + const normalizedQuery = query.toLowerCase(); + return refs.filter((refName) => refName.name.toLowerCase().includes(normalizedQuery)); +} + +function paginateBranches(input: { + refs: ReadonlyArray; + cursor?: number | undefined; + limit?: number | undefined; +}): { + refs: ReadonlyArray; + nextCursor: number | null; + totalCount: number; +} { + const cursor = input.cursor ?? 0; + const limit = input.limit ?? GIT_LIST_BRANCHES_DEFAULT_LIMIT; + const totalCount = input.refs.length; + const refs = input.refs.slice(cursor, cursor + limit); + const nextCursor = cursor + refs.length < totalCount ? cursor + refs.length : null; + + return { + refs, + nextCursor, + totalCount, + }; +} + +function sanitizeRemoteName(value: string): string { + const sanitized = value + .trim() + .replace(/[^A-Za-z0-9._-]+/g, "-") + .replace(/^-+|-+$/g, ""); + return sanitized.length > 0 ? sanitized : "fork"; +} + +function normalizeRemoteUrl(value: string): string { + return value + .trim() + .replace(/\/+$/g, "") + .replace(/\.git$/i, "") + .toLowerCase(); +} + +function parseRemoteFetchUrls(stdout: string): Map { + const remotes = new Map(); + for (const line of stdout.split("\n")) { + const trimmed = line.trim(); + if (trimmed.length === 0) continue; + const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)$/.exec(trimmed); + if (!match) continue; + const [, remoteName = "", remoteUrl = "", direction = ""] = match; + if (direction !== "fetch" || remoteName.length === 0 || remoteUrl.length === 0) { + continue; + } + remotes.set(remoteName, remoteUrl); + } + return remotes; +} + +function parseUpstreamRefWithRemoteNames( + upstreamRef: string, + remoteNames: ReadonlyArray, +): { upstreamRef: string; remoteName: string; branchName: string } | null { + const parsed = parseRemoteRefWithRemoteNames(upstreamRef, remoteNames); + if (!parsed) { + return null; + } + + return { + upstreamRef, + remoteName: parsed.remoteName, + branchName: parsed.branchName, + }; +} + +function parseUpstreamRefByFirstSeparator( + upstreamRef: string, +): { upstreamRef: string; remoteName: string; branchName: string } | null { + const separatorIndex = upstreamRef.indexOf("/"); + if (separatorIndex <= 0 || separatorIndex === upstreamRef.length - 1) { + return null; + } + + const remoteName = upstreamRef.slice(0, separatorIndex).trim(); + const branchName = upstreamRef.slice(separatorIndex + 1).trim(); + if (remoteName.length === 0 || branchName.length === 0) { + return null; + } + + return { + upstreamRef, + remoteName, + branchName, + }; +} + +function parseTrackingBranchByUpstreamRef(stdout: string, upstreamRef: string): string | null { + for (const line of stdout.split("\n")) { + const trimmedLine = line.trim(); + if (trimmedLine.length === 0) { + continue; + } + const [branchNameRaw, upstreamBranchRaw = ""] = trimmedLine.split("\t"); + const branchName = branchNameRaw?.trim() ?? ""; + const candidateUpstreamRef = upstreamBranchRaw.trim(); + if (branchName.length === 0 || candidateUpstreamRef.length === 0) { + continue; + } + if (candidateUpstreamRef === upstreamRef) { + return branchName; + } + } + + return null; +} + +function deriveLocalBranchNameFromRemoteRef(branchName: string): string | null { + const separatorIndex = branchName.indexOf("/"); + if (separatorIndex <= 0 || separatorIndex === branchName.length - 1) { + return null; + } + const localBranch = branchName.slice(separatorIndex + 1).trim(); + return localBranch.length > 0 ? localBranch : null; +} + +function commandLabel(args: readonly string[]): string { + return `git ${args.join(" ")}`; +} + +function parseDefaultBranchFromRemoteHeadRef(value: string, remoteName: string): string | null { + const trimmed = value.trim(); + const prefix = `refs/remotes/${remoteName}/`; + if (!trimmed.startsWith(prefix)) { + return null; + } + const refName = trimmed.slice(prefix.length).trim(); + return refName.length > 0 ? refName : null; +} + +function createGitCommandError( + operation: string, + cwd: string, + args: readonly string[], + detail: string, + cause?: unknown, +): GitCommandError { + return new GitCommandError({ + operation, + command: commandLabel(args), + cwd, + detail, + ...(cause !== undefined ? { cause } : {}), + }); +} + +function quoteGitCommand(args: ReadonlyArray): string { + return `git ${args.join(" ")}`; +} + +function isMissingGitCwdError(error: GitCommandError): boolean { + const normalized = `${error.detail}\n${error.message}`.toLowerCase(); + return ( + normalized.includes("no such file or directory") || + normalized.includes("notfound: filesystem.access") || + normalized.includes("enoent") || + normalized.includes("not a directory") + ); +} + +function toGitCommandError( + input: Pick, + detail: string, +) { + return (cause: unknown) => + Schema.is(GitCommandError)(cause) + ? cause + : new GitCommandError({ + operation: input.operation, + command: quoteGitCommand(input.args), + cwd: input.cwd, + detail: `${cause instanceof Error && cause.message.length > 0 ? cause.message : "Unknown error"} - ${detail}`, + ...(cause !== undefined ? { cause } : {}), + }); +} + +interface Trace2Monitor { + readonly env: NodeJS.ProcessEnv; + readonly flush: Effect.Effect; +} + +const nowUnixNano = DateTime.now.pipe( + Effect.map((now) => BigInt(DateTime.toEpochMillis(now)) * 1_000_000n), +); + +const addCurrentSpanEvent = (name: string, attributes: Record) => + Effect.gen(function* () { + const span = yield* Effect.currentSpan; + const timestamp = yield* nowUnixNano; + yield* Effect.sync(() => { + span.event(name, timestamp, compactTraceAttributes(attributes)); + }); + }).pipe(Effect.catch(() => Effect.void)); + +function trace2ChildKey(record: Record): string | null { + const childId = record.child_id; + if (typeof childId === "number" || typeof childId === "string") { + return String(childId); + } + const hookName = record.hook_name; + return typeof hookName === "string" && hookName.trim().length > 0 ? hookName.trim() : null; +} + +const Trace2Record = Schema.Record(Schema.String, Schema.Unknown); + +const createTrace2Monitor = Effect.fn("createTrace2Monitor")(function* ( + input: Pick, + progress: ExecuteGitProgress | undefined, +): Effect.fn.Return< + Trace2Monitor, + PlatformError.PlatformError, + Scope.Scope | FileSystem.FileSystem | Path.Path +> { + if (!progress?.onHookStarted && !progress?.onHookFinished) { + return { + env: {}, + flush: Effect.void, + }; + } + + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const traceFilePath = yield* fs.makeTempFileScoped({ + prefix: `t3code-git-trace2-${process.pid}-`, + suffix: ".json", + }); + const hookStartByChildKey = new Map(); + const traceTailState = yield* Ref.make({ + processedChars: 0, + remainder: "", + }); + + const handleTraceLine = Effect.fn("handleTraceLine")(function* (line: string) { + const trimmedLine = line.trim(); + if (trimmedLine.length === 0) { + return; + } + + const traceRecord = decodeJsonResult(Trace2Record)(trimmedLine); + if (Result.isFailure(traceRecord)) { + yield* Effect.logDebug( + `GitVcsDriver.trace2: failed to parse trace line for ${quoteGitCommand(input.args)} in ${input.cwd}`, + traceRecord.failure, + ); + return; + } + + if (traceRecord.success.child_class !== "hook") { + return; + } + + const event = traceRecord.success.event; + const childKey = trace2ChildKey(traceRecord.success); + if (childKey === null) { + return; + } + const started = hookStartByChildKey.get(childKey); + const hookNameFromEvent = + typeof traceRecord.success.hook_name === "string" ? traceRecord.success.hook_name.trim() : ""; + const hookName = hookNameFromEvent.length > 0 ? hookNameFromEvent : (started?.hookName ?? ""); + if (hookName.length === 0) { + return; + } + + if (event === "child_start") { + const now = yield* DateTime.now; + hookStartByChildKey.set(childKey, { hookName, startedAtMs: DateTime.toEpochMillis(now) }); + yield* addCurrentSpanEvent("git.hook.started", { + hookName, + }); + if (progress.onHookStarted) { + yield* progress.onHookStarted(hookName); + } + return; + } + + if (event === "child_exit") { + hookStartByChildKey.delete(childKey); + const code = traceRecord.success.exitCode; + const exitCode = typeof code === "number" && Number.isInteger(code) ? code : null; + const now = yield* DateTime.now; + const durationMs = started + ? Math.max(0, DateTime.toEpochMillis(now) - started.startedAtMs) + : null; + yield* addCurrentSpanEvent("git.hook.finished", { + hookName: started?.hookName ?? hookName, + exitCode, + durationMs, + }); + if (progress.onHookFinished) { + yield* progress.onHookFinished({ + hookName: started?.hookName ?? hookName, + exitCode, + durationMs, + }); + } + } + }); + + const deltaMutex = yield* Semaphore.make(1); + const readTraceDelta = deltaMutex.withPermit( + fs.readFileString(traceFilePath).pipe( + Effect.flatMap((contents) => + Effect.uninterruptible( + Ref.modify(traceTailState, ({ processedChars, remainder }) => { + if (contents.length <= processedChars) { + return [[], { processedChars, remainder }]; + } + + const appended = contents.slice(processedChars); + const combined = remainder + appended; + const lines = combined.split("\n"); + const nextRemainder = lines.pop() ?? ""; + + return [ + lines.map((line) => line.replace(/\r$/, "")), + { + processedChars: contents.length, + remainder: nextRemainder, + }, + ]; + }).pipe( + Effect.flatMap((lines) => Effect.forEach(lines, handleTraceLine, { discard: true })), + ), + ), + ), + Effect.ignore({ log: true }), + ), + ); + const traceFileName = path.basename(traceFilePath); + yield* Stream.runForEach(fs.watch(traceFilePath), (event) => { + const eventPath = event.path; + const isTargetTraceEvent = + eventPath === traceFilePath || + eventPath === traceFileName || + path.basename(eventPath) === traceFileName; + if (!isTargetTraceEvent) return Effect.void; + return readTraceDelta; + }).pipe(Effect.ignoreCause({ log: true }), Effect.forkScoped); + + const finalizeTrace2Monitor = Effect.fn("finalizeTrace2Monitor")(function* () { + yield* readTraceDelta; + const finalLine = yield* Ref.modify(traceTailState, ({ processedChars, remainder }) => [ + remainder.trim(), + { + processedChars, + remainder: "", + }, + ]); + if (finalLine.length > 0) { + yield* handleTraceLine(finalLine); + } + }); + + yield* Effect.addFinalizer(finalizeTrace2Monitor); + + return { + env: { + GIT_TRACE2_EVENT: traceFilePath, + }, + flush: readTraceDelta, + }; +}); + +const collectOutput = Effect.fn("collectOutput")(function* ( + input: Pick, + stream: Stream.Stream, + maxOutputBytes: number, + truncateOutputAtMaxBytes: boolean, + onLine: ((line: string) => Effect.Effect) | undefined, +): Effect.fn.Return<{ readonly text: string; readonly truncated: boolean }, GitCommandError> { + const decoder = new TextDecoder(); + let bytes = 0; + let text = ""; + let lineBuffer = ""; + let truncated = false; + + const emitCompleteLines = Effect.fn("emitCompleteLines")(function* (flush: boolean) { + let newlineIndex = lineBuffer.indexOf("\n"); + while (newlineIndex >= 0) { + const line = lineBuffer.slice(0, newlineIndex).replace(/\r$/, ""); + lineBuffer = lineBuffer.slice(newlineIndex + 1); + if (line.length > 0 && onLine) { + yield* onLine(line); + } + newlineIndex = lineBuffer.indexOf("\n"); + } + + if (flush) { + const trailing = lineBuffer.replace(/\r$/, ""); + lineBuffer = ""; + if (trailing.length > 0 && onLine) { + yield* onLine(trailing); + } + } + }); + + const processChunk = Effect.fn("processChunk")(function* (chunk: Uint8Array) { + if (truncateOutputAtMaxBytes && truncated) { + return; + } + const nextBytes = bytes + chunk.byteLength; + if (!truncateOutputAtMaxBytes && nextBytes > maxOutputBytes) { + return yield* new GitCommandError({ + operation: input.operation, + command: quoteGitCommand(input.args), + cwd: input.cwd, + detail: `${quoteGitCommand(input.args)} output exceeded ${maxOutputBytes} bytes and was truncated.`, + }); + } + + const chunkToDecode = + truncateOutputAtMaxBytes && nextBytes > maxOutputBytes + ? chunk.subarray(0, Math.max(0, maxOutputBytes - bytes)) + : chunk; + bytes += chunkToDecode.byteLength; + truncated = truncateOutputAtMaxBytes && nextBytes > maxOutputBytes; + + const decoded = decoder.decode(chunkToDecode, { stream: !truncated }); + text += decoded; + lineBuffer += decoded; + yield* emitCompleteLines(false); + }); + + yield* Stream.runForEach(stream, processChunk).pipe( + Effect.mapError(toGitCommandError(input, "output stream failed.")), + ); + + const remainder = truncated ? "" : decoder.decode(); + text += remainder; + lineBuffer += remainder; + yield* emitCompleteLines(true); + return { + text, + truncated, + }; +}); + +export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* (options?: { + executeOverride?: GitVcsDriverShape["execute"]; +}) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { worktreesDir } = yield* ServerConfig; + + let executeRaw: GitVcsDriverShape["execute"]; + + if (options?.executeOverride) { + executeRaw = options.executeOverride; + } else { + const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + executeRaw = Effect.fnUntraced(function* (input) { + const commandInput = { + ...input, + args: [...input.args], + } as const; + const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; + const truncateOutputAtMaxBytes = input.truncateOutputAtMaxBytes ?? false; + + const runGitCommand = Effect.fn("runGitCommand")(function* () { + const trace2Monitor = yield* createTrace2Monitor(commandInput, input.progress).pipe( + Effect.provideService(Path.Path, path), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.mapError(toGitCommandError(commandInput, "failed to create trace2 monitor.")), + ); + const child = yield* commandSpawner + .spawn( + ChildProcess.make("git", commandInput.args, { + cwd: commandInput.cwd, + env: { + ...process.env, + ...input.env, + ...trace2Monitor.env, + }, + }), + ) + .pipe(Effect.mapError(toGitCommandError(commandInput, "failed to spawn."))); + + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + collectOutput( + commandInput, + child.stdout, + maxOutputBytes, + truncateOutputAtMaxBytes, + input.progress?.onStdoutLine, + ), + collectOutput( + commandInput, + child.stderr, + maxOutputBytes, + truncateOutputAtMaxBytes, + input.progress?.onStderrLine, + ), + child.exitCode.pipe( + Effect.mapError(toGitCommandError(commandInput, "failed to report exit code.")), + ), + input.stdin === undefined + ? Effect.void + : Stream.run(Stream.encodeText(Stream.make(input.stdin)), child.stdin).pipe( + Effect.mapError(toGitCommandError(commandInput, "failed to write stdin.")), + ), + ], + { concurrency: "unbounded" }, + ).pipe(Effect.map(([stdout, stderr, exitCode]) => [stdout, stderr, exitCode] as const)); + yield* trace2Monitor.flush; + + if (!input.allowNonZeroExit && exitCode !== 0) { + const trimmedStderr = stderr.text.trim(); + return yield* new GitCommandError({ + operation: commandInput.operation, + command: quoteGitCommand(commandInput.args), + cwd: commandInput.cwd, + detail: + trimmedStderr.length > 0 + ? `${quoteGitCommand(commandInput.args)} failed: ${trimmedStderr}` + : `${quoteGitCommand(commandInput.args)} failed with code ${exitCode}.`, + }); + } + + return { + exitCode, + stdout: stdout.text, + stderr: stderr.text, + stdoutTruncated: stdout.truncated, + stderrTruncated: stderr.truncated, + } satisfies ExecuteGitResult; + }); + + return yield* runGitCommand().pipe( + Effect.scoped, + Effect.timeoutOption(timeoutMs), + Effect.flatMap((result) => + Option.match(result, { + onNone: () => + Effect.fail( + new GitCommandError({ + operation: commandInput.operation, + command: quoteGitCommand(commandInput.args), + cwd: commandInput.cwd, + detail: `${quoteGitCommand(commandInput.args)} timed out.`, + }), + ), + onSome: Effect.succeed, + }), + ), + ); + }); + } + + const execute: GitVcsDriverShape["execute"] = (input) => + executeRaw(input).pipe( + withMetrics({ + counter: gitCommandsTotal, + timer: gitCommandDuration, + attributes: { + operation: input.operation, + }, + }), + Effect.withSpan(input.operation, { + kind: "client", + attributes: { + "git.operation": input.operation, + "git.cwd": input.cwd, + "git.args_count": input.args.length, + }, + }), + ); + + const executeGit = ( + operation: string, + cwd: string, + args: readonly string[], + options: ExecuteGitOptions = {}, + ): Effect.Effect => + execute({ + operation, + cwd, + args, + ...(options.stdin !== undefined ? { stdin: options.stdin } : {}), + allowNonZeroExit: true, + ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), + ...(options.maxOutputBytes !== undefined ? { maxOutputBytes: options.maxOutputBytes } : {}), + ...(options.truncateOutputAtMaxBytes !== undefined + ? { truncateOutputAtMaxBytes: options.truncateOutputAtMaxBytes } + : {}), + ...(options.progress ? { progress: options.progress } : {}), + }).pipe( + Effect.flatMap((result) => { + if (options.allowNonZeroExit || result.exitCode === 0) { + return Effect.succeed(result); + } + const stderr = result.stderr.trim(); + if (stderr.length > 0) { + return Effect.fail(createGitCommandError(operation, cwd, args, stderr)); + } + if (options.fallbackErrorMessage) { + return Effect.fail( + createGitCommandError(operation, cwd, args, options.fallbackErrorMessage), + ); + } + return Effect.fail( + createGitCommandError( + operation, + cwd, + args, + `${commandLabel(args)} failed: code=${result.exitCode ?? "null"}`, + ), + ); + }), + ); + + const runGit = ( + operation: string, + cwd: string, + args: readonly string[], + allowNonZeroExit = false, + ): Effect.Effect => + executeGit(operation, cwd, args, { allowNonZeroExit }).pipe(Effect.asVoid); + + const runGitStdout = ( + operation: string, + cwd: string, + args: readonly string[], + allowNonZeroExit = false, + ): Effect.Effect => + executeGit(operation, cwd, args, { allowNonZeroExit }).pipe( + Effect.map((result) => result.stdout), + ); + + const runGitStdoutWithOptions = ( + operation: string, + cwd: string, + args: readonly string[], + options: ExecuteGitOptions = {}, + ): Effect.Effect => + executeGit(operation, cwd, args, options).pipe( + Effect.map((result) => + result.stdoutTruncated ? `${result.stdout}${OUTPUT_TRUNCATED_MARKER}` : result.stdout, + ), + ); + + const branchExists = (cwd: string, refName: string): Effect.Effect => + executeGit( + "GitVcsDriver.branchExists", + cwd, + ["show-ref", "--verify", "--quiet", `refs/heads/${refName}`], + { + allowNonZeroExit: true, + timeoutMs: 5_000, + }, + ).pipe(Effect.map((result) => result.exitCode === 0)); + + const resolveAvailableBranchName = Effect.fn("resolveAvailableBranchName")(function* ( + cwd: string, + desiredBranch: string, + ) { + const isDesiredTaken = yield* branchExists(cwd, desiredBranch); + if (!isDesiredTaken) { + return desiredBranch; + } + + for (let suffix = 1; suffix <= 100; suffix += 1) { + const candidate = `${desiredBranch}-${suffix}`; + const isCandidateTaken = yield* branchExists(cwd, candidate); + if (!isCandidateTaken) { + return candidate; + } + } + + return yield* createGitCommandError( + "GitVcsDriver.renameBranch", + cwd, + ["branch", "-m", "--", desiredBranch], + `Could not find an available branch name for '${desiredBranch}'.`, + ); + }); + + const resolveCurrentUpstream = Effect.fn("resolveCurrentUpstream")(function* (cwd: string) { + const upstreamRef = yield* runGitStdout( + "GitVcsDriver.resolveCurrentUpstream", + cwd, + ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], + true, + ).pipe(Effect.map((stdout) => stdout.trim())); + + if (upstreamRef.length === 0 || upstreamRef === "@{upstream}") { + return null; + } + + const remoteNames = yield* runGitStdout("GitVcsDriver.listRemoteNames", cwd, ["remote"]).pipe( + Effect.map(parseRemoteNames), + Effect.catch(() => Effect.succeed>([])), + ); + return ( + parseUpstreamRefWithRemoteNames(upstreamRef, remoteNames) ?? + parseUpstreamRefByFirstSeparator(upstreamRef) + ); + }); + + const fetchRemoteForStatus = ( + gitCommonDir: string, + remoteName: string, + ): Effect.Effect => { + const fetchCwd = + path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir; + return executeGit( + "GitVcsDriver.fetchRemoteForStatus", + fetchCwd, + ["--git-dir", gitCommonDir, "fetch", "--quiet", "--no-tags", remoteName], + { + allowNonZeroExit: true, + timeoutMs: Duration.toMillis(STATUS_UPSTREAM_REFRESH_TIMEOUT), + }, + ).pipe(Effect.asVoid); + }; + + const resolveGitCommonDir = Effect.fn("resolveGitCommonDir")(function* (cwd: string) { + const gitCommonDir = yield* runGitStdout("GitVcsDriver.resolveGitCommonDir", cwd, [ + "rev-parse", + "--git-common-dir", + ]).pipe(Effect.map((stdout) => stdout.trim())); + return path.isAbsolute(gitCommonDir) ? gitCommonDir : path.resolve(cwd, gitCommonDir); + }); + + const refreshStatusRemoteCacheEntry = Effect.fn("refreshStatusRemoteCacheEntry")(function* ( + cacheKey: StatusRemoteRefreshCacheKey, + ) { + yield* fetchRemoteForStatus(cacheKey.gitCommonDir, cacheKey.remoteName); + return true as const; + }); + + const statusRemoteRefreshCache = yield* Cache.makeWith(refreshStatusRemoteCacheEntry, { + capacity: STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY, + // Keep successful refreshes warm and briefly back off failed refreshes to avoid retry storms. + timeToLive: (exit) => + Exit.isSuccess(exit) + ? STATUS_UPSTREAM_REFRESH_INTERVAL + : STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN, + }); + + const refreshStatusUpstreamIfStale = Effect.fn("refreshStatusUpstreamIfStale")(function* ( + cwd: string, + ) { + const upstream = yield* resolveCurrentUpstream(cwd); + if (!upstream) return; + const gitCommonDir = yield* resolveGitCommonDir(cwd); + yield* Cache.get( + statusRemoteRefreshCache, + new StatusRemoteRefreshCacheKey({ + gitCommonDir, + remoteName: upstream.remoteName, + }), + ); + }); + + const resolveDefaultBranchName = ( + cwd: string, + remoteName: string, + ): Effect.Effect => + executeGit( + "GitVcsDriver.resolveDefaultBranchName", + cwd, + ["symbolic-ref", `refs/remotes/${remoteName}/HEAD`], + { allowNonZeroExit: true }, + ).pipe( + Effect.map((result) => { + if (result.exitCode !== 0) { + return null; + } + return parseDefaultBranchFromRemoteHeadRef(result.stdout, remoteName); + }), + ); + + const remoteBranchExists = ( + cwd: string, + remoteName: string, + refName: string, + ): Effect.Effect => + executeGit( + "GitVcsDriver.remoteBranchExists", + cwd, + ["show-ref", "--verify", "--quiet", `refs/remotes/${remoteName}/${refName}`], + { + allowNonZeroExit: true, + }, + ).pipe(Effect.map((result) => result.exitCode === 0)); + + const originRemoteExists = (cwd: string): Effect.Effect => + executeGit("GitVcsDriver.originRemoteExists", cwd, ["remote", "get-url", "origin"], { + allowNonZeroExit: true, + }).pipe(Effect.map((result) => result.exitCode === 0)); + + const listRemoteNames = (cwd: string): Effect.Effect, GitCommandError> => + runGitStdout("GitVcsDriver.listRemoteNames", cwd, ["remote"]).pipe( + Effect.map(parseRemoteNamesInGitOrder), + ); + + const resolvePrimaryRemoteName = Effect.fn("resolvePrimaryRemoteName")(function* (cwd: string) { + if (yield* originRemoteExists(cwd)) { + return "origin"; + } + const remotes = yield* listRemoteNames(cwd); + const [firstRemote] = remotes; + if (firstRemote) { + return firstRemote; + } + return yield* createGitCommandError( + "GitVcsDriver.resolvePrimaryRemoteName", + cwd, + ["remote"], + "No git remote is configured for this repository.", + ); + }); + + const resolvePushRemoteName = Effect.fn("resolvePushRemoteName")(function* ( + cwd: string, + refName: string, + ) { + const branchPushRemote = yield* runGitStdout( + "GitVcsDriver.resolvePushRemoteName.branchPushRemote", + cwd, + ["config", "--get", `branch.${refName}.pushRemote`], + true, + ).pipe(Effect.map((stdout) => stdout.trim())); + if (branchPushRemote.length > 0) { + return branchPushRemote; + } + + const pushDefaultRemote = yield* runGitStdout( + "GitVcsDriver.resolvePushRemoteName.remotePushDefault", + cwd, + ["config", "--get", "remote.pushDefault"], + true, + ).pipe(Effect.map((stdout) => stdout.trim())); + if (pushDefaultRemote.length > 0) { + return pushDefaultRemote; + } + + return yield* resolvePrimaryRemoteName(cwd).pipe(Effect.catch(() => Effect.succeed(null))); + }); + + const ensureRemote: GitVcsDriverShape["ensureRemote"] = Effect.fn("ensureRemote")( + function* (input) { + const preferredName = sanitizeRemoteName(input.preferredName); + const normalizedTargetUrl = normalizeRemoteUrl(input.url); + const remoteFetchUrls = yield* runGitStdout( + "GitVcsDriver.ensureRemote.listRemoteUrls", + input.cwd, + ["remote", "-v"], + ).pipe(Effect.map((stdout) => parseRemoteFetchUrls(stdout))); + + for (const [remoteName, remoteUrl] of remoteFetchUrls.entries()) { + if (normalizeRemoteUrl(remoteUrl) === normalizedTargetUrl) { + return remoteName; + } + } + + let remoteName = preferredName; + let suffix = 1; + while (remoteFetchUrls.has(remoteName)) { + remoteName = `${preferredName}-${suffix}`; + suffix += 1; + } + + yield* runGit("GitVcsDriver.ensureRemote.add", input.cwd, [ + "remote", + "add", + remoteName, + input.url, + ]); + return remoteName; + }, + ); + + const resolveBaseBranchForNoUpstream = Effect.fn("resolveBaseBranchForNoUpstream")(function* ( + cwd: string, + refName: string, + ) { + const configuredBaseBranch = yield* runGitStdout( + "GitVcsDriver.resolveBaseBranchForNoUpstream.config", + cwd, + ["config", "--get", `branch.${refName}.gh-merge-base`], + true, + ).pipe(Effect.map((stdout) => stdout.trim())); + + const primaryRemoteName = yield* resolvePrimaryRemoteName(cwd).pipe( + Effect.catch(() => Effect.succeed(null)), + ); + const defaultBranch = + primaryRemoteName === null ? null : yield* resolveDefaultBranchName(cwd, primaryRemoteName); + const candidates = [ + configuredBaseBranch.length > 0 ? configuredBaseBranch : null, + defaultBranch, + ...DEFAULT_BASE_BRANCH_CANDIDATES, + ]; + + for (const candidate of candidates) { + if (!candidate) { + continue; + } + + const remotePrefix = + primaryRemoteName && primaryRemoteName !== "origin" ? `${primaryRemoteName}/` : null; + const normalizedCandidate = candidate.startsWith("origin/") + ? candidate.slice("origin/".length) + : remotePrefix && candidate.startsWith(remotePrefix) + ? candidate.slice(remotePrefix.length) + : candidate; + if (normalizedCandidate.length === 0 || normalizedCandidate === refName) { + continue; + } + + if (yield* branchExists(cwd, normalizedCandidate)) { + return normalizedCandidate; + } + + if ( + primaryRemoteName && + (yield* remoteBranchExists(cwd, primaryRemoteName, normalizedCandidate)) + ) { + return `${primaryRemoteName}/${normalizedCandidate}`; + } + } + + return null; + }); + + const computeAheadCountAgainstBase = Effect.fn("computeAheadCountAgainstBase")(function* ( + cwd: string, + refName: string, + ) { + const baseRef = yield* resolveBaseBranchForNoUpstream(cwd, refName); + if (!baseRef) { + return 0; + } + + const result = yield* executeGit( + "GitVcsDriver.computeAheadCountAgainstBase", + cwd, + ["rev-list", "--count", `${baseRef}..HEAD`], + { allowNonZeroExit: true }, + ); + if (result.exitCode !== 0) { + return 0; + } + + const parsed = Number.parseInt(result.stdout.trim(), 10); + return Number.isFinite(parsed) ? Math.max(0, parsed) : 0; + }); + + const readBranchRecency = Effect.fn("readBranchRecency")(function* (cwd: string) { + const branchRecency = yield* executeGit( + "GitVcsDriver.readBranchRecency", + cwd, + [ + "for-each-ref", + "--format=%(refname:short)%09%(committerdate:unix)", + "refs/heads", + "refs/remotes", + ], + { + timeoutMs: 15_000, + allowNonZeroExit: true, + }, + ); + + const branchLastCommit = new Map(); + if (branchRecency.exitCode !== 0) { + return branchLastCommit; + } + + for (const line of branchRecency.stdout.split("\n")) { + if (line.length === 0) { + continue; + } + const [name, lastCommitRaw] = line.split("\t"); + if (!name) { + continue; + } + const lastCommit = Number.parseInt(lastCommitRaw ?? "0", 10); + branchLastCommit.set(name, Number.isFinite(lastCommit) ? lastCommit : 0); + } + + return branchLastCommit; + }); + + const readStatusDetailsLocal = Effect.fn("readStatusDetailsLocal")(function* (cwd: string) { + const statusResult = yield* executeGit( + "GitVcsDriver.statusDetails.status", + cwd, + ["status", "--porcelain=2", "--branch"], + { + allowNonZeroExit: true, + }, + ).pipe(Effect.catchIf(isMissingGitCwdError, () => Effect.succeed(null))); + + if (statusResult === null) { + return NON_REPOSITORY_STATUS_DETAILS; + } + + if (statusResult.exitCode !== 0) { + const stderr = statusResult.stderr.trim(); + return yield* createGitCommandError( + "GitVcsDriver.statusDetails.status", + cwd, + ["status", "--porcelain=2", "--branch"], + stderr || "git status failed", + ); + } + + const [unstagedNumstatStdout, stagedNumstatStdout, defaultRefResult, hasPrimaryRemote] = + yield* Effect.all( + [ + runGitStdout("GitVcsDriver.statusDetails.unstagedNumstat", cwd, ["diff", "--numstat"]), + runGitStdout("GitVcsDriver.statusDetails.stagedNumstat", cwd, [ + "diff", + "--cached", + "--numstat", + ]), + executeGit( + "GitVcsDriver.statusDetails.defaultRef", + cwd, + ["symbolic-ref", "refs/remotes/origin/HEAD"], + { + allowNonZeroExit: true, + }, + ), + originRemoteExists(cwd).pipe(Effect.catch(() => Effect.succeed(false))), + ], + { concurrency: "unbounded" }, + ); + const statusStdout = statusResult.stdout; + const defaultBranch = + defaultRefResult.exitCode === 0 + ? defaultRefResult.stdout.trim().replace(/^refs\/remotes\/origin\//, "") + : null; + + let refName: string | null = null; + let upstreamRef: string | null = null; + let aheadCount = 0; + let behindCount = 0; + let hasWorkingTreeChanges = false; + const changedFilesWithoutNumstat = new Set(); + + for (const line of statusStdout.split(/\r?\n/g)) { + if (line.startsWith("# branch.head ")) { + const value = line.slice("# branch.head ".length).trim(); + refName = value.startsWith("(") ? null : value; + continue; + } + if (line.startsWith("# branch.upstream ")) { + const value = line.slice("# branch.upstream ".length).trim(); + upstreamRef = value.length > 0 ? value : null; + continue; + } + if (line.startsWith("# branch.ab ")) { + const value = line.slice("# branch.ab ".length).trim(); + const parsed = parseBranchAb(value); + aheadCount = parsed.ahead; + behindCount = parsed.behind; + continue; + } + if (line.trim().length > 0 && !line.startsWith("#")) { + hasWorkingTreeChanges = true; + const pathValue = parsePorcelainPath(line); + if (pathValue) changedFilesWithoutNumstat.add(pathValue); + } + } + + if (!upstreamRef && refName) { + aheadCount = yield* computeAheadCountAgainstBase(cwd, refName).pipe( + Effect.catch(() => Effect.succeed(0)), + ); + behindCount = 0; + } + + const stagedEntries = parseNumstatEntries(stagedNumstatStdout); + const unstagedEntries = parseNumstatEntries(unstagedNumstatStdout); + const fileStatMap = new Map(); + for (const entry of [...stagedEntries, ...unstagedEntries]) { + const existing = fileStatMap.get(entry.path) ?? { insertions: 0, deletions: 0 }; + existing.insertions += entry.insertions; + existing.deletions += entry.deletions; + fileStatMap.set(entry.path, existing); + } + + let insertions = 0; + let deletions = 0; + const files = Array.from(fileStatMap.entries()) + .map(([filePath, stat]) => { + insertions += stat.insertions; + deletions += stat.deletions; + return { path: filePath, insertions: stat.insertions, deletions: stat.deletions }; + }) + .toSorted((a, b) => a.path.localeCompare(b.path)); + + for (const filePath of changedFilesWithoutNumstat) { + if (fileStatMap.has(filePath)) continue; + files.push({ path: filePath, insertions: 0, deletions: 0 }); + } + files.sort((a, b) => a.path.localeCompare(b.path)); + + return { + isRepo: true, + hasOriginRemote: hasPrimaryRemote, + isDefaultBranch: + refName !== null && + (refName === defaultBranch || + (defaultBranch === null && (refName === "main" || refName === "master"))), + branch: refName, + upstreamRef, + hasWorkingTreeChanges, + workingTree: { + files, + insertions, + deletions, + }, + hasUpstream: upstreamRef !== null, + aheadCount, + behindCount, + }; + }); + + const statusDetailsLocal: GitVcsDriverShape["statusDetailsLocal"] = Effect.fn( + "statusDetailsLocal", + )(function* (cwd) { + return yield* readStatusDetailsLocal(cwd); + }); + + const statusDetails: GitVcsDriverShape["statusDetails"] = Effect.fn("statusDetails")( + function* (cwd) { + yield* refreshStatusUpstreamIfStale(cwd).pipe( + Effect.catchIf(isMissingGitCwdError, () => Effect.void), + Effect.ignoreCause({ log: true }), + ); + return yield* readStatusDetailsLocal(cwd); + }, + ); + + const status: GitVcsDriverShape["status"] = (input) => + statusDetails(input.cwd).pipe( + Effect.map((details) => ({ + isRepo: details.isRepo, + hasPrimaryRemote: details.hasOriginRemote, + isDefaultRef: details.isDefaultBranch, + refName: details.branch, + hasWorkingTreeChanges: details.hasWorkingTreeChanges, + workingTree: details.workingTree, + hasUpstream: details.hasUpstream, + aheadCount: details.aheadCount, + behindCount: details.behindCount, + pr: null, + })), + ); + + const prepareCommitContext: GitVcsDriverShape["prepareCommitContext"] = Effect.fn( + "prepareCommitContext", + )(function* (cwd, filePaths) { + if (filePaths && filePaths.length > 0) { + yield* runGit("GitVcsDriver.prepareCommitContext.reset", cwd, ["reset"]).pipe( + Effect.catch(() => Effect.void), + ); + yield* runGit("GitVcsDriver.prepareCommitContext.addSelected", cwd, [ + "add", + "-A", + "--", + ...filePaths, + ]); + } else { + yield* runGit("GitVcsDriver.prepareCommitContext.addAll", cwd, ["add", "-A"]); + } + + const stagedSummary = yield* runGitStdout( + "GitVcsDriver.prepareCommitContext.stagedSummary", + cwd, + ["diff", "--cached", "--name-status"], + ).pipe(Effect.map((stdout) => stdout.trim())); + if (stagedSummary.length === 0) { + return null; + } + + const stagedPatch = yield* runGitStdoutWithOptions( + "GitVcsDriver.prepareCommitContext.stagedPatch", + cwd, + ["diff", "--cached", "--patch", "--minimal"], + { + maxOutputBytes: PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES, + truncateOutputAtMaxBytes: true, + }, + ); + + return { + stagedSummary, + stagedPatch, + }; + }); + + const commit: GitVcsDriverShape["commit"] = Effect.fn("commit")(function* ( + cwd, + subject, + body, + options?: GitCommitOptions, + ) { + const args = ["commit", "-m", subject]; + const trimmedBody = body.trim(); + if (trimmedBody.length > 0) { + args.push("-m", trimmedBody); + } + const progress = + options?.progress?.onOutputLine === undefined + ? options?.progress + : { + ...options.progress, + onStdoutLine: (line: string) => + options.progress?.onOutputLine?.({ stream: "stdout", text: line }) ?? Effect.void, + onStderrLine: (line: string) => + options.progress?.onOutputLine?.({ stream: "stderr", text: line }) ?? Effect.void, + }; + yield* executeGit("GitVcsDriver.commit.commit", cwd, args, { + ...(options?.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), + ...(progress ? { progress } : {}), + }).pipe(Effect.asVoid); + const commitSha = yield* runGitStdout("GitVcsDriver.commit.revParseHead", cwd, [ + "rev-parse", + "HEAD", + ]).pipe(Effect.map((stdout) => stdout.trim())); + + return { commitSha }; + }); + + const pushCurrentBranch: GitVcsDriverShape["pushCurrentBranch"] = Effect.fn("pushCurrentBranch")( + function* (cwd, fallbackBranch) { + const details = yield* statusDetails(cwd); + const branch = details.branch ?? fallbackBranch; + if (!branch) { + return yield* createGitCommandError( + "GitVcsDriver.pushCurrentBranch", + cwd, + ["push"], + "Cannot push from detached HEAD.", + ); + } + + const hasNoLocalDelta = details.aheadCount === 0 && details.behindCount === 0; + if (hasNoLocalDelta) { + if (details.hasUpstream) { + return { + status: "skipped_up_to_date" as const, + branch, + ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}), + }; + } + + const comparableBaseBranch = yield* resolveBaseBranchForNoUpstream(cwd, branch).pipe( + Effect.catch(() => Effect.succeed(null)), + ); + if (comparableBaseBranch) { + const publishRemoteName = yield* resolvePushRemoteName(cwd, branch).pipe( + Effect.catch(() => Effect.succeed(null)), + ); + if (!publishRemoteName) { + return { + status: "skipped_up_to_date" as const, + branch, + }; + } + + const hasRemoteBranch = yield* remoteBranchExists(cwd, publishRemoteName, branch).pipe( + Effect.catch(() => Effect.succeed(false)), + ); + if (hasRemoteBranch) { + return { + status: "skipped_up_to_date" as const, + branch, + }; + } + } + } + + if (!details.hasUpstream) { + const publishRemoteName = yield* resolvePushRemoteName(cwd, branch); + if (!publishRemoteName) { + return yield* createGitCommandError( + "GitVcsDriver.pushCurrentBranch", + cwd, + ["push"], + "Cannot push because no git remote is configured for this repository.", + ); + } + yield* runGit("GitVcsDriver.pushCurrentBranch.pushWithUpstream", cwd, [ + "push", + "-u", + publishRemoteName, + `HEAD:refs/heads/${branch}`, + ]); + return { + status: "pushed" as const, + branch, + upstreamBranch: `${publishRemoteName}/${branch}`, + setUpstream: true, + }; + } + + const currentUpstream = yield* resolveCurrentUpstream(cwd).pipe( + Effect.catch(() => Effect.succeed(null)), + ); + if (currentUpstream) { + yield* runGit("GitVcsDriver.pushCurrentBranch.pushUpstream", cwd, [ + "push", + currentUpstream.remoteName, + `HEAD:${currentUpstream.upstreamRef}`, + ]); + return { + status: "pushed" as const, + branch, + upstreamBranch: currentUpstream.upstreamRef, + setUpstream: false, + }; + } + + yield* runGit("GitVcsDriver.pushCurrentBranch.push", cwd, ["push"]); + return { + status: "pushed" as const, + branch, + ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}), + setUpstream: false, + }; + }, + ); + + const pullCurrentBranch: GitVcsDriverShape["pullCurrentBranch"] = Effect.fn("pullCurrentBranch")( + function* (cwd) { + const details = yield* statusDetails(cwd); + const refName = details.branch; + if (!refName) { + return yield* createGitCommandError( + "GitVcsDriver.pullCurrentBranch", + cwd, + ["pull", "--ff-only"], + "Cannot pull from detached HEAD.", + ); + } + if (!details.hasUpstream) { + return yield* createGitCommandError( + "GitVcsDriver.pullCurrentBranch", + cwd, + ["pull", "--ff-only"], + "Current branch has no upstream configured. Push with upstream first.", + ); + } + const beforeSha = yield* runGitStdout( + "GitVcsDriver.pullCurrentBranch.beforeSha", + cwd, + ["rev-parse", "HEAD"], + true, + ).pipe(Effect.map((stdout) => stdout.trim())); + yield* executeGit("GitVcsDriver.pullCurrentBranch.pull", cwd, ["pull", "--ff-only"], { + timeoutMs: 30_000, + fallbackErrorMessage: "git pull failed", + }); + const afterSha = yield* runGitStdout( + "GitVcsDriver.pullCurrentBranch.afterSha", + cwd, + ["rev-parse", "HEAD"], + true, + ).pipe(Effect.map((stdout) => stdout.trim())); + + const refreshed = yield* statusDetails(cwd); + return { + status: beforeSha.length > 0 && beforeSha === afterSha ? "skipped_up_to_date" : "pulled", + refName, + upstreamRef: refreshed.upstreamRef, + }; + }, + ); + + const readRangeContext: GitVcsDriverShape["readRangeContext"] = Effect.fn("readRangeContext")( + function* (cwd, baseRef) { + const range = `${baseRef}..HEAD`; + const [commitSummary, diffSummary, diffPatch] = yield* Effect.all( + [ + runGitStdoutWithOptions( + "GitVcsDriver.readRangeContext.log", + cwd, + ["log", "--oneline", range], + { + maxOutputBytes: RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES, + truncateOutputAtMaxBytes: true, + }, + ), + runGitStdoutWithOptions( + "GitVcsDriver.readRangeContext.diffStat", + cwd, + ["diff", "--stat", range], + { + maxOutputBytes: RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES, + truncateOutputAtMaxBytes: true, + }, + ), + runGitStdoutWithOptions( + "GitVcsDriver.readRangeContext.diffPatch", + cwd, + ["diff", "--patch", "--minimal", range], + { + maxOutputBytes: RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES, + truncateOutputAtMaxBytes: true, + }, + ), + ], + { concurrency: "unbounded" }, + ); + + return { + commitSummary, + diffSummary, + diffPatch, + }; + }, + ); + + const readConfigValue: GitVcsDriverShape["readConfigValue"] = (cwd, key) => + runGitStdout("GitVcsDriver.readConfigValue", cwd, ["config", "--get", key], true).pipe( + Effect.map((stdout) => stdout.trim()), + Effect.map((trimmed) => (trimmed.length > 0 ? trimmed : null)), + ); + + const listRefs: GitVcsDriverShape["listRefs"] = Effect.fn("listRefs")(function* (input) { + const branchRecencyPromise = readBranchRecency(input.cwd).pipe( + Effect.catch(() => Effect.succeed(new Map())), + ); + const localBranchResult = yield* executeGit( + "GitVcsDriver.listRefs.branchNoColor", + input.cwd, + ["branch", "--no-color", "--no-column"], + { + timeoutMs: 10_000, + allowNonZeroExit: true, + }, + ).pipe( + Effect.catchIf(isMissingGitCwdError, () => + Effect.succeed({ + exitCode: ChildProcessSpawner.ExitCode(128), + stdout: "", + stderr: "fatal: not a git repository", + stdoutTruncated: false, + stderrTruncated: false, + }), + ), + ); + + if (localBranchResult.exitCode !== 0) { + const stderr = localBranchResult.stderr.trim(); + if (stderr.toLowerCase().includes("not a git repository")) { + return { + refs: [], + isRepo: false, + hasPrimaryRemote: false, + nextCursor: null, + totalCount: 0, + }; + } + return yield* createGitCommandError( + "GitVcsDriver.listRefs", + input.cwd, + ["branch", "--no-color", "--no-column"], + stderr || "git branch failed", + ); + } + + const remoteBranchResultEffect = executeGit( + "GitVcsDriver.listRefs.remoteBranches", + input.cwd, + ["branch", "--no-color", "--no-column", "--remotes"], + { + timeoutMs: 10_000, + allowNonZeroExit: true, + }, + ).pipe( + Effect.catch((error) => + Effect.logWarning( + `GitVcsDriver.listRefs: remote refName lookup failed for ${input.cwd}: ${error.message}. Falling back to an empty remote refName list.`, + ).pipe( + Effect.as({ + exitCode: ChildProcessSpawner.ExitCode(1), + stdout: "", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + } satisfies ExecuteGitResult), + ), + ), + ); + + const remoteNamesResultEffect = executeGit( + "GitVcsDriver.listRefs.remoteNames", + input.cwd, + ["remote"], + { + timeoutMs: 5_000, + allowNonZeroExit: true, + }, + ).pipe( + Effect.catch((error) => + Effect.logWarning( + `GitVcsDriver.listRefs: remote name lookup failed for ${input.cwd}: ${error.message}. Falling back to an empty remote name list.`, + ).pipe( + Effect.as({ + exitCode: ChildProcessSpawner.ExitCode(1), + stdout: "", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + } satisfies ExecuteGitResult), + ), + ), + ); + + const [defaultRef, worktreeList, remoteBranchResult, remoteNamesResult, branchLastCommit] = + yield* Effect.all( + [ + executeGit( + "GitVcsDriver.listRefs.defaultRef", + input.cwd, + ["symbolic-ref", "refs/remotes/origin/HEAD"], + { + timeoutMs: 5_000, + allowNonZeroExit: true, + }, + ), + executeGit( + "GitVcsDriver.listRefs.worktreeList", + input.cwd, + ["worktree", "list", "--porcelain"], + { + timeoutMs: 5_000, + allowNonZeroExit: true, + }, + ), + remoteBranchResultEffect, + remoteNamesResultEffect, + branchRecencyPromise, + ], + { concurrency: "unbounded" }, + ); + + const remoteNames = + remoteNamesResult.exitCode === 0 ? parseRemoteNames(remoteNamesResult.stdout) : []; + if (remoteBranchResult.exitCode !== 0 && remoteBranchResult.stderr.trim().length > 0) { + yield* Effect.logWarning( + `GitVcsDriver.listRefs: remote refName lookup returned code ${remoteBranchResult.exitCode} for ${input.cwd}: ${remoteBranchResult.stderr.trim()}. Falling back to an empty remote refName list.`, + ); + } + if (remoteNamesResult.exitCode !== 0 && remoteNamesResult.stderr.trim().length > 0) { + yield* Effect.logWarning( + `GitVcsDriver.listRefs: remote name lookup returned code ${remoteNamesResult.exitCode} for ${input.cwd}: ${remoteNamesResult.stderr.trim()}. Falling back to an empty remote name list.`, + ); + } + + const defaultBranch = + defaultRef.exitCode === 0 + ? defaultRef.stdout.trim().replace(/^refs\/remotes\/origin\//, "") + : null; + + const worktreeMap = new Map(); + if (worktreeList.exitCode === 0) { + let currentPath: string | null = null; + for (const line of worktreeList.stdout.split("\n")) { + if (line.startsWith("worktree ")) { + const candidatePath = line.slice("worktree ".length); + const exists = yield* fileSystem.stat(candidatePath).pipe( + Effect.map(() => true), + Effect.catch(() => Effect.succeed(false)), + ); + currentPath = exists ? candidatePath : null; + } else if (line.startsWith("branch refs/heads/") && currentPath) { + worktreeMap.set(line.slice("branch refs/heads/".length), currentPath); + } else if (line === "") { + currentPath = null; + } + } + } + + const localBranches = localBranchResult.stdout + .split("\n") + .map(parseBranchLine) + .filter((refName): refName is { name: string; current: boolean } => refName !== null) + .map((refName) => ({ + name: refName.name, + current: refName.current, + isRemote: false, + isDefault: refName.name === defaultBranch, + worktreePath: worktreeMap.get(refName.name) ?? null, + })) + .toSorted((a, b) => { + const aPriority = a.current ? 0 : a.isDefault ? 1 : 2; + const bPriority = b.current ? 0 : b.isDefault ? 1 : 2; + if (aPriority !== bPriority) return aPriority - bPriority; + + const aLastCommit = branchLastCommit.get(a.name) ?? 0; + const bLastCommit = branchLastCommit.get(b.name) ?? 0; + if (aLastCommit !== bLastCommit) return bLastCommit - aLastCommit; + return a.name.localeCompare(b.name); + }); + + const remoteBranches = + remoteBranchResult.exitCode === 0 + ? remoteBranchResult.stdout + .split("\n") + .map(parseBranchLine) + .filter((refName): refName is { name: string; current: boolean } => refName !== null) + .map((refName) => { + const parsedRemoteRef = parseRemoteRefWithRemoteNames(refName.name, remoteNames); + const remoteBranch: { + name: string; + current: boolean; + isRemote: boolean; + remoteName?: string; + isDefault: boolean; + worktreePath: string | null; + } = { + name: refName.name, + current: false, + isRemote: true, + isDefault: false, + worktreePath: null, + }; + if (parsedRemoteRef) { + remoteBranch.remoteName = parsedRemoteRef.remoteName; + } + return remoteBranch; + }) + .toSorted((a, b) => { + const aLastCommit = branchLastCommit.get(a.name) ?? 0; + const bLastCommit = branchLastCommit.get(b.name) ?? 0; + if (aLastCommit !== bLastCommit) return bLastCommit - aLastCommit; + return a.name.localeCompare(b.name); + }) + : []; + + const refs = paginateBranches({ + refs: filterBranchesForListQuery( + dedupeRemoteBranchesWithLocalMatches([...localBranches, ...remoteBranches]), + input.query, + ), + cursor: input.cursor, + limit: input.limit, + }); + + return { + refs: [...refs.refs], + isRepo: true, + hasPrimaryRemote: remoteNames.includes("origin"), + nextCursor: refs.nextCursor, + totalCount: refs.totalCount, + }; + }); + + const createWorktree: GitVcsDriverShape["createWorktree"] = Effect.fn("createWorktree")( + function* (input) { + const targetBranch = input.newRefName ?? input.refName; + const sanitizedBranch = targetBranch.replace(/\//g, "-"); + const repoName = path.basename(input.cwd); + const worktreePath = input.path ?? path.join(worktreesDir, repoName, sanitizedBranch); + const args = input.newRefName + ? ["worktree", "add", "-b", input.newRefName, worktreePath, input.refName] + : ["worktree", "add", worktreePath, input.refName]; + + yield* executeGit("GitVcsDriver.createWorktree", input.cwd, args, { + fallbackErrorMessage: "git worktree add failed", + }); + + return { + worktree: { + path: worktreePath, + refName: targetBranch, + }, + }; + }, + ); + + const fetchPullRequestBranch: GitVcsDriverShape["fetchPullRequestBranch"] = Effect.fn( + "fetchPullRequestBranch", + )(function* (input) { + const remoteName = yield* resolvePrimaryRemoteName(input.cwd); + yield* executeGit( + "GitVcsDriver.fetchPullRequestBranch", + input.cwd, + [ + "fetch", + "--quiet", + "--no-tags", + remoteName, + `+refs/pull/${input.prNumber}/head:refs/heads/${input.branch}`, + ], + { + fallbackErrorMessage: "git fetch pull request branch failed", + }, + ); + }); + + const fetchRemoteBranch: GitVcsDriverShape["fetchRemoteBranch"] = Effect.fn("fetchRemoteBranch")( + function* (input) { + yield* runGit("GitVcsDriver.fetchRemoteBranch.fetch", input.cwd, [ + "fetch", + "--quiet", + "--no-tags", + input.remoteName, + `+refs/heads/${input.remoteBranch}:refs/remotes/${input.remoteName}/${input.remoteBranch}`, + ]); + + const localBranchAlreadyExists = yield* branchExists(input.cwd, input.localBranch); + const targetRef = `${input.remoteName}/${input.remoteBranch}`; + yield* runGit( + "GitVcsDriver.fetchRemoteBranch.materialize", + input.cwd, + localBranchAlreadyExists + ? ["branch", "--force", input.localBranch, targetRef] + : ["branch", input.localBranch, targetRef], + ); + }, + ); + + const setBranchUpstream: GitVcsDriverShape["setBranchUpstream"] = (input) => + runGit("GitVcsDriver.setBranchUpstream", input.cwd, [ + "branch", + "--set-upstream-to", + `${input.remoteName}/${input.remoteBranch}`, + input.branch, + ]); + + const removeWorktree: GitVcsDriverShape["removeWorktree"] = Effect.fn("removeWorktree")( + function* (input) { + const args = ["worktree", "remove"]; + if (input.force) { + args.push("--force"); + } + args.push(input.path); + yield* executeGit("GitVcsDriver.removeWorktree", input.cwd, args, { + timeoutMs: 15_000, + fallbackErrorMessage: "git worktree remove failed", + }).pipe( + Effect.mapError((error) => + createGitCommandError( + "GitVcsDriver.removeWorktree", + input.cwd, + args, + `${commandLabel(args)} failed (cwd: ${input.cwd}): ${error.message}`, + error, + ), + ), + ); + }, + ); + + const renameBranch: GitVcsDriverShape["renameBranch"] = Effect.fn("renameBranch")( + function* (input) { + if (input.oldBranch === input.newBranch) { + return { branch: input.newBranch }; + } + const targetBranch = yield* resolveAvailableBranchName(input.cwd, input.newBranch); + + yield* executeGit( + "GitVcsDriver.renameBranch", + input.cwd, + ["branch", "-m", "--", input.oldBranch, targetBranch], + { + timeoutMs: 10_000, + fallbackErrorMessage: "git branch rename failed", + }, + ); + + return { branch: targetBranch }; + }, + ); + + const switchRef: GitVcsDriverShape["switchRef"] = Effect.fn("switchRef")(function* (input) { + const [localInputExists, remoteExists] = yield* Effect.all( + [ + executeGit( + "GitVcsDriver.switchRef.localInputExists", + input.cwd, + ["show-ref", "--verify", "--quiet", `refs/heads/${input.refName}`], + { + timeoutMs: 5_000, + allowNonZeroExit: true, + }, + ).pipe(Effect.map((result) => result.exitCode === 0)), + executeGit( + "GitVcsDriver.switchRef.remoteExists", + input.cwd, + ["show-ref", "--verify", "--quiet", `refs/remotes/${input.refName}`], + { + timeoutMs: 5_000, + allowNonZeroExit: true, + }, + ).pipe(Effect.map((result) => result.exitCode === 0)), + ], + { concurrency: "unbounded" }, + ); + + const localTrackingBranch = remoteExists + ? yield* executeGit( + "GitVcsDriver.switchRef.localTrackingBranch", + input.cwd, + ["for-each-ref", "--format=%(refname:short)\t%(upstream:short)", "refs/heads"], + { + timeoutMs: 5_000, + allowNonZeroExit: true, + }, + ).pipe( + Effect.map((result) => + result.exitCode === 0 + ? parseTrackingBranchByUpstreamRef(result.stdout, input.refName) + : null, + ), + ) + : null; + + const localTrackedBranchCandidate = deriveLocalBranchNameFromRemoteRef(input.refName); + const localTrackedBranchTargetExists = + remoteExists && localTrackedBranchCandidate + ? yield* executeGit( + "GitVcsDriver.switchRef.localTrackedBranchTargetExists", + input.cwd, + ["show-ref", "--verify", "--quiet", `refs/heads/${localTrackedBranchCandidate}`], + { + timeoutMs: 5_000, + allowNonZeroExit: true, + }, + ).pipe(Effect.map((result) => result.exitCode === 0)) + : false; + + const checkoutArgs = localInputExists + ? ["checkout", input.refName] + : remoteExists && !localTrackingBranch && localTrackedBranchTargetExists + ? ["checkout", input.refName] + : remoteExists && !localTrackingBranch + ? ["checkout", "--track", input.refName] + : remoteExists && localTrackingBranch + ? ["checkout", localTrackingBranch] + : ["checkout", input.refName]; + + yield* executeGit("GitVcsDriver.switchRef.checkout", input.cwd, checkoutArgs, { + timeoutMs: 10_000, + fallbackErrorMessage: "git checkout failed", + }); + + const refName = yield* runGitStdout("GitVcsDriver.switchRef.currentBranch", input.cwd, [ + "branch", + "--show-current", + ]).pipe(Effect.map((stdout) => stdout.trim() || null)); + + return { refName }; + }); + + const createRef: GitVcsDriverShape["createRef"] = Effect.fn("createRef")(function* (input) { + yield* executeGit("GitVcsDriver.createRef", input.cwd, ["branch", input.refName], { + timeoutMs: 10_000, + fallbackErrorMessage: "git branch create failed", + }); + if (input.switchRef) { + yield* switchRef({ cwd: input.cwd, refName: input.refName }); + } + + return { refName: input.refName }; + }); + + const initRepo: GitVcsDriverShape["initRepo"] = (input) => + executeGit("GitVcsDriver.initRepo", input.cwd, ["init"], { + timeoutMs: 10_000, + fallbackErrorMessage: "git init failed", + }).pipe(Effect.asVoid); + + const listLocalBranchNames: GitVcsDriverShape["listLocalBranchNames"] = (cwd) => + runGitStdout("GitVcsDriver.listLocalBranchNames", cwd, [ + "branch", + "--list", + "--no-column", + "--format=%(refname:short)", + ]).pipe( + Effect.map((stdout) => + stdout + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0), + ), + ); + + return { + execute, + status, + statusDetails, + statusDetailsLocal, + prepareCommitContext, + commit, + pushCurrentBranch, + pullCurrentBranch, + readRangeContext, + readConfigValue, + listRefs, + createWorktree, + fetchPullRequestBranch, + ensureRemote, + fetchRemoteBranch, + setBranchUpstream, + removeWorktree, + renameBranch, + createRef, + switchRef, + initRepo, + listLocalBranchNames, + } satisfies GitVcsDriverShape; +}); diff --git a/apps/server/src/vcs/VcsDriver.ts b/apps/server/src/vcs/VcsDriver.ts new file mode 100644 index 0000000000000000000000000000000000000000..14e5f68ef45c2954dfb2148effd03b0c7d716e38 --- /dev/null +++ b/apps/server/src/vcs/VcsDriver.ts @@ -0,0 +1,31 @@ +import { Context, type Effect } from "effect"; + +import type { + VcsDriverCapabilities, + VcsError, + VcsInitInput, + VcsListRemotesResult, + VcsListWorkspaceFilesResult, + VcsRepositoryIdentity, +} from "@t3tools/contracts"; +import type { VcsProcessInput, VcsProcessOutput } from "./VcsProcess.ts"; + +export interface VcsDriverShape { + readonly capabilities: VcsDriverCapabilities; + readonly execute: ( + input: Omit, + ) => Effect.Effect; + readonly detectRepository: (cwd: string) => Effect.Effect; + readonly isInsideWorkTree: (cwd: string) => Effect.Effect; + readonly listWorkspaceFiles: ( + cwd: string, + ) => Effect.Effect; + readonly listRemotes: (cwd: string) => Effect.Effect; + readonly filterIgnoredPaths: ( + cwd: string, + relativePaths: ReadonlyArray, + ) => Effect.Effect, VcsError>; + readonly initRepository: (input: VcsInitInput) => Effect.Effect; +} + +export class VcsDriver extends Context.Service()("t3/vcs/VcsDriver") {} diff --git a/apps/server/src/vcs/VcsDriverRegistry.test.ts b/apps/server/src/vcs/VcsDriverRegistry.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..8e482c46b818028ae714042a2384493ca5e105cf --- /dev/null +++ b/apps/server/src/vcs/VcsDriverRegistry.test.ts @@ -0,0 +1,87 @@ +import { assert, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { describe } from "vitest"; + +import { VcsProcess, type VcsProcessInput, type VcsProcessOutput } from "./VcsProcess.ts"; +import { VcsProjectConfig } from "./VcsProjectConfig.ts"; +import { VcsDriverRegistry, make as makeVcsDriverRegistry } from "./VcsDriverRegistry.ts"; + +const processOutput = (stdout: string): VcsProcessOutput => ({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, +}); + +describe("VcsDriverRegistry", () => { + it.effect("routes directly by VCS driver kind for non-repository workflows", () => { + const layer = Layer.effect(VcsDriverRegistry, makeVcsDriverRegistry()).pipe( + Layer.provide( + Layer.mock(VcsProjectConfig)({ + resolveKind: (input) => Effect.succeed(input.requestedKind ?? "auto"), + }), + ), + Layer.provide( + Layer.mock(VcsProcess)({ + run: () => Effect.succeed(processOutput("")), + }), + ), + ); + + return Effect.gen(function* () { + const registry = yield* VcsDriverRegistry; + const driver = yield* registry.get("git"); + + assert.strictEqual(driver.capabilities.kind, "git"); + }).pipe(Effect.provide(layer)); + }); + + it.effect("caches repository detection for repeated resolves in the same cwd and kind", () => { + const calls: VcsProcessInput[] = []; + const layer = Layer.effect(VcsDriverRegistry, makeVcsDriverRegistry()).pipe( + Layer.provide( + Layer.mock(VcsProjectConfig)({ + resolveKind: (input) => Effect.succeed(input.requestedKind ?? "auto"), + }), + ), + Layer.provide( + Layer.mock(VcsProcess)({ + run: (input) => + Effect.sync(() => { + calls.push(input); + const command = input.args.join(" "); + if (command === "rev-parse --is-inside-work-tree") { + return processOutput("true\n"); + } + if (command === "rev-parse --show-toplevel") { + return processOutput("/repo\n"); + } + if (command === "rev-parse --git-common-dir") { + return processOutput("/repo/.git\n"); + } + return processOutput(""); + }), + }), + ), + ); + + return Effect.gen(function* () { + const registry = yield* VcsDriverRegistry; + const first = yield* registry.resolve({ cwd: "/repo", requestedKind: "git" }); + const second = yield* registry.resolve({ cwd: "/repo", requestedKind: "git" }); + + assert.equal(first.repository.rootPath, "/repo"); + assert.equal(second.repository.rootPath, "/repo"); + assert.deepStrictEqual( + calls.map((call) => call.args.join(" ")), + [ + "rev-parse --is-inside-work-tree", + "rev-parse --show-toplevel", + "rev-parse --git-common-dir", + ], + ); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/server/src/vcs/VcsDriverRegistry.ts b/apps/server/src/vcs/VcsDriverRegistry.ts new file mode 100644 index 0000000000000000000000000000000000000000..7798d63a087d2db073b2634df8b28baa05f9331d --- /dev/null +++ b/apps/server/src/vcs/VcsDriverRegistry.ts @@ -0,0 +1,155 @@ +import { Cache, Context, Duration, Effect, Exit, Layer } from "effect"; + +import type { VcsDriverKind, VcsError, VcsRepositoryIdentity } from "@t3tools/contracts"; +import { VcsUnsupportedOperationError } from "@t3tools/contracts"; +import * as GitVcsDriver from "./GitVcsDriver.ts"; +import * as VcsProjectConfig from "./VcsProjectConfig.ts"; +import type { VcsDriverShape } from "./VcsDriver.ts"; + +const DETECTION_CACHE_CAPACITY = 2_048; +const DETECTION_CACHE_TTL = Duration.seconds(2); + +export interface VcsDriverResolveInput { + readonly cwd: string; + readonly requestedKind?: VcsDriverKind | "auto"; +} + +export interface VcsDriverHandle { + readonly kind: VcsDriverKind; + readonly repository: VcsRepositoryIdentity; + readonly driver: VcsDriverShape; +} + +export interface VcsDriverRegistryShape { + readonly get: (kind: VcsDriverKind) => Effect.Effect; + readonly detect: ( + input: VcsDriverResolveInput, + ) => Effect.Effect; + readonly resolve: (input: VcsDriverResolveInput) => Effect.Effect; +} + +export class VcsDriverRegistry extends Context.Service()( + "t3/vcs/VcsDriverRegistry", +) {} + +const unsupported = (operation: string, kind: VcsDriverKind, detail: string) => + new VcsUnsupportedOperationError({ + operation, + kind, + detail, + }); + +function detectionCacheKey(input: { + readonly cwd: string; + readonly requestedKind: VcsDriverKind | "auto"; +}): string { + return `${input.requestedKind}\0${input.cwd}`; +} + +function parseDetectionCacheKey(key: string): { + readonly cwd: string; + readonly requestedKind: VcsDriverKind | "auto"; +} { + const separatorIndex = key.indexOf("\0"); + if (separatorIndex === -1) { + return { + cwd: key, + requestedKind: "auto", + }; + } + return { + requestedKind: key.slice(0, separatorIndex) as VcsDriverKind | "auto", + cwd: key.slice(separatorIndex + 1), + }; +} + +export const make = Effect.fn("makeVcsDriverRegistry")(function* () { + const projectConfig = yield* VcsProjectConfig.VcsProjectConfig; + const git = yield* GitVcsDriver.makeVcsDriverShape(); + const drivers: Partial> = { + git, + }; + + const get: VcsDriverRegistryShape["get"] = (kind) => { + const driver = drivers[kind]; + if (!driver) { + return Effect.fail( + unsupported("VcsDriverRegistry.get", kind, `No ${kind} VCS driver is registered.`), + ); + } + return Effect.succeed(driver); + }; + + const detectWithDriver = Effect.fn("VcsDriverRegistry.detectWithDriver")(function* ( + kind: VcsDriverKind, + driver: VcsDriverShape, + cwd: string, + ) { + const repository = yield* driver.detectRepository(cwd); + if (!repository) { + return null; + } + return { + kind, + repository, + driver, + } satisfies VcsDriverHandle; + }); + + const detectResolvedKind = Effect.fn("VcsDriverRegistry.detectResolvedKind")(function* (input: { + readonly cwd: string; + readonly requestedKind: VcsDriverKind | "auto"; + }) { + const requestedKind = input.requestedKind; + + if (requestedKind !== "auto" && requestedKind !== "unknown") { + const driver = yield* get(requestedKind); + return yield* detectWithDriver(requestedKind, driver, input.cwd); + } + + return yield* detectWithDriver("git", git, input.cwd); + }); + + const detectionCache = yield* Cache.makeWith( + (key) => detectResolvedKind(parseDetectionCacheKey(key)), + { + capacity: DETECTION_CACHE_CAPACITY, + timeToLive: (exit) => (Exit.isSuccess(exit) ? DETECTION_CACHE_TTL : Duration.zero), + }, + ); + + const detect: VcsDriverRegistryShape["detect"] = Effect.fn("VcsDriverRegistry.detect")( + function* (input) { + const requestedKind = yield* projectConfig.resolveKind(input); + return yield* Cache.get(detectionCache, detectionCacheKey({ cwd: input.cwd, requestedKind })); + }, + ); + + const resolve: VcsDriverRegistryShape["resolve"] = Effect.fn("VcsDriverRegistry.resolve")( + function* (input) { + const detected = yield* detect(input); + if (detected) { + return detected; + } + + const requestedKind = input.requestedKind ?? "auto"; + return yield* unsupported( + "VcsDriverRegistry.resolve", + requestedKind === "auto" ? "unknown" : requestedKind, + requestedKind === "auto" + ? `No supported VCS repository was detected at ${input.cwd}.` + : `No ${requestedKind} repository was detected at ${input.cwd}.`, + ); + }, + ); + + return VcsDriverRegistry.of({ + get, + detect, + resolve, + }); +}); + +export const layer = Layer.effect(VcsDriverRegistry, make()).pipe( + Layer.provide(VcsProjectConfig.layer), +); diff --git a/apps/server/src/vcs/VcsProcess.ts b/apps/server/src/vcs/VcsProcess.ts new file mode 100644 index 0000000000000000000000000000000000000000..33e03a2551df22ca0848841990b16e0383d4a05f --- /dev/null +++ b/apps/server/src/vcs/VcsProcess.ts @@ -0,0 +1,259 @@ +import { Duration, Context, Effect, Layer, Option, PlatformError, Sink, Stream } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { + VcsOutputDecodeError, + type VcsError, + VcsProcessExitError, + VcsProcessSpawnError, + VcsProcessTimeoutError, +} from "@t3tools/contracts"; + +export interface VcsProcessInput { + readonly operation: string; + readonly command: string; + readonly args: ReadonlyArray; + readonly cwd: string; + readonly stdin?: string; + readonly env?: NodeJS.ProcessEnv; + readonly allowNonZeroExit?: boolean; + readonly timeoutMs?: number; + readonly maxOutputBytes?: number; + readonly truncateOutputAtMaxBytes?: boolean; +} + +export interface VcsProcessOutput { + readonly exitCode: ChildProcessSpawner.ExitCode; + readonly stdout: string; + readonly stderr: string; + readonly stdoutTruncated: boolean; + readonly stderrTruncated: boolean; +} + +export interface VcsProcessCollectedText { + readonly text: string; + readonly truncated: boolean; +} + +export interface VcsProcessHandle { + readonly pid: ChildProcessSpawner.ProcessId; + readonly stdin: Sink.Sink; + readonly stdout: Stream.Stream; + readonly stderr: Stream.Stream; + readonly exitCode: Effect.Effect; + readonly writeStdin: (input: string) => Effect.Effect; +} + +export interface VcsProcessShape { + readonly withProcess: ( + input: VcsProcessInput, + use: (handle: VcsProcessHandle) => Effect.Effect, + ) => Effect.Effect; + readonly run: (input: VcsProcessInput) => Effect.Effect; +} + +export class VcsProcess extends Context.Service()( + "t3/vcs/VcsProcess", +) {} + +const DEFAULT_TIMEOUT_MS = 30_000; +const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; +const OUTPUT_TRUNCATED_MARKER = "\n\n[truncated]"; + +function commandLabel(command: string, args: ReadonlyArray): string { + return [command, ...args].join(" "); +} + +function outputDecodeError( + input: VcsProcessInput, + detail: string, + cause: unknown, +): VcsOutputDecodeError { + return new VcsOutputDecodeError({ + operation: input.operation, + command: commandLabel(input.command, input.args), + cwd: input.cwd, + detail, + cause, + }); +} + +export const collectText = Effect.fn("VcsProcess.collectText")(function* (input: { + readonly operation: string; + readonly command: string; + readonly cwd: string; + readonly stream: Stream.Stream; + readonly maxOutputBytes?: number; + readonly truncateOutputAtMaxBytes?: boolean; +}) { + const decoder = new TextDecoder(); + let text = ""; + let bytes = 0; + let truncated = false; + const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; + const truncateOutputAtMaxBytes = input.truncateOutputAtMaxBytes ?? false; + + yield* Stream.runForEach(input.stream, (chunk) => + Effect.sync(() => { + if (truncated) return; + + const remainingBytes = maxOutputBytes - bytes; + if (remainingBytes <= 0) { + truncated = true; + if (truncateOutputAtMaxBytes) { + text += OUTPUT_TRUNCATED_MARKER; + } + return; + } + + const nextChunk = chunk.byteLength > remainingBytes ? chunk.slice(0, remainingBytes) : chunk; + text += decoder.decode(nextChunk, { stream: true }); + bytes += nextChunk.byteLength; + + if (chunk.byteLength > remainingBytes) { + truncated = true; + if (truncateOutputAtMaxBytes) { + text += OUTPUT_TRUNCATED_MARKER; + } + } + }), + ); + + if (!truncated) { + text += decoder.decode(); + } + + return { text, truncated } satisfies VcsProcessCollectedText; +}); + +export const make = Effect.fn("makeVcsProcess")(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + + const spawn = Effect.fn("VcsProcess.spawn")(function* (input: VcsProcessInput) { + const label = commandLabel(input.command, input.args); + const child = yield* spawner + .spawn( + ChildProcess.make(input.command, [...input.args], { + cwd: input.cwd, + env: { + ...process.env, + ...input.env, + }, + }), + ) + .pipe( + Effect.mapError( + (cause) => + new VcsProcessSpawnError({ + operation: input.operation, + command: label, + cwd: input.cwd, + cause, + }), + ), + ); + yield* Effect.addFinalizer(() => child.kill().pipe(Effect.ignore)); + + const mapStreamError = (streamName: "stdout" | "stderr") => + Stream.mapError((cause: PlatformError.PlatformError) => + outputDecodeError(input, `failed to read process ${streamName}`, cause), + ); + const mapEffectError = (detail: string) => + Effect.mapError((cause: PlatformError.PlatformError) => + outputDecodeError(input, detail, cause), + ); + const writeStdin = (stdin: string) => + Stream.run(Stream.encodeText(Stream.make(stdin)), child.stdin).pipe( + mapEffectError("failed to write process stdin"), + ); + + return { + pid: child.pid, + stdin: child.stdin.pipe( + Sink.mapError((cause) => outputDecodeError(input, "failed to write process stdin", cause)), + ), + stdout: child.stdout.pipe(mapStreamError("stdout")), + stderr: child.stderr.pipe(mapStreamError("stderr")), + exitCode: child.exitCode.pipe(mapEffectError("failed to read process exit code")), + writeStdin, + } satisfies VcsProcessHandle; + }); + + const withProcess: VcsProcessShape["withProcess"] = (input, use) => + Effect.scoped(spawn(input).pipe(Effect.flatMap(use))); + + const run = Effect.fn("VcsProcess.run")(function* (input: VcsProcessInput) { + const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; + const label = commandLabel(input.command, input.args); + + const runProcess = Effect.gen(function* () { + const [stdout, stderr, exitCode] = yield* withProcess(input, (child) => + Effect.all( + [ + collectText({ + operation: input.operation, + command: label, + cwd: input.cwd, + stream: child.stdout, + maxOutputBytes, + truncateOutputAtMaxBytes: input.truncateOutputAtMaxBytes ?? false, + }), + collectText({ + operation: input.operation, + command: label, + cwd: input.cwd, + stream: child.stderr, + maxOutputBytes, + truncateOutputAtMaxBytes: input.truncateOutputAtMaxBytes ?? false, + }), + child.exitCode, + input.stdin === undefined ? Effect.void : child.writeStdin(input.stdin), + ], + { concurrency: "unbounded" }, + ), + ).pipe(Effect.map(([stdout, stderr, exitCode]) => [stdout, stderr, exitCode] as const)); + + if (!input.allowNonZeroExit && exitCode !== 0) { + return yield* new VcsProcessExitError({ + operation: input.operation, + command: label, + cwd: input.cwd, + exitCode, + detail: stderr.text.trim() || `${label} exited with code ${exitCode}.`, + }); + } + + return { + exitCode, + stdout: stdout.text, + stderr: stderr.text, + stdoutTruncated: stdout.truncated, + stderrTruncated: stderr.truncated, + } satisfies VcsProcessOutput; + }); + + return yield* runProcess.pipe( + Effect.scoped, + Effect.timeoutOption(Duration.millis(timeoutMs)), + Effect.flatMap((result) => + Option.match(result, { + onSome: Effect.succeed, + onNone: () => + Effect.fail( + new VcsProcessTimeoutError({ + operation: input.operation, + command: label, + cwd: input.cwd, + timeoutMs, + }), + ), + }), + ), + ); + }); + + return VcsProcess.of({ withProcess, run }); +}); + +export const layer = Layer.effect(VcsProcess, make()); diff --git a/apps/server/src/vcs/VcsProjectConfig.test.ts b/apps/server/src/vcs/VcsProjectConfig.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..95181e94fb269d30b4d5a05bad734dc94849562d --- /dev/null +++ b/apps/server/src/vcs/VcsProjectConfig.test.ts @@ -0,0 +1,67 @@ +import { assert, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { Effect, FileSystem, Layer, Path } from "effect"; +import { describe } from "vitest"; + +import { VcsProjectConfig, layer as VcsProjectConfigLayer } from "./VcsProjectConfig.ts"; + +const TestLayer = VcsProjectConfigLayer.pipe( + Layer.provide(NodeServices.layer), + Layer.provideMerge(NodeServices.layer), +); + +describe("VcsProjectConfig", () => { + it.layer(TestLayer)("uses an explicit requested VCS kind before config", (it) => { + it.effect("returns the requested kind", () => + Effect.gen(function* () { + const config = yield* VcsProjectConfig; + const kind = yield* config.resolveKind({ + cwd: "/repo", + requestedKind: "jj", + }); + + assert.equal(kind, "jj"); + }), + ); + }); + + it.layer(TestLayer)("discovers .t3code/vcs.json from nested workspaces", (it) => { + it.effect("returns the configured kind", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-vcs-config-test-", + }); + const configDir = path.join(root, ".t3code"); + const nested = path.join(root, "packages", "app"); + yield* fileSystem.makeDirectory(configDir, { recursive: true }); + yield* fileSystem.makeDirectory(nested, { recursive: true }); + yield* fileSystem.writeFileString( + path.join(configDir, "vcs.json"), + JSON.stringify({ vcs: { kind: "jj" } }), + ); + + const config = yield* VcsProjectConfig; + const kind = yield* config.resolveKind({ cwd: nested }); + + assert.equal(kind, "jj"); + }), + ); + }); + + it.layer(TestLayer)("falls back to auto when no config exists", (it) => { + it.effect("returns auto", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-vcs-config-test-", + }); + const config = yield* VcsProjectConfig; + const kind = yield* config.resolveKind({ cwd: root }); + + assert.equal(kind, "auto"); + }), + ); + }); +}); diff --git a/apps/server/src/vcs/VcsProjectConfig.ts b/apps/server/src/vcs/VcsProjectConfig.ts new file mode 100644 index 0000000000000000000000000000000000000000..6b4ba008d4ea4f414ba64296c29f38b93a9248e5 --- /dev/null +++ b/apps/server/src/vcs/VcsProjectConfig.ts @@ -0,0 +1,117 @@ +import { Context, Effect, FileSystem, Layer, Path, Schema } from "effect"; + +import { VcsDriverKind, type VcsDriverKind as VcsDriverKindType } from "@t3tools/contracts"; + +const ProjectVcsConfig = Schema.Struct({ + vcs: Schema.optional( + Schema.Struct({ + kind: Schema.optional(VcsDriverKind), + }), + ), + vcsKind: Schema.optional(VcsDriverKind), +}); + +interface ProjectVcsConfigFile { + readonly vcs?: + | { + readonly kind?: VcsDriverKindType | undefined; + } + | undefined; + readonly vcsKind?: VcsDriverKindType | undefined; +} + +export interface VcsProjectConfigResolveInput { + readonly cwd: string; + readonly requestedKind?: VcsDriverKindType | "auto"; +} + +export interface VcsProjectConfigShape { + readonly resolveKind: ( + input: VcsProjectConfigResolveInput, + ) => Effect.Effect; +} + +export class VcsProjectConfig extends Context.Service()( + "t3/vcs/VcsProjectConfig", +) {} + +function configuredKind(config: ProjectVcsConfigFile): VcsDriverKindType | "auto" { + return config.vcs?.kind ?? config.vcsKind ?? "auto"; +} + +function parseConfig(raw: string): ProjectVcsConfigFile | null { + try { + const parsed = JSON.parse(raw) as unknown; + return Schema.is(ProjectVcsConfig)(parsed) ? parsed : null; + } catch { + return null; + } +} + +export const make = Effect.fn("makeVcsProjectConfig")(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const findConfigPath = Effect.fn("VcsProjectConfig.findConfigPath")(function* (cwd: string) { + let current = cwd; + while (true) { + const candidate = path.join(current, ".t3code", "vcs.json"); + if (yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false))) { + return candidate; + } + + const parent = path.dirname(current); + if (parent === current) { + return null; + } + current = parent; + } + }); + + const readConfiguredKind = Effect.fn("VcsProjectConfig.readConfiguredKind")(function* ( + configPath: string, + ) { + const raw = yield* fileSystem.readFileString(configPath).pipe( + Effect.catch((error) => + Effect.logWarning("failed to read VCS project config", { + configPath, + error, + }).pipe(Effect.as(null)), + ), + ); + if (raw === null) { + return "auto" as const; + } + + const parsed = parseConfig(raw); + if (parsed === null) { + yield* Effect.logWarning("invalid VCS project config", { + configPath, + }); + return "auto" as const; + } + + return configuredKind(parsed); + }); + + const resolveKind: VcsProjectConfigShape["resolveKind"] = Effect.fn( + "VcsProjectConfig.resolveKind", + )(function* (input) { + if (input.requestedKind !== undefined && input.requestedKind !== "auto") { + return input.requestedKind; + } + + const configPath = yield* findConfigPath(input.cwd); + if (configPath === null) { + return "auto"; + } + + return yield* readConfiguredKind(configPath); + }); + + return VcsProjectConfig.of({ + resolveKind, + }); +}); + +export const layer = Layer.effect(VcsProjectConfig, make()); diff --git a/apps/server/src/vcs/VcsProvisioningService.test.ts b/apps/server/src/vcs/VcsProvisioningService.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..5f936e7a1d789152ac1fbf6951adf8930a1121a0 --- /dev/null +++ b/apps/server/src/vcs/VcsProvisioningService.test.ts @@ -0,0 +1,94 @@ +import { assert, it } from "@effect/vitest"; +import { DateTime, Effect, Layer, Option } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import type { VcsDriverShape } from "./VcsDriver.ts"; +import { VcsDriverRegistry } from "./VcsDriverRegistry.ts"; +import { VcsProvisioningService, layer } from "./VcsProvisioningService.ts"; + +const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); + +function makeDriver(calls: string[]): VcsDriverShape { + return { + capabilities: { + kind: "git", + supportsWorktrees: true, + supportsBookmarks: false, + supportsAtomicSnapshot: false, + supportsPushDefaultRemote: true, + ignoreClassifier: "native", + }, + execute: () => + Effect.succeed({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: "", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }), + detectRepository: () => Effect.succeed(null), + isInsideWorkTree: () => Effect.succeed(false), + listWorkspaceFiles: () => + Effect.succeed({ + paths: [], + truncated: false, + freshness: { + source: "live-local", + observedAt: TEST_EPOCH, + expiresAt: Option.none(), + }, + }), + listRemotes: () => + Effect.succeed({ + remotes: [], + freshness: { + source: "live-local", + observedAt: TEST_EPOCH, + expiresAt: Option.none(), + }, + }), + filterIgnoredPaths: (_cwd, relativePaths) => Effect.succeed(relativePaths), + initRepository: (input) => + Effect.sync(() => { + calls.push(`${input.kind ?? "default"}:${input.cwd}`); + }), + }; +} + +it.effect("routes repository initialization through an explicit VCS driver kind", () => { + const calls: string[] = []; + const driver = makeDriver(calls); + const testLayer = layer.pipe( + Layer.provide( + Layer.mock(VcsDriverRegistry)({ + get: (kind) => (kind === "git" ? Effect.succeed(driver) : Effect.die("unexpected kind")), + }), + ), + ); + + return Effect.gen(function* () { + const provisioning = yield* VcsProvisioningService; + yield* provisioning.initRepository({ cwd: "/repo", kind: "git" }); + + assert.deepStrictEqual(calls, ["git:/repo"]); + }).pipe(Effect.provide(testLayer)); +}); + +it.effect("defaults repository initialization to Git until callers choose a VCS kind", () => { + const calls: string[] = []; + const driver = makeDriver(calls); + const testLayer = layer.pipe( + Layer.provide( + Layer.mock(VcsDriverRegistry)({ + get: (kind) => (kind === "git" ? Effect.succeed(driver) : Effect.die("unexpected kind")), + }), + ), + ); + + return Effect.gen(function* () { + const provisioning = yield* VcsProvisioningService; + yield* provisioning.initRepository({ cwd: "/repo" }); + + assert.deepStrictEqual(calls, ["default:/repo"]); + }).pipe(Effect.provide(testLayer)); +}); diff --git a/apps/server/src/vcs/VcsProvisioningService.ts b/apps/server/src/vcs/VcsProvisioningService.ts new file mode 100644 index 0000000000000000000000000000000000000000..7d5b93dd14da135587feac44bb3abc6a9b5bf764 --- /dev/null +++ b/apps/server/src/vcs/VcsProvisioningService.ts @@ -0,0 +1,54 @@ +import { Context, Effect, Layer } from "effect"; + +import { + type VcsDriverKind, + type VcsError, + type VcsInitInput, + VcsUnsupportedOperationError, +} from "@t3tools/contracts"; +import { VcsDriverRegistry } from "./VcsDriverRegistry.ts"; + +export interface VcsProvisioningServiceShape { + readonly initRepository: (input: VcsInitInput) => Effect.Effect; +} + +export class VcsProvisioningService extends Context.Service< + VcsProvisioningService, + VcsProvisioningServiceShape +>()("t3/vcs/VcsProvisioningService") {} + +function resolveRequestedKind( + kind: VcsDriverKind | undefined, +): Effect.Effect { + if (kind === undefined) { + return Effect.succeed("git"); + } + if (kind === "unknown") { + return Effect.fail( + new VcsUnsupportedOperationError({ + operation: "VcsProvisioningService.resolveRequestedKind", + kind, + detail: "A concrete VCS driver kind is required for repository provisioning.", + }), + ); + } + return Effect.succeed(kind); +} + +export const make = Effect.fn("makeVcsProvisioningService")(function* () { + const registry = yield* VcsDriverRegistry; + + const initRepository: VcsProvisioningServiceShape["initRepository"] = Effect.fn( + "VcsProvisioningService.initRepository", + )(function* (input) { + const kind = yield* resolveRequestedKind(input.kind); + const driver = yield* registry.get(kind); + return yield* driver.initRepository(input); + }); + + return VcsProvisioningService.of({ + initRepository, + }); +}); + +export const layer = Layer.effect(VcsProvisioningService, make()); diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..e8bcf4bdf03c95829c8fc5ffa7071e8638166ce3 --- /dev/null +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -0,0 +1,364 @@ +import { assert, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { Deferred, Effect, Exit, FileSystem, Layer, Option, Path, Scope, Stream } from "effect"; +import type { + VcsStatusLocalResult, + VcsStatusRemoteResult, + VcsStatusResult, + VcsStatusStreamEvent, +} from "@t3tools/contracts"; +import { describe } from "vitest"; + +import { + VcsStatusBroadcaster, + layer as VcsStatusBroadcasterLayer, +} from "./VcsStatusBroadcaster.ts"; +import { GitWorkflowService, type GitWorkflowServiceShape } from "../git/GitWorkflowService.ts"; + +const baseLocalStatus: VcsStatusLocalResult = { + isRepo: true, + sourceControlProvider: { + kind: "github", + name: "GitHub", + baseUrl: "https://github.com", + }, + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "feature/status-broadcast", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, +}; + +const baseRemoteStatus: VcsStatusRemoteResult = { + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + pr: null, +}; + +const baseStatus: VcsStatusResult = { + ...baseLocalStatus, + ...baseRemoteStatus, +}; + +function makeTestLayer(state: { + currentLocalStatus: VcsStatusLocalResult; + currentRemoteStatus: VcsStatusRemoteResult | null; + localStatusCalls: number; + remoteStatusCalls: number; + localInvalidationCalls: number; + remoteInvalidationCalls: number; +}) { + return VcsStatusBroadcasterLayer.pipe( + Layer.provide( + Layer.mock(GitWorkflowService)({ + localStatus: () => + Effect.sync(() => { + state.localStatusCalls += 1; + return state.currentLocalStatus; + }), + remoteStatus: () => + Effect.sync(() => { + state.remoteStatusCalls += 1; + return state.currentRemoteStatus; + }), + invalidateLocalStatus: () => + Effect.sync(() => { + state.localInvalidationCalls += 1; + }), + invalidateRemoteStatus: () => + Effect.sync(() => { + state.remoteInvalidationCalls += 1; + }), + }), + ), + ); +} + +describe("VcsStatusBroadcaster", () => { + it.effect("reuses the cached VCS status across repeated reads", () => { + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: baseRemoteStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + }; + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster; + + const first = yield* broadcaster.getStatus({ cwd: "/repo" }); + const second = yield* broadcaster.getStatus({ cwd: "/repo" }); + + assert.deepStrictEqual(first, baseStatus); + assert.deepStrictEqual(second, baseStatus); + assert.equal(state.localStatusCalls, 1); + assert.equal(state.remoteStatusCalls, 1); + assert.equal(state.localInvalidationCalls, 0); + assert.equal(state.remoteInvalidationCalls, 0); + }).pipe(Effect.provide(makeTestLayer(state))); + }); + + it.effect("refreshes the cached snapshot after explicit invalidation", () => { + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: baseRemoteStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + }; + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster; + const initial = yield* broadcaster.getStatus({ cwd: "/repo" }); + + state.currentLocalStatus = { + ...baseLocalStatus, + refName: "feature/updated-status", + }; + state.currentRemoteStatus = { + ...baseRemoteStatus, + aheadCount: 2, + }; + const refreshed = yield* broadcaster.refreshStatus("/repo"); + const cached = yield* broadcaster.getStatus({ cwd: "/repo" }); + + assert.deepStrictEqual(initial, baseStatus); + assert.deepStrictEqual(refreshed, { + ...state.currentLocalStatus, + ...state.currentRemoteStatus, + }); + assert.deepStrictEqual(cached, { + ...state.currentLocalStatus, + ...state.currentRemoteStatus, + }); + assert.equal(state.localStatusCalls, 2); + assert.equal(state.remoteStatusCalls, 2); + assert.equal(state.localInvalidationCalls, 1); + assert.equal(state.remoteInvalidationCalls, 1); + }).pipe(Effect.provide(makeTestLayer(state))); + }); + + it.effect("refreshes only the cached local snapshot when requested", () => { + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: baseRemoteStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + }; + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster; + const initial = yield* broadcaster.getStatus({ cwd: "/repo" }); + + state.currentLocalStatus = { + ...baseLocalStatus, + refName: "feature/local-only-refresh", + hasWorkingTreeChanges: true, + }; + + const refreshedLocal = yield* broadcaster.refreshLocalStatus("/repo"); + const cached = yield* broadcaster.getStatus({ cwd: "/repo" }); + + assert.deepStrictEqual(initial, baseStatus); + assert.deepStrictEqual(refreshedLocal, state.currentLocalStatus); + assert.deepStrictEqual(cached, { + ...state.currentLocalStatus, + ...baseRemoteStatus, + }); + assert.equal(state.localStatusCalls, 2); + assert.equal(state.remoteStatusCalls, 1); + assert.equal(state.localInvalidationCalls, 1); + assert.equal(state.remoteInvalidationCalls, 0); + }).pipe(Effect.provide(makeTestLayer(state))); + }); + + it.effect("normalizes symlinked CWDs before cache lookup and workflow calls", () => { + const seenCwds: string[] = []; + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: baseRemoteStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + }; + const testLayer = VcsStatusBroadcasterLayer.pipe( + Layer.provide( + Layer.mock(GitWorkflowService)({ + localStatus: (input) => + Effect.sync(() => { + seenCwds.push(input.cwd); + state.localStatusCalls += 1; + return state.currentLocalStatus; + }), + remoteStatus: (input) => + Effect.sync(() => { + seenCwds.push(input.cwd); + state.remoteStatusCalls += 1; + return state.currentRemoteStatus; + }), + invalidateLocalStatus: () => + Effect.sync(() => { + state.localInvalidationCalls += 1; + }), + invalidateRemoteStatus: () => + Effect.sync(() => { + state.remoteInvalidationCalls += 1; + }), + } satisfies Partial), + ), + ); + + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const realDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-vcs-status-real-", + }); + const linkParent = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-vcs-status-link-", + }); + const linkDir = path.join(linkParent, "repo-link"); + yield* fileSystem.symlink(realDir, linkDir); + const realPath = yield* fileSystem.realPath(realDir); + + const broadcaster = yield* VcsStatusBroadcaster; + yield* broadcaster.getStatus({ cwd: linkDir }); + yield* broadcaster.getStatus({ cwd: realDir }); + + assert.deepStrictEqual(seenCwds, [realPath, realPath]); + assert.equal(state.localStatusCalls, 1); + assert.equal(state.remoteStatusCalls, 1); + }).pipe(Effect.provide(Layer.mergeAll(testLayer, NodeServices.layer))); + }); + + it.effect("streams a local snapshot first and remote updates later", () => { + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: baseRemoteStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + }; + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster; + const snapshotDeferred = yield* Deferred.make(); + const remoteUpdatedDeferred = yield* Deferred.make(); + yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) => { + if (event._tag === "snapshot") { + return Deferred.succeed(snapshotDeferred, event).pipe(Effect.ignore); + } + if (event._tag === "remoteUpdated") { + return Deferred.succeed(remoteUpdatedDeferred, event).pipe(Effect.ignore); + } + return Effect.void; + }).pipe(Effect.forkScoped); + + const snapshot = yield* Deferred.await(snapshotDeferred); + yield* broadcaster.refreshStatus("/repo"); + const remoteUpdated = yield* Deferred.await(remoteUpdatedDeferred); + + assert.deepStrictEqual(snapshot, { + _tag: "snapshot", + local: baseLocalStatus, + remote: null, + } satisfies VcsStatusStreamEvent); + assert.deepStrictEqual(remoteUpdated, { + _tag: "remoteUpdated", + remote: baseRemoteStatus, + } satisfies VcsStatusStreamEvent); + }).pipe(Effect.provide(makeTestLayer(state))); + }); + + it.effect("stops the remote poller after the last stream subscriber disconnects", () => { + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: baseRemoteStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + }; + let remoteInterruptedDeferred: Deferred.Deferred | null = null; + let remoteStartedDeferred: Deferred.Deferred | null = null; + const testLayer = VcsStatusBroadcasterLayer.pipe( + Layer.provide( + Layer.mock(GitWorkflowService)({ + localStatus: () => + Effect.sync(() => { + state.localStatusCalls += 1; + return state.currentLocalStatus; + }), + remoteStatus: () => + Effect.sync(() => { + state.remoteStatusCalls += 1; + }).pipe( + Effect.andThen( + remoteStartedDeferred + ? Deferred.succeed(remoteStartedDeferred, undefined).pipe(Effect.ignore) + : Effect.void, + ), + Effect.andThen(Effect.never as Effect.Effect), + Effect.onInterrupt(() => + remoteInterruptedDeferred + ? Deferred.succeed(remoteInterruptedDeferred, undefined).pipe(Effect.ignore) + : Effect.void, + ), + ), + invalidateLocalStatus: () => + Effect.sync(() => { + state.localInvalidationCalls += 1; + }), + invalidateRemoteStatus: () => + Effect.sync(() => { + state.remoteInvalidationCalls += 1; + }), + } satisfies Partial), + ), + ); + + return Effect.gen(function* () { + const remoteInterrupted = yield* Deferred.make(); + const remoteStarted = yield* Deferred.make(); + remoteInterruptedDeferred = remoteInterrupted; + remoteStartedDeferred = remoteStarted; + + const broadcaster = yield* VcsStatusBroadcaster; + const firstSnapshot = yield* Deferred.make(); + const secondSnapshot = yield* Deferred.make(); + const firstScope = yield* Scope.make(); + const secondScope = yield* Scope.make(); + yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) => + event._tag === "snapshot" + ? Deferred.succeed(firstSnapshot, event).pipe(Effect.ignore) + : Effect.void, + ).pipe(Effect.forkIn(firstScope)); + yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) => + event._tag === "snapshot" + ? Deferred.succeed(secondSnapshot, event).pipe(Effect.ignore) + : Effect.void, + ).pipe(Effect.forkIn(secondScope)); + + yield* Deferred.await(firstSnapshot); + yield* Deferred.await(secondSnapshot); + yield* Deferred.await(remoteStarted); + + assert.equal(state.remoteStatusCalls, 1); + + yield* Scope.close(firstScope, Exit.void); + assert.isTrue(Option.isNone(yield* Deferred.poll(remoteInterrupted))); + + yield* Scope.close(secondScope, Exit.void).pipe(Effect.forkScoped); + yield* Deferred.await(remoteInterrupted); + assert.isTrue(Option.isSome(yield* Deferred.poll(remoteInterrupted))); + }).pipe(Effect.provide(testLayer)); + }); +}); diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts new file mode 100644 index 0000000000000000000000000000000000000000..028d3f1a1d964f9657e9d9dbde8a820b4d849e18 --- /dev/null +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -0,0 +1,344 @@ +import { realpathSync } from "node:fs"; + +import { + Duration, + Effect, + Exit, + Fiber, + Layer, + PubSub, + Ref, + Scope, + Stream, + SynchronizedRef, +} from "effect"; +import type { + GitManagerServiceError, + VcsStatusInput, + VcsStatusLocalResult, + VcsStatusRemoteResult, + VcsStatusResult, + VcsStatusStreamEvent, +} from "@t3tools/contracts"; +import { Context } from "effect"; +import { mergeGitStatusParts } from "@t3tools/shared/git"; + +import { GitWorkflowService } from "../git/GitWorkflowService.ts"; + +const VCS_STATUS_REFRESH_INTERVAL = Duration.seconds(30); + +interface VcsStatusChange { + readonly cwd: string; + readonly event: VcsStatusStreamEvent; +} + +interface CachedValue { + readonly fingerprint: string; + readonly value: T; +} + +interface CachedVcsStatus { + readonly local: CachedValue | null; + readonly remote: CachedValue | null; +} + +interface ActiveRemotePoller { + readonly fiber: Fiber.Fiber; + readonly subscriberCount: number; +} + +export interface VcsStatusBroadcasterShape { + readonly getStatus: ( + input: VcsStatusInput, + ) => Effect.Effect; + readonly refreshLocalStatus: ( + cwd: string, + ) => Effect.Effect; + readonly refreshStatus: (cwd: string) => Effect.Effect; + readonly streamStatus: ( + input: VcsStatusInput, + ) => Stream.Stream; +} + +export class VcsStatusBroadcaster extends Context.Service< + VcsStatusBroadcaster, + VcsStatusBroadcasterShape +>()("t3/vcs/VcsStatusBroadcaster") {} + +function fingerprintStatusPart(status: unknown): string { + return JSON.stringify(status); +} + +function normalizeCwd(cwd: string): string { + try { + return realpathSync.native(cwd); + } catch { + return cwd; + } +} + +export const layer = Layer.effect( + VcsStatusBroadcaster, + Effect.gen(function* () { + const workflow = yield* GitWorkflowService; + const changesPubSub = yield* Effect.acquireRelease( + PubSub.unbounded(), + (pubsub) => PubSub.shutdown(pubsub), + ); + const broadcasterScope = yield* Effect.acquireRelease(Scope.make(), (scope) => + Scope.close(scope, Exit.void), + ); + const cacheRef = yield* Ref.make(new Map()); + const pollersRef = yield* SynchronizedRef.make(new Map()); + + const getCachedStatus = Effect.fn("VcsStatusBroadcaster.getCachedStatus")(function* ( + cwd: string, + ) { + return yield* Ref.get(cacheRef).pipe(Effect.map((cache) => cache.get(cwd) ?? null)); + }); + + const updateCachedLocalStatus = Effect.fn("VcsStatusBroadcaster.updateCachedLocalStatus")( + function* (cwd: string, local: VcsStatusLocalResult, options?: { publish?: boolean }) { + const nextLocal = { + fingerprint: fingerprintStatusPart(local), + value: local, + } satisfies CachedValue; + const shouldPublish = yield* Ref.modify(cacheRef, (cache) => { + const previous = cache.get(cwd) ?? { local: null, remote: null }; + const nextCache = new Map(cache); + nextCache.set(cwd, { + ...previous, + local: nextLocal, + }); + return [previous.local?.fingerprint !== nextLocal.fingerprint, nextCache] as const; + }); + + if (options?.publish && shouldPublish) { + yield* PubSub.publish(changesPubSub, { + cwd, + event: { + _tag: "localUpdated", + local, + }, + }); + } + + return local; + }, + ); + + const updateCachedRemoteStatus = Effect.fn("VcsStatusBroadcaster.updateCachedRemoteStatus")( + function* ( + cwd: string, + remote: VcsStatusRemoteResult | null, + options?: { publish?: boolean }, + ) { + const nextRemote = { + fingerprint: fingerprintStatusPart(remote), + value: remote, + } satisfies CachedValue; + const shouldPublish = yield* Ref.modify(cacheRef, (cache) => { + const previous = cache.get(cwd) ?? { local: null, remote: null }; + const nextCache = new Map(cache); + nextCache.set(cwd, { + ...previous, + remote: nextRemote, + }); + return [previous.remote?.fingerprint !== nextRemote.fingerprint, nextCache] as const; + }); + + if (options?.publish && shouldPublish) { + yield* PubSub.publish(changesPubSub, { + cwd, + event: { + _tag: "remoteUpdated", + remote, + }, + }); + } + + return remote; + }, + ); + + const loadLocalStatus = Effect.fn("VcsStatusBroadcaster.loadLocalStatus")(function* ( + cwd: string, + ) { + const local = yield* workflow.localStatus({ cwd }); + return yield* updateCachedLocalStatus(cwd, local); + }); + + const loadRemoteStatus = Effect.fn("VcsStatusBroadcaster.loadRemoteStatus")(function* ( + cwd: string, + ) { + const remote = yield* workflow.remoteStatus({ cwd }); + return yield* updateCachedRemoteStatus(cwd, remote); + }); + + const getOrLoadLocalStatus = Effect.fn("VcsStatusBroadcaster.getOrLoadLocalStatus")(function* ( + cwd: string, + ) { + const cached = yield* getCachedStatus(cwd); + if (cached?.local) { + return cached.local.value; + } + return yield* loadLocalStatus(cwd); + }); + + const getOrLoadRemoteStatus = Effect.fn("VcsStatusBroadcaster.getOrLoadRemoteStatus")( + function* (cwd: string) { + const cached = yield* getCachedStatus(cwd); + if (cached?.remote) { + return cached.remote.value; + } + return yield* loadRemoteStatus(cwd); + }, + ); + + const getStatus: VcsStatusBroadcasterShape["getStatus"] = Effect.fn( + "VcsStatusBroadcaster.getStatus", + )(function* (input) { + const cwd = normalizeCwd(input.cwd); + const [local, remote] = yield* Effect.all([ + getOrLoadLocalStatus(cwd), + getOrLoadRemoteStatus(cwd), + ]); + return mergeGitStatusParts(local, remote); + }); + + const refreshLocalStatus: VcsStatusBroadcasterShape["refreshLocalStatus"] = Effect.fn( + "VcsStatusBroadcaster.refreshLocalStatus", + )(function* (rawCwd) { + const cwd = normalizeCwd(rawCwd); + yield* workflow.invalidateLocalStatus(cwd); + const local = yield* workflow.localStatus({ cwd }); + return yield* updateCachedLocalStatus(cwd, local, { publish: true }); + }); + + const refreshRemoteStatus = Effect.fn("VcsStatusBroadcaster.refreshRemoteStatus")(function* ( + cwd: string, + ) { + yield* workflow.invalidateRemoteStatus(cwd); + const remote = yield* workflow.remoteStatus({ cwd }); + return yield* updateCachedRemoteStatus(cwd, remote, { publish: true }); + }); + + const refreshStatus: VcsStatusBroadcasterShape["refreshStatus"] = Effect.fn( + "VcsStatusBroadcaster.refreshStatus", + )(function* (rawCwd) { + const cwd = normalizeCwd(rawCwd); + const [local, remote] = yield* Effect.all([ + refreshLocalStatus(cwd), + refreshRemoteStatus(cwd), + ]); + return mergeGitStatusParts(local, remote); + }); + + const makeRemoteRefreshLoop = (cwd: string) => { + const logRefreshFailure = (error: Error) => + Effect.logWarning("VCS remote status refresh failed", { + cwd, + detail: error.message, + }); + + return refreshRemoteStatus(cwd).pipe( + Effect.catch(logRefreshFailure), + Effect.andThen( + Effect.forever( + Effect.sleep(VCS_STATUS_REFRESH_INTERVAL).pipe( + Effect.andThen(refreshRemoteStatus(cwd).pipe(Effect.catch(logRefreshFailure))), + ), + ), + ), + ); + }; + + const retainRemotePoller = Effect.fn("VcsStatusBroadcaster.retainRemotePoller")(function* ( + cwd: string, + ) { + yield* SynchronizedRef.modifyEffect(pollersRef, (activePollers) => { + const existing = activePollers.get(cwd); + if (existing) { + const nextPollers = new Map(activePollers); + nextPollers.set(cwd, { + ...existing, + subscriberCount: existing.subscriberCount + 1, + }); + return Effect.succeed([undefined, nextPollers] as const); + } + + return makeRemoteRefreshLoop(cwd).pipe( + Effect.forkIn(broadcasterScope), + Effect.map((fiber) => { + const nextPollers = new Map(activePollers); + nextPollers.set(cwd, { + fiber, + subscriberCount: 1, + }); + return [undefined, nextPollers] as const; + }), + ); + }); + }); + + const releaseRemotePoller = Effect.fn("VcsStatusBroadcaster.releaseRemotePoller")(function* ( + cwd: string, + ) { + const pollerToInterrupt = yield* SynchronizedRef.modify(pollersRef, (activePollers) => { + const existing = activePollers.get(cwd); + if (!existing) { + return [null, activePollers] as const; + } + + if (existing.subscriberCount > 1) { + const nextPollers = new Map(activePollers); + nextPollers.set(cwd, { + ...existing, + subscriberCount: existing.subscriberCount - 1, + }); + return [null, nextPollers] as const; + } + + const nextPollers = new Map(activePollers); + nextPollers.delete(cwd); + return [existing.fiber, nextPollers] as const; + }); + + if (pollerToInterrupt) { + yield* Fiber.interrupt(pollerToInterrupt).pipe(Effect.ignore); + } + }); + + const streamStatus: VcsStatusBroadcasterShape["streamStatus"] = (input) => + Stream.unwrap( + Effect.gen(function* () { + const cwd = normalizeCwd(input.cwd); + const subscription = yield* PubSub.subscribe(changesPubSub); + const initialLocal = yield* getOrLoadLocalStatus(cwd); + const initialRemote = (yield* getCachedStatus(cwd))?.remote?.value ?? null; + yield* retainRemotePoller(cwd); + + const release = releaseRemotePoller(cwd).pipe(Effect.ignore, Effect.asVoid); + + return Stream.concat( + Stream.make({ + _tag: "snapshot" as const, + local: initialLocal, + remote: initialRemote, + }), + Stream.fromSubscription(subscription).pipe( + Stream.filter((event) => event.cwd === cwd), + Stream.map((event) => event.event), + ), + ).pipe(Stream.ensuring(release)); + }), + ); + + return VcsStatusBroadcaster.of({ + getStatus, + refreshLocalStatus, + refreshStatus, + streamStatus, + }); + }), +); diff --git a/apps/server/src/vcs/testing/VcsDriverContractHarness.ts b/apps/server/src/vcs/testing/VcsDriverContractHarness.ts new file mode 100644 index 0000000000000000000000000000000000000000..c0e195558b506b88e4db26760fb7f0115af5b2e6 --- /dev/null +++ b/apps/server/src/vcs/testing/VcsDriverContractHarness.ts @@ -0,0 +1,152 @@ +import { assert, it } from "@effect/vitest"; +import { DateTime, Option } from "effect"; +import { Effect, FileSystem, Layer, Path, type PlatformError, type Scope } from "effect"; +import { describe } from "vitest"; + +import type { VcsDriverKind } from "@t3tools/contracts"; +import { VcsDriver } from "../VcsDriver.ts"; + +export interface VcsDriverFixture { + readonly createRepo: (cwd: string) => Effect.Effect; + readonly writeFile: ( + cwd: string, + relativePath: string, + contents: string, + ) => Effect.Effect; + readonly trackFile?: (cwd: string, relativePath: string) => Effect.Effect; + readonly commit?: (cwd: string, message: string) => Effect.Effect; + readonly ignorePath: ( + cwd: string, + pattern: string, + ) => Effect.Effect; +} + +export interface VcsDriverContractSuiteInput { + readonly name: string; + readonly kind: VcsDriverKind; + readonly layer: Layer.Layer; + readonly fixture: VcsDriverFixture; +} + +export function runVcsDriverContractSuite(input: VcsDriverContractSuiteInput) { + const makeTmpDir = ( + prefix = `t3-${input.kind}-vcs-contract-`, + ): Effect.Effect => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + return yield* fileSystem.makeTempDirectoryScoped({ prefix }); + }); + + it.layer(input.layer)(`${input.name} VCS driver contract`, (it) => { + describe("repository detection", () => { + it.effect("returns null outside a repository", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const driver = yield* VcsDriver; + + assert.equal(yield* driver.detectRepository(cwd), null); + assert.equal(yield* driver.isInsideWorkTree(cwd), false); + }), + ); + + it.effect("detects repository identity inside a repository and nested directories", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const driver = yield* VcsDriver; + + yield* input.fixture.createRepo(cwd); + yield* input.fixture.writeFile(cwd, "src/index.ts", "export const value = 1;\n"); + const identity = yield* driver.detectRepository(cwd); + assert.equal(identity?.kind, input.kind); + assert.isTrue(identity?.rootPath.endsWith(cwd)); + assert.equal(identity?.freshness.source, "live-local"); + assert.isTrue(DateTime.isDateTime(identity?.freshness.observedAt)); + assert.isTrue(Option.isNone(identity?.freshness.expiresAt ?? Option.none())); + assert.equal(yield* driver.isInsideWorkTree(cwd), true); + + const path = yield* Path.Path; + const nestedDir = path.join(cwd, "src"); + const nestedIdentity = yield* driver.detectRepository(nestedDir); + assert.equal(nestedIdentity?.rootPath, identity?.rootPath); + assert.equal(yield* driver.isInsideWorkTree(nestedDir), true); + }), + ); + }); + + describe("workspace files", () => { + it.effect("lists tracked and untracked non-ignored files", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const driver = yield* VcsDriver; + + yield* input.fixture.createRepo(cwd); + yield* input.fixture.writeFile(cwd, "tracked.ts", "export const tracked = true;\n"); + if (input.fixture.trackFile && input.fixture.commit) { + yield* input.fixture.trackFile(cwd, "tracked.ts"); + yield* input.fixture.commit(cwd, "Track file"); + } + yield* input.fixture.writeFile(cwd, "untracked.ts", "export const untracked = true;\n"); + + const result = yield* driver.listWorkspaceFiles(cwd); + + assert.include(result.paths, "tracked.ts"); + assert.include(result.paths, "untracked.ts"); + assert.equal(result.truncated, false); + assert.equal(result.freshness.source, "live-local"); + assert.isTrue(DateTime.isDateTime(result.freshness.observedAt)); + assert.isTrue(Option.isNone(result.freshness.expiresAt)); + }), + ); + + it.effect("excludes ignored files from workspace listing", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const driver = yield* VcsDriver; + + yield* input.fixture.createRepo(cwd); + yield* input.fixture.ignorePath(cwd, "*.log"); + yield* input.fixture.writeFile(cwd, "included.ts", "export const included = true;\n"); + yield* input.fixture.writeFile(cwd, "debug.log", "ignore me\n"); + yield* input.fixture.writeFile(cwd, "nested/error.log", "ignore me too\n"); + + const result = yield* driver.listWorkspaceFiles(cwd); + + assert.include(result.paths, "included.ts"); + assert.notInclude(result.paths, "debug.log"); + assert.notInclude(result.paths, "nested/error.log"); + }), + ); + }); + + describe("ignored path filtering", () => { + it.effect("filters ignored paths", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const driver = yield* VcsDriver; + + yield* input.fixture.createRepo(cwd); + yield* input.fixture.ignorePath(cwd, "*.log"); + + const result = yield* driver.filterIgnoredPaths(cwd, [ + "keep.ts", + "debug.log", + "nested/error.log", + ]); + + assert.deepStrictEqual(result, ["keep.ts"]); + }), + ); + + it.effect("returns empty input unchanged", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const driver = yield* VcsDriver; + + yield* input.fixture.createRepo(cwd); + + assert.deepStrictEqual(yield* driver.filterIgnoredPaths(cwd, []), []); + }), + ); + }); + }); +} diff --git a/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts b/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts index 85b43ab37f671f8681a602901b9c400424225b18..a7385794e934ac578fdcdd9c80f1161a500ab322 100644 --- a/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts @@ -5,8 +5,8 @@ import { it, afterEach, describe, expect, vi } from "@effect/vitest"; import { Effect, FileSystem, Layer, Path, PlatformError } from "effect"; import { ServerConfig } from "../../config.ts"; -import { GitCoreLive } from "../../git/Layers/GitCore.ts"; -import { GitCore } from "../../git/Services/GitCore.ts"; +import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts"; +import * as VcsProcess from "../../vcs/VcsProcess.ts"; import { WorkspaceEntries } from "../Services/WorkspaceEntries.ts"; import { WorkspaceEntriesLive } from "./WorkspaceEntries.ts"; import { WorkspacePathsLive } from "./WorkspacePaths.ts"; @@ -14,7 +14,8 @@ import { WorkspacePathsLive } from "./WorkspacePaths.ts"; const TestLayer = Layer.empty.pipe( Layer.provideMerge(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))), Layer.provideMerge(WorkspacePathsLive), - Layer.provideMerge(GitCoreLive), + Layer.provideMerge(VcsProcess.layer), + Layer.provideMerge(VcsDriverRegistry.layer.pipe(Layer.provide(VcsProcess.layer))), Layer.provide( ServerConfig.layerTest(process.cwd(), { prefix: "t3-workspace-entries-test-", @@ -25,12 +26,11 @@ const TestLayer = Layer.empty.pipe( const makeTempDir = Effect.fn(function* (opts?: { prefix?: string; git?: boolean }) { const fileSystem = yield* FileSystem.FileSystem; - const gitCore = yield* GitCore; const dir = yield* fileSystem.makeTempDirectoryScoped({ prefix: opts?.prefix ?? "t3code-workspace-entries-", }); if (opts?.git) { - yield* gitCore.initRepo({ cwd: dir }); + yield* git(dir, ["init"]); } return dir; }); @@ -51,9 +51,10 @@ function writeTextFile( const git = (cwd: string, args: ReadonlyArray, env?: NodeJS.ProcessEnv) => Effect.gen(function* () { - const gitCore = yield* GitCore; - const result = yield* gitCore.execute({ + const process = yield* VcsProcess.VcsProcess; + const result = yield* process.run({ operation: "WorkspaceEntries.test.git", + command: "git", cwd, args, ...(env ? { env } : {}), diff --git a/apps/server/src/workspace/Layers/WorkspaceEntries.ts b/apps/server/src/workspace/Layers/WorkspaceEntries.ts index 7cb16b652bea445eb471cd67d922919ece9c9769..3046546813f54aae43af29ff3b02990d1aad86db 100644 --- a/apps/server/src/workspace/Layers/WorkspaceEntries.ts +++ b/apps/server/src/workspace/Layers/WorkspaceEntries.ts @@ -2,7 +2,7 @@ import * as OS from "node:os"; import fsPromises from "node:fs/promises"; import type { Dirent } from "node:fs"; -import { Cache, Duration, Effect, Exit, Layer, Option, Path } from "effect"; +import { Cache, DateTime, Duration, Effect, Exit, Layer, Path } from "effect"; import { type FilesystemBrowseInput, type ProjectEntry } from "@t3tools/contracts"; import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/path"; @@ -13,7 +13,7 @@ import { type RankedSearchResult, } from "@t3tools/shared/searchRanking"; -import { GitCore } from "../../git/Services/GitCore.ts"; +import { VcsDriverRegistry } from "../../vcs/VcsDriverRegistry.ts"; import { WorkspaceEntries, WorkspaceEntriesBrowseError, @@ -174,38 +174,39 @@ const resolveBrowseTarget = ( export const makeWorkspaceEntries = Effect.gen(function* () { const path = yield* Path.Path; - const gitOption = yield* Effect.serviceOption(GitCore); + const vcsRegistry = yield* VcsDriverRegistry; const workspacePaths = yield* WorkspacePaths; - const isInsideGitWorkTree = (cwd: string): Effect.Effect => - Option.match(gitOption, { - onSome: (git) => git.isInsideWorkTree(cwd).pipe(Effect.catch(() => Effect.succeed(false))), - onNone: () => Effect.succeed(false), - }); + const isInsideVcsWorkTree = (cwd: string): Effect.Effect => + vcsRegistry.detect({ cwd }).pipe( + Effect.map((handle) => handle !== null), + Effect.catch(() => Effect.succeed(false)), + ); - const filterGitIgnoredPaths = ( + const filterVcsIgnoredPaths = ( cwd: string, relativePaths: string[], ): Effect.Effect => - Option.match(gitOption, { - onSome: (git) => - git.filterIgnoredPaths(cwd, relativePaths).pipe( - Effect.map((paths) => [...paths]), - Effect.catch(() => Effect.succeed(relativePaths)), - ), - onNone: () => Effect.succeed(relativePaths), - }); + vcsRegistry.detect({ cwd }).pipe( + Effect.flatMap((handle) => + handle + ? handle.driver.filterIgnoredPaths(cwd, relativePaths).pipe( + Effect.map((paths) => [...paths]), + Effect.catch(() => Effect.succeed(relativePaths)), + ) + : Effect.succeed(relativePaths), + ), + Effect.catch(() => Effect.succeed(relativePaths)), + ); - const buildWorkspaceIndexFromGit = Effect.fn("WorkspaceEntries.buildWorkspaceIndexFromGit")( + const buildWorkspaceIndexFromVcs = Effect.fn("WorkspaceEntries.buildWorkspaceIndexFromVcs")( function* (cwd: string) { - if (Option.isNone(gitOption)) { - return null; - } - if (!(yield* isInsideGitWorkTree(cwd))) { + const vcs = yield* vcsRegistry.detect({ cwd }).pipe(Effect.catch(() => Effect.succeed(null))); + if (!vcs) { return null; } - const listedFiles = yield* gitOption.value + const listedFiles = yield* vcs.driver .listWorkspaceFiles(cwd) .pipe(Effect.catch(() => Effect.succeed(null))); @@ -216,7 +217,10 @@ export const makeWorkspaceEntries = Effect.gen(function* () { const listedPaths = [...listedFiles.paths] .map((entry) => toPosixPath(entry)) .filter((entry) => entry.length > 0 && !isPathInIgnoredDirectory(entry)); - const filePaths = yield* filterGitIgnoredPaths(cwd, listedPaths); + const filePaths = yield* vcs.driver.filterIgnoredPaths(cwd, listedPaths).pipe( + Effect.map((paths) => [...paths]), + Effect.catch(() => filterVcsIgnoredPaths(cwd, listedPaths)), + ); const directorySet = new Set(); for (const filePath of filePaths) { @@ -248,9 +252,10 @@ export const makeWorkspaceEntries = Effect.gen(function* () { ) .map(toSearchableWorkspaceEntry); + const now = yield* DateTime.now; const entries = [...directoryEntries, ...fileEntries]; return { - scannedAt: Date.now(), + scannedAt: now.epochMilliseconds, entries: entries.slice(0, WORKSPACE_INDEX_MAX_ENTRIES), truncated: listedFiles.truncated || entries.length > WORKSPACE_INDEX_MAX_ENTRIES, }; @@ -288,7 +293,7 @@ export const makeWorkspaceEntries = Effect.gen(function* () { const buildWorkspaceIndexFromFilesystem = Effect.fn( "WorkspaceEntries.buildWorkspaceIndexFromFilesystem", )(function* (cwd: string): Effect.fn.Return { - const shouldFilterWithGitIgnore = yield* isInsideGitWorkTree(cwd); + const shouldFilterWithGitIgnore = yield* isInsideVcsWorkTree(cwd); let pendingDirectories: string[] = [""]; const entries: SearchableWorkspaceEntry[] = []; @@ -336,7 +341,7 @@ export const makeWorkspaceEntries = Effect.gen(function* () { candidateEntries.map((entry) => entry.relativePath), ); const allowedPathSet = shouldFilterWithGitIgnore - ? new Set(yield* filterGitIgnoredPaths(cwd, candidatePaths)) + ? new Set(yield* filterVcsIgnoredPaths(cwd, candidatePaths)) : null; for (const candidateEntries of candidateEntriesByDirectory) { @@ -368,8 +373,9 @@ export const makeWorkspaceEntries = Effect.gen(function* () { } } + const now = yield* DateTime.now; return { - scannedAt: Date.now(), + scannedAt: now.epochMilliseconds, entries, truncated, }; @@ -378,9 +384,9 @@ export const makeWorkspaceEntries = Effect.gen(function* () { const buildWorkspaceIndex = Effect.fn("WorkspaceEntries.buildWorkspaceIndex")(function* ( cwd: string, ): Effect.fn.Return { - const gitIndexed = yield* buildWorkspaceIndexFromGit(cwd); - if (gitIndexed) { - return gitIndexed; + const vcsIndexed = yield* buildWorkspaceIndexFromVcs(cwd); + if (vcsIndexed) { + return vcsIndexed; } return yield* buildWorkspaceIndexFromFilesystem(cwd); }); diff --git a/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts b/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts index fcfd13c912eda531cd24254cc9a03971d74f3ebc..9c38b88f851735b2f0f0d97065e7d5c133419429 100644 --- a/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts +++ b/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts @@ -3,7 +3,8 @@ import { it, describe, expect } from "@effect/vitest"; import { Effect, FileSystem, Layer, Path } from "effect"; import { ServerConfig } from "../../config.ts"; -import { GitCoreLive } from "../../git/Layers/GitCore.ts"; +import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts"; +import * as VcsProcess from "../../vcs/VcsProcess.ts"; import { WorkspaceEntries } from "../Services/WorkspaceEntries.ts"; import { WorkspaceFileSystem } from "../Services/WorkspaceFileSystem.ts"; import { WorkspaceEntriesLive } from "./WorkspaceEntries.ts"; @@ -19,7 +20,7 @@ const TestLayer = Layer.empty.pipe( Layer.provideMerge(ProjectLayer), Layer.provideMerge(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))), Layer.provideMerge(WorkspacePathsLive), - Layer.provideMerge(GitCoreLive), + Layer.provideMerge(VcsDriverRegistry.layer.pipe(Layer.provide(VcsProcess.layer))), Layer.provide( ServerConfig.layerTest(process.cwd(), { prefix: "t3-workspace-files-test-", diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index fd256b32dfa3ac84c3a40d64c5f45b61db418b9d..c3827d98bf6edee330d76deed97c45e721e88095 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -29,9 +29,6 @@ import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery.ts"; import { ServerConfig } from "./config.ts"; -import { GitCore } from "./git/Services/GitCore.ts"; -import { GitManager } from "./git/Services/GitManager.ts"; -import { GitStatusBroadcaster } from "./git/Services/GitStatusBroadcaster.ts"; import { Keybindings } from "./keybindings.ts"; import { Open, resolveAvailableEditors } from "./open.ts"; import { normalizeDispatchCommand } from "./orchestration/Normalizer.ts"; @@ -50,10 +47,15 @@ import { TerminalManager } from "./terminal/Services/Manager.ts"; import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries.ts"; import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts"; import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePaths.ts"; +import { VcsStatusBroadcaster } from "./vcs/VcsStatusBroadcaster.ts"; +import { VcsProvisioningService } from "./vcs/VcsProvisioningService.ts"; +import { GitWorkflowService } from "./git/GitWorkflowService.ts"; import { ProjectSetupScriptRunner } from "./project/Services/ProjectSetupScriptRunner.ts"; import { RepositoryIdentityResolver } from "./project/Services/RepositoryIdentityResolver.ts"; import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts"; import { ServerAuth } from "./auth/Services/ServerAuth.ts"; +import * as SourceControlDiscoveryLayer from "./sourceControl/SourceControlDiscovery.ts"; +import * as VcsProcess from "./vcs/VcsProcess.ts"; import { BootstrapCredentialService, type BootstrapCredentialChange, @@ -136,9 +138,9 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => const checkpointDiffQuery = yield* CheckpointDiffQuery; const keybindings = yield* Keybindings; const open = yield* Open; - const gitManager = yield* GitManager; - const git = yield* GitCore; - const gitStatusBroadcaster = yield* GitStatusBroadcaster; + const gitWorkflow = yield* GitWorkflowService; + const vcsProvisioning = yield* VcsProvisioningService; + const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; const terminalManager = yield* TerminalManager; const providerRegistry = yield* ProviderRegistry; const config = yield* ServerConfig; @@ -151,6 +153,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => const repositoryIdentityResolver = yield* RepositoryIdentityResolver; const serverEnvironment = yield* ServerEnvironment; const serverAuth = yield* ServerAuth; + const sourceControlDiscovery = yield* SourceControlDiscoveryLayer.SourceControlDiscovery; const bootstrapCredentials = yield* BootstrapCredentialService; const sessions = yield* SessionCredentialService; const serverCommandId = (tag: string) => @@ -453,10 +456,10 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => } if (bootstrap?.prepareWorktree) { - const worktree = yield* git.createWorktree({ + const worktree = yield* gitWorkflow.createWorktree({ cwd: bootstrap.prepareWorktree.projectCwd, - branch: bootstrap.prepareWorktree.baseBranch, - newBranch: bootstrap.prepareWorktree.branch, + refName: bootstrap.prepareWorktree.baseBranch, + newRefName: bootstrap.prepareWorktree.branch, path: null, }); targetWorktreePath = worktree.worktree.path; @@ -464,7 +467,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => type: "thread.meta.update", commandId: serverCommandId("bootstrap-thread-meta-update"), threadId: command.threadId, - branch: worktree.worktree.branch, + branch: worktree.worktree.refName, worktreePath: targetWorktreePath, }); yield* refreshGitStatus(targetWorktreePath); @@ -540,7 +543,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => }); const refreshGitStatus = (cwd: string) => - gitStatusBroadcaster + vcsStatusBroadcaster .refreshStatus(cwd) .pipe(Effect.ignoreCause({ log: true }), Effect.forkDetach, Effect.asVoid); @@ -783,6 +786,14 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => "rpc.aggregate": "server", }, ), + [WS_METHODS.serverDiscoverSourceControl]: (_input) => + observeRpcEffect( + WS_METHODS.serverDiscoverSourceControl, + sourceControlDiscovery.discover, + { + "rpc.aggregate": "server", + }, + ), [WS_METHODS.projectsSearchEntries]: (input) => observeRpcEffect( WS_METHODS.projectsSearchEntries, @@ -831,26 +842,26 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => ), { "rpc.aggregate": "workspace" }, ), - [WS_METHODS.subscribeGitStatus]: (input) => + [WS_METHODS.subscribeVcsStatus]: (input) => observeRpcStream( - WS_METHODS.subscribeGitStatus, - gitStatusBroadcaster.streamStatus(input), + WS_METHODS.subscribeVcsStatus, + vcsStatusBroadcaster.streamStatus(input), { - "rpc.aggregate": "git", + "rpc.aggregate": "vcs", }, ), - [WS_METHODS.gitRefreshStatus]: (input) => + [WS_METHODS.vcsRefreshStatus]: (input) => observeRpcEffect( - WS_METHODS.gitRefreshStatus, - gitStatusBroadcaster.refreshStatus(input.cwd), + WS_METHODS.vcsRefreshStatus, + vcsStatusBroadcaster.refreshStatus(input.cwd), { - "rpc.aggregate": "git", + "rpc.aggregate": "vcs", }, ), - [WS_METHODS.gitPull]: (input) => + [WS_METHODS.vcsPull]: (input) => observeRpcEffect( - WS_METHODS.gitPull, - git.pullCurrentBranch(input.cwd).pipe( + WS_METHODS.vcsPull, + gitWorkflow.pullCurrentBranch(input.cwd).pipe( Effect.matchCauseEffect({ onFailure: (cause) => Effect.failCause(cause), onSuccess: (result) => @@ -863,7 +874,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => observeRpcStream( WS_METHODS.gitRunStackedAction, Stream.callback((queue) => - gitManager + gitWorkflow .runStackedAction(input, { actionId: input.actionId, progressReporter: { @@ -880,55 +891,59 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => }), ), ), - { "rpc.aggregate": "git" }, + { "rpc.aggregate": "vcs" }, ), [WS_METHODS.gitResolvePullRequest]: (input) => - observeRpcEffect(WS_METHODS.gitResolvePullRequest, gitManager.resolvePullRequest(input), { - "rpc.aggregate": "git", - }), + observeRpcEffect( + WS_METHODS.gitResolvePullRequest, + gitWorkflow.resolvePullRequest(input), + { + "rpc.aggregate": "git", + }, + ), [WS_METHODS.gitPreparePullRequestThread]: (input) => observeRpcEffect( WS_METHODS.gitPreparePullRequestThread, - gitManager + gitWorkflow .preparePullRequestThread(input) .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), { "rpc.aggregate": "git" }, ), - [WS_METHODS.gitListBranches]: (input) => - observeRpcEffect(WS_METHODS.gitListBranches, git.listBranches(input), { - "rpc.aggregate": "git", + [WS_METHODS.vcsListRefs]: (input) => + observeRpcEffect(WS_METHODS.vcsListRefs, gitWorkflow.listRefs(input), { + "rpc.aggregate": "vcs", }), - [WS_METHODS.gitCreateWorktree]: (input) => + [WS_METHODS.vcsCreateWorktree]: (input) => observeRpcEffect( - WS_METHODS.gitCreateWorktree, - git.createWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), - { "rpc.aggregate": "git" }, + WS_METHODS.vcsCreateWorktree, + gitWorkflow.createWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "vcs" }, ), - [WS_METHODS.gitRemoveWorktree]: (input) => + [WS_METHODS.vcsRemoveWorktree]: (input) => observeRpcEffect( - WS_METHODS.gitRemoveWorktree, - git.removeWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), - { "rpc.aggregate": "git" }, + WS_METHODS.vcsRemoveWorktree, + gitWorkflow.removeWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "vcs" }, ), - [WS_METHODS.gitCreateBranch]: (input) => + [WS_METHODS.vcsCreateRef]: (input) => observeRpcEffect( - WS_METHODS.gitCreateBranch, - git.createBranch(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), - { "rpc.aggregate": "git" }, + WS_METHODS.vcsCreateRef, + gitWorkflow.createRef(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "vcs" }, ), - [WS_METHODS.gitCheckout]: (input) => + [WS_METHODS.vcsSwitchRef]: (input) => observeRpcEffect( - WS_METHODS.gitCheckout, - Effect.scoped(git.checkoutBranch(input)).pipe( - Effect.tap(() => refreshGitStatus(input.cwd)), - ), - { "rpc.aggregate": "git" }, + WS_METHODS.vcsSwitchRef, + gitWorkflow.switchRef(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "vcs" }, ), - [WS_METHODS.gitInit]: (input) => + [WS_METHODS.vcsInit]: (input) => observeRpcEffect( - WS_METHODS.gitInit, - git.initRepo(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), - { "rpc.aggregate": "git" }, + WS_METHODS.vcsInit, + vcsProvisioning + .initRepository(input) + .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "vcs" }, ), [WS_METHODS.terminalOpen]: (input) => observeRpcEffect(WS_METHODS.terminalOpen, terminalManager.open(input), { @@ -1084,7 +1099,12 @@ export const websocketRpcRouteLayer = Layer.unwrap( }, }).pipe( Effect.provide( - makeWsRpcLayer(session.sessionId).pipe(Layer.provideMerge(RpcSerialization.layerJson)), + makeWsRpcLayer(session.sessionId).pipe( + Layer.provideMerge(RpcSerialization.layerJson), + Layer.provide( + SourceControlDiscoveryLayer.layer.pipe(Layer.provide(VcsProcess.layer)), + ), + ), ), ); return yield* Effect.acquireUseRelease( diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index 7beaec808db31b99958a5f4525062f9be27fbcff..3db5237373a10d89f1eb235e680329f1bdedb049 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -1,4 +1,4 @@ -import { EnvironmentId, type GitBranch } from "@t3tools/contracts"; +import { EnvironmentId, type VcsRef } from "@t3tools/contracts"; import { describe, expect, it } from "vitest"; import { dedupeRemoteBranchesWithLocalMatches, @@ -28,7 +28,7 @@ describe("resolveDraftEnvModeAfterBranchChange", () => { ).toBe("local"); }); - it("keeps new-worktree mode when selecting a base branch before worktree creation", () => { + it("keeps new-worktree mode when selecting a base ref before worktree creation", () => { expect( resolveDraftEnvModeAfterBranchChange({ nextWorktreePath: null, @@ -38,7 +38,7 @@ describe("resolveDraftEnvModeAfterBranchChange", () => { ).toBe("worktree"); }); - it("uses worktree mode when selecting a branch already attached to a worktree", () => { + it("uses worktree mode when selecting a ref already attached to a worktree", () => { expect( resolveDraftEnvModeAfterBranchChange({ nextWorktreePath: "/repo/.t3/worktrees/feature-a", @@ -50,7 +50,7 @@ describe("resolveDraftEnvModeAfterBranchChange", () => { }); describe("resolveBranchToolbarValue", () => { - it("defaults new-worktree mode to current git branch when no explicit base branch is set", () => { + it("defaults new-worktree mode to current git ref when no explicit base ref is set", () => { expect( resolveBranchToolbarValue({ envMode: "worktree", @@ -61,7 +61,7 @@ describe("resolveBranchToolbarValue", () => { ).toBe("main"); }); - it("keeps an explicitly selected worktree base branch", () => { + it("keeps an explicitly selected worktree base ref", () => { expect( resolveBranchToolbarValue({ envMode: "worktree", @@ -72,7 +72,7 @@ describe("resolveBranchToolbarValue", () => { ).toBe("feature/base"); }); - it("shows the actual checked-out branch when not selecting a new worktree base", () => { + it("shows the actual checked-out ref when not selecting a new worktree base", () => { expect( resolveBranchToolbarValue({ envMode: "local", @@ -186,8 +186,8 @@ describe("deriveLocalBranchNameFromRemoteRef", () => { }); describe("dedupeRemoteBranchesWithLocalMatches", () => { - it("hides remote refs when the matching local branch exists", () => { - const input: GitBranch[] = [ + it("hides remote refs when the matching local ref exists", () => { + const input: VcsRef[] = [ { name: "feature/demo", current: false, @@ -212,14 +212,14 @@ describe("dedupeRemoteBranchesWithLocalMatches", () => { }, ]; - expect(dedupeRemoteBranchesWithLocalMatches(input).map((branch) => branch.name)).toEqual([ + expect(dedupeRemoteBranchesWithLocalMatches(input).map((ref) => ref.name)).toEqual([ "feature/demo", "origin/feature/remote-only", ]); }); it("keeps all entries when no local match exists for a remote ref", () => { - const input: GitBranch[] = [ + const input: VcsRef[] = [ { name: "feature/local", current: false, @@ -236,14 +236,14 @@ describe("dedupeRemoteBranchesWithLocalMatches", () => { }, ]; - expect(dedupeRemoteBranchesWithLocalMatches(input).map((branch) => branch.name)).toEqual([ + expect(dedupeRemoteBranchesWithLocalMatches(input).map((ref) => ref.name)).toEqual([ "feature/local", "origin/feature/remote-only", ]); }); - it("keeps non-origin remote refs visible even when a matching local branch exists", () => { - const input: GitBranch[] = [ + it("keeps non-origin remote refs visible even when a matching local ref exists", () => { + const input: VcsRef[] = [ { name: "feature/demo", current: false, @@ -260,14 +260,14 @@ describe("dedupeRemoteBranchesWithLocalMatches", () => { }, ]; - expect(dedupeRemoteBranchesWithLocalMatches(input).map((branch) => branch.name)).toEqual([ + expect(dedupeRemoteBranchesWithLocalMatches(input).map((ref) => ref.name)).toEqual([ "feature/demo", "my-org/upstream/feature/demo", ]); }); it("keeps non-origin remote refs visible when git tracks with first-slash local naming", () => { - const input: GitBranch[] = [ + const input: VcsRef[] = [ { name: "upstream/feature", current: false, @@ -284,7 +284,7 @@ describe("dedupeRemoteBranchesWithLocalMatches", () => { }, ]; - expect(dedupeRemoteBranchesWithLocalMatches(input).map((branch) => branch.name)).toEqual([ + expect(dedupeRemoteBranchesWithLocalMatches(input).map((ref) => ref.name)).toEqual([ "upstream/feature", "my-org/upstream/feature", ]); @@ -292,12 +292,12 @@ describe("dedupeRemoteBranchesWithLocalMatches", () => { }); describe("resolveBranchSelectionTarget", () => { - it("reuses an existing secondary worktree for the selected branch", () => { + it("reuses an existing secondary worktree for the selected ref", () => { expect( resolveBranchSelectionTarget({ activeProjectCwd: "/repo", activeWorktreePath: "/repo/.t3/worktrees/feature-a", - branch: { + refName: { isDefault: false, worktreePath: "/repo/.t3/worktrees/feature-b", }, @@ -309,12 +309,12 @@ describe("resolveBranchSelectionTarget", () => { }); }); - it("switches back to the main repo when the branch already lives there", () => { + it("switches back to the main repo when the ref already lives there", () => { expect( resolveBranchSelectionTarget({ activeProjectCwd: "/repo", activeWorktreePath: "/repo/.t3/worktrees/feature-a", - branch: { + refName: { isDefault: true, worktreePath: "/repo", }, @@ -326,12 +326,12 @@ describe("resolveBranchSelectionTarget", () => { }); }); - it("checks out the default branch in the main repo when leaving a secondary worktree", () => { + it("checks out the default ref in the main repo when leaving a secondary worktree", () => { expect( resolveBranchSelectionTarget({ activeProjectCwd: "/repo", activeWorktreePath: "/repo/.t3/worktrees/feature-a", - branch: { + refName: { isDefault: true, worktreePath: null, }, @@ -343,12 +343,12 @@ describe("resolveBranchSelectionTarget", () => { }); }); - it("keeps checkout in the current worktree for non-default branches", () => { + it("keeps checkout in the current worktree for non-default refs", () => { expect( resolveBranchSelectionTarget({ activeProjectCwd: "/repo", activeWorktreePath: "/repo/.t3/worktrees/feature-a", - branch: { + refName: { isDefault: false, worktreePath: null, }, @@ -373,7 +373,7 @@ describe("shouldIncludeBranchPickerItem", () => { ).toBe(true); }); - it("keeps the synthetic create-branch item visible for arbitrary branch input", () => { + it("keeps the synthetic create-ref item visible for arbitrary ref input", () => { expect( shouldIncludeBranchPickerItem({ itemValue: "__create_new_branch__:feature/demo", @@ -384,7 +384,7 @@ describe("shouldIncludeBranchPickerItem", () => { ).toBe(true); }); - it("still filters ordinary branch items by query text", () => { + it("still filters ordinary ref items by query text", () => { expect( shouldIncludeBranchPickerItem({ itemValue: "main", diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index 7adab1a2e16f67310ac8b1d071e333545a1f1ca8..31f614a2e2cc9d556c3aaa37e03132613be5cc47 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -1,4 +1,4 @@ -import type { EnvironmentId, GitBranch, ProjectId } from "@t3tools/contracts"; +import type { EnvironmentId, VcsRef, ProjectId } from "@t3tools/contracts"; import { Schema } from "effect"; export { dedupeRemoteBranchesWithLocalMatches, @@ -100,24 +100,24 @@ export function resolveBranchToolbarValue(input: { export function resolveBranchSelectionTarget(input: { activeProjectCwd: string; activeWorktreePath: string | null; - branch: Pick; + refName: Pick; }): { checkoutCwd: string; nextWorktreePath: string | null; reuseExistingWorktree: boolean; } { - const { activeProjectCwd, activeWorktreePath, branch } = input; + const { activeProjectCwd, activeWorktreePath, refName } = input; - if (branch.worktreePath) { + if (refName.worktreePath) { return { - checkoutCwd: branch.worktreePath, - nextWorktreePath: branch.worktreePath === activeProjectCwd ? null : branch.worktreePath, + checkoutCwd: refName.worktreePath, + nextWorktreePath: refName.worktreePath === activeProjectCwd ? null : refName.worktreePath, reuseExistingWorktree: true, }; } const nextWorktreePath = - activeWorktreePath !== null && branch.isDefault ? null : activeWorktreePath; + activeWorktreePath !== null && refName.isDefault ? null : activeWorktreePath; return { checkoutCwd: nextWorktreePath ?? activeProjectCwd, diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index af5bd2360e97409c6f67f8297e7c983ca27c79f7..2e30edfc02c1f778a4a0a05185d938e75b1d0241 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -1,5 +1,5 @@ import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; -import type { EnvironmentId, GitBranch, ThreadId } from "@t3tools/contracts"; +import type { EnvironmentId, VcsRef, ThreadId } from "@t3tools/contracts"; import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; import { LegendList, type LegendListRef } from "@legendapp/list/react"; import { ChevronDownIcon } from "lucide-react"; @@ -21,6 +21,7 @@ import { useGitStatus } from "../lib/gitStatusState"; import { newCommandId } from "../lib/utils"; import { cn } from "../lib/utils"; import { parsePullRequestReference } from "../pullRequestReference"; +import { getSourceControlPresentation } from "../sourceControlPresentation"; import { useStore } from "../store"; import { createProjectSelectorByRef, createThreadSelectorByRef } from "../storeSelectors"; import { @@ -53,7 +54,7 @@ interface BranchToolbarBranchSelectorProps { envLocked: boolean; effectiveEnvModeOverride?: "local" | "worktree"; activeThreadBranchOverride?: string | null; - onActiveThreadBranchOverrideChange?: (branch: string | null) => void; + onActiveThreadBranchOverrideChange?: (refName: string | null) => void; onCheckoutPullRequestRequest?: (reference: string) => void; onComposerFocusRequest?: () => void; } @@ -69,7 +70,7 @@ function getBranchTriggerLabel(input: { }): string { const { activeWorktreePath, effectiveEnvMode, resolvedActiveBranch } = input; if (!resolvedActiveBranch) { - return "Select branch"; + return "Select ref"; } if (effectiveEnvMode === "worktree" && !activeWorktreePath) { return `From ${resolvedActiveBranch}`; @@ -193,7 +194,7 @@ export function BranchToolbarBranchSelector({ ); // --------------------------------------------------------------------------- - // Git branch queries + // Git ref queries // --------------------------------------------------------------------------- const queryClient = useQueryClient(); const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false); @@ -224,22 +225,27 @@ export function BranchToolbarBranchSelector({ query: deferredTrimmedBranchQuery, }), ); - const branches = useMemo( - () => branchesSearchData?.pages.flatMap((page) => page.branches) ?? [], + const refs = useMemo( + () => branchesSearchData?.pages.flatMap((page) => page.refs) ?? [], [branchesSearchData?.pages], ); const currentGitBranch = - branchStatusQuery.data?.branch ?? branches.find((branch) => branch.current)?.name ?? null; + branchStatusQuery.data?.refName ?? refs.find((refName) => refName.current)?.name ?? null; + const sourceControlPresentation = useMemo( + () => getSourceControlPresentation(branchStatusQuery.data?.sourceControlProvider), + [branchStatusQuery.data?.sourceControlProvider], + ); + const SourceControlIcon = sourceControlPresentation.Icon; const canonicalActiveBranch = resolveBranchToolbarValue({ envMode: effectiveEnvMode, activeWorktreePath, activeThreadBranch, currentGitBranch, }); - const branchNames = useMemo(() => branches.map((branch) => branch.name), [branches]); + const branchNames = useMemo(() => refs.map((refName) => refName.name), [refs]); const branchByName = useMemo( - () => new Map(branches.map((branch) => [branch.name, branch] as const)), - [branches], + () => new Map(refs.map((refName) => [refName.name, refName] as const)), + [refs], ); const normalizedDeferredBranchQuery = deferredTrimmedBranchQuery.toLowerCase(); const prReference = parsePullRequestReference(trimmedBranchQuery); @@ -289,11 +295,11 @@ export function BranchToolbarBranchSelector({ const shouldVirtualizeBranchList = filteredBranchPickerItems.length > 40; const totalBranchCount = branchesSearchData?.pages[0]?.totalCount ?? 0; const branchStatusText = isBranchesSearchPending - ? "Loading branches..." + ? "Loading refs..." : isFetchingNextPage - ? "Loading more branches..." + ? "Loading more refs..." : hasNextPage - ? `Showing ${branches.length} of ${totalBranchCount} branches` + ? `Showing ${refs.length} of ${totalBranchCount} refs` : null; // --------------------------------------------------------------------------- @@ -303,17 +309,17 @@ export function BranchToolbarBranchSelector({ startBranchActionTransition(async () => { await action().catch(() => undefined); await queryClient - .invalidateQueries({ queryKey: gitQueryKeys.branches(environmentId, branchCwd) }) + .invalidateQueries({ queryKey: gitQueryKeys.refs(environmentId, branchCwd) }) .catch(() => undefined); }); }; - const selectBranch = (branch: GitBranch) => { + const selectBranch = (refName: VcsRef) => { const api = readEnvironmentApi(environmentId); if (!api || !branchCwd || !activeProjectCwd || isBranchActionPending) return; if (isSelectingWorktreeBase) { - setThreadBranch(branch.name, null); + setThreadBranch(refName.name, null); setIsBranchMenuOpen(false); onComposerFocusRequest?.(); return; @@ -322,19 +328,19 @@ export function BranchToolbarBranchSelector({ const selectionTarget = resolveBranchSelectionTarget({ activeProjectCwd, activeWorktreePath, - branch, + refName, }); if (selectionTarget.reuseExistingWorktree) { - setThreadBranch(branch.name, selectionTarget.nextWorktreePath); + setThreadBranch(refName.name, selectionTarget.nextWorktreePath); setIsBranchMenuOpen(false); onComposerFocusRequest?.(); return; } - const selectedBranchName = branch.isRemote - ? deriveLocalBranchNameFromRemoteRef(branch.name) - : branch.name; + const selectedBranchName = refName.isRemote + ? deriveLocalBranchNameFromRemoteRef(refName.name) + : refName.name; setIsBranchMenuOpen(false); onComposerFocusRequest?.(); @@ -343,12 +349,12 @@ export function BranchToolbarBranchSelector({ const previousBranch = resolvedActiveBranch; setOptimisticBranch(selectedBranchName); try { - const checkoutResult = await api.git.checkout({ + const checkoutResult = await api.vcs.switchRef({ cwd: selectionTarget.checkoutCwd, - branch: branch.name, + refName: refName.name, }); - const nextBranchName = branch.isRemote - ? (checkoutResult.branch ?? selectedBranchName) + const nextBranchName = refName.isRemote + ? (checkoutResult.refName ?? selectedBranchName) : selectedBranchName; setOptimisticBranch(nextBranchName); setThreadBranch(nextBranchName, selectionTarget.nextWorktreePath); @@ -357,7 +363,7 @@ export function BranchToolbarBranchSelector({ toastManager.add( stackedThreadToast({ type: "error", - title: "Failed to checkout branch.", + title: "Failed to switch ref.", description: toBranchActionErrorMessage(error), }), ); @@ -365,7 +371,7 @@ export function BranchToolbarBranchSelector({ }); }; - const createBranch = (rawName: string) => { + const createRef = (rawName: string) => { const name = rawName.trim(); const api = readEnvironmentApi(environmentId); if (!api || !branchCwd || !name || isBranchActionPending) return; @@ -377,19 +383,19 @@ export function BranchToolbarBranchSelector({ const previousBranch = resolvedActiveBranch; setOptimisticBranch(name); try { - const createBranchResult = await api.git.createBranch({ + const createBranchResult = await api.vcs.createRef({ cwd: branchCwd, - branch: name, - checkout: true, + refName: name, + switchRef: true, }); - setOptimisticBranch(createBranchResult.branch); - setThreadBranch(createBranchResult.branch, activeWorktreePath); + setOptimisticBranch(createBranchResult.refName); + setThreadBranch(createBranchResult.refName, activeWorktreePath); } catch (error) { setOptimisticBranch(previousBranch); toastManager.add( stackedThreadToast({ type: "error", - title: "Failed to create and checkout branch.", + title: "Failed to create and switch ref.", description: toBranchActionErrorMessage(error), }), ); @@ -420,7 +426,7 @@ export function BranchToolbarBranchSelector({ return; } void queryClient.invalidateQueries({ - queryKey: gitQueryKeys.branches(environmentId, branchCwd), + queryKey: gitQueryKeys.refs(environmentId, branchCwd), }); }, [branchCwd, environmentId, queryClient], @@ -482,7 +488,7 @@ export function BranchToolbarBranchSelector({ useEffect(() => { if (shouldVirtualizeBranchList) return; maybeFetchNextBranchPage(); - }, [branches.length, maybeFetchNextBranchPage, shouldVirtualizeBranchList]); + }, [refs.length, maybeFetchNextBranchPage, shouldVirtualizeBranchList]); const triggerLabel = getBranchTriggerLabel({ activeWorktreePath, @@ -508,9 +514,14 @@ export function BranchToolbarBranchSelector({ onCheckoutPullRequestRequest(prReference); }} > -
- Checkout Pull Request - {prReference} +
+ + + + Checkout {sourceControlPresentation.terminology.singular} + + {prReference} +
); @@ -522,25 +533,25 @@ export function BranchToolbarBranchSelector({ key={itemValue} index={index} value={itemValue} - onClick={() => createBranch(trimmedBranchQuery)} + onClick={() => createRef(trimmedBranchQuery)} > - Create new branch "{trimmedBranchQuery}" + Create new ref "{trimmedBranchQuery}" ); } - const branch = branchByName.get(itemValue); - if (!branch) return null; + const refName = branchByName.get(itemValue); + if (!refName) return null; const hasSecondaryWorktree = - branch.worktreePath && activeProjectCwd && branch.worktreePath !== activeProjectCwd; - const badge = branch.current + refName.worktreePath && activeProjectCwd && refName.worktreePath !== activeProjectCwd; + const badge = refName.current ? "current" : hasSecondaryWorktree ? "worktree" - : branch.isRemote + : refName.isRemote ? "remote" - : branch.isDefault + : refName.isDefault ? "default" : null; return ( @@ -549,7 +560,7 @@ export function BranchToolbarBranchSelector({ key={itemValue} index={index} value={itemValue} - onClick={() => selectBranch(branch)} + onClick={() => selectBranch(refName)} >
{itemValue} @@ -581,7 +592,7 @@ export function BranchToolbarBranchSelector({ } className={cn("min-w-0 text-muted-foreground/70 hover:text-foreground/80", className)} - disabled={(isBranchesSearchPending && branches.length === 0) || isBranchActionPending} + disabled={(isBranchesSearchPending && refs.length === 0) || isBranchActionPending} > {triggerLabel} @@ -591,14 +602,14 @@ export function BranchToolbarBranchSelector({ setBranchQuery(event.target.value)} />
- No branches found. + No refs found. {shouldVirtualizeBranchList ? ( diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 445fe193057b5ab18f4ff28437b6608eb6647fe4..2a1edb9181303c964ddd38a031c05786a26ec516 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -206,6 +206,7 @@ function createMockEnvironmentApi(input: { filesystem: { browse: input.browse, }, + vcs: {} as EnvironmentApi["vcs"], git: {} as EnvironmentApi["git"], orchestration: { dispatchCommand: input.dispatchCommand, @@ -951,13 +952,13 @@ function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown { if (tag === WS_METHODS.serverGetConfig) { return fixture.serverConfig; } - if (tag === WS_METHODS.gitListBranches) { + if (tag === WS_METHODS.vcsListRefs) { return { isRepo: true, - hasOriginRemote: true, + hasPrimaryRemote: true, nextCursor: null, totalCount: 1, - branches: [ + refs: [ { name: "main", current: true, @@ -2287,16 +2288,16 @@ describe("ChatView timeline estimator parity (full app)", () => { branchButton.click(); const branchInput = await waitForElement( - () => document.querySelector('input[placeholder="Search branches..."]'), - "Unable to find branch search input.", + () => document.querySelector('input[placeholder="Search refs..."]'), + "Unable to find ref search input.", ); branchInput.focus(); - await page.getByPlaceholder("Search branches...").fill("1359"); + await page.getByPlaceholder("Search refs...").fill("1359"); const checkoutItem = await waitForElement( () => Array.from(document.querySelectorAll("span")).find( - (element) => element.textContent?.trim() === "Checkout Pull Request", + (element) => element.textContent?.trim() === "Checkout pull request", ) as HTMLSpanElement | null, "Unable to find checkout pull request option.", ); @@ -2425,7 +2426,7 @@ describe("ChatView timeline estimator parity (full app)", () => { { timeout: 8_000, interval: 16 }, ); - expect(wsRequests.some((request) => request._tag === WS_METHODS.gitCreateWorktree)).toBe( + expect(wsRequests.some((request) => request._tag === WS_METHODS.vcsCreateWorktree)).toBe( false, ); expect( @@ -2572,13 +2573,13 @@ describe("ChatView timeline estimator parity (full app)", () => { ), }, resolveRpc: (body) => { - if (body._tag === WS_METHODS.gitListBranches) { + if (body._tag === WS_METHODS.vcsListRefs) { return { isRepo: true, - hasOriginRemote: true, + hasPrimaryRemote: true, nextCursor: null, totalCount: 1, - branches: [ + refs: [ { name: "main", current: true, @@ -2665,13 +2666,13 @@ describe("ChatView timeline estimator parity (full app)", () => { ), }, resolveRpc: (body) => { - if (body._tag === WS_METHODS.gitListBranches) { + if (body._tag === WS_METHODS.vcsListRefs) { return { isRepo: true, - hasOriginRemote: true, + hasPrimaryRemote: true, nextCursor: null, totalCount: 2, - branches: [ + refs: [ { name: "main", current: true, @@ -2761,13 +2762,13 @@ describe("ChatView timeline estimator parity (full app)", () => { viewport: DEFAULT_VIEWPORT, snapshot: snapshotWithTwoThreads, resolveRpc: (body) => { - if (body._tag === WS_METHODS.gitListBranches) { + if (body._tag === WS_METHODS.vcsListRefs) { return { isRepo: true, - hasOriginRemote: true, + hasPrimaryRemote: true, nextCursor: null, totalCount: 2, - branches: [ + refs: [ { name: "main", current: true, @@ -3021,13 +3022,13 @@ describe("ChatView timeline estimator parity (full app)", () => { snapshot: createDraftOnlySnapshot(), initialPath: `/draft/${activeDraftId}`, resolveRpc: (body) => { - if (body._tag === WS_METHODS.gitListBranches) { + if (body._tag === WS_METHODS.vcsListRefs) { return { isRepo: true, - hasOriginRemote: true, + hasPrimaryRemote: true, nextCursor: null, totalCount: 2, - branches: [ + refs: [ { name: "main", current: true, @@ -3146,13 +3147,13 @@ describe("ChatView timeline estimator parity (full app)", () => { snapshot: createDraftOnlySnapshot(), initialPath: `/draft/${draftId}`, resolveRpc: (body) => { - if (body._tag === WS_METHODS.gitListBranches) { + if (body._tag === WS_METHODS.vcsListRefs) { return { isRepo: true, - hasOriginRemote: true, + hasPrimaryRemote: true, nextCursor: null, totalCount: branches.length, - branches, + refs: branches, }; } return undefined; @@ -3170,8 +3171,8 @@ describe("ChatView timeline estimator parity (full app)", () => { branchButton.click(); await waitForElement( - () => document.querySelector('input[placeholder="Search branches..."]'), - "Unable to find branch search input.", + () => document.querySelector('input[placeholder="Search refs..."]'), + "Unable to find ref search input.", ); const popup = await waitForElement( diff --git a/apps/web/src/components/GitActionsControl.browser.tsx b/apps/web/src/components/GitActionsControl.browser.tsx index beeb136a9c154c9cbe6983b40cd0ec7f29ca696b..787e034bd1f6a997a5205c2db7274b4db42b922b 100644 --- a/apps/web/src/components/GitActionsControl.browser.tsx +++ b/apps/web/src/components/GitActionsControl.browser.tsx @@ -113,7 +113,15 @@ vi.mock("~/lib/gitStatusState", () => ({ resetGitStatusStateForTests: () => undefined, useGitStatus: vi.fn(() => ({ data: { - branch: BRANCH_NAME, + isRepo: true, + sourceControlProvider: { + kind: "github", + name: "GitHub", + baseUrl: "https://github.com", + }, + hasPrimaryRemote: true, + isDefaultRef: false, + refName: BRANCH_NAME, hasWorkingTreeChanges: false, workingTree: { files: [], insertions: 0, deletions: 0 }, hasUpstream: true, diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts index c6a50b82c277cba1cb80d2e92b008cb46d419c51..08c85e3649f034a4a34f59889e12bab9e42a8302 100644 --- a/apps/web/src/components/GitActionsControl.logic.test.ts +++ b/apps/web/src/components/GitActionsControl.logic.test.ts @@ -1,4 +1,4 @@ -import type { GitStatusResult } from "@t3tools/contracts"; +import type { VcsStatusResult } from "@t3tools/contracts"; import { assert, describe, it } from "vitest"; import { buildGitActionProgressStages, @@ -11,12 +11,12 @@ import { resolveThreadBranchUpdate, } from "./GitActionsControl.logic"; -function status(overrides: Partial = {}): GitStatusResult { +function status(overrides: Partial = {}): VcsStatusResult { return { isRepo: true, - hasOriginRemote: true, - isDefaultBranch: false, - branch: "feature/test", + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "feature/test", hasWorkingTreeChanges: false, workingTree: { files: [], @@ -31,7 +31,7 @@ function status(overrides: Partial = {}): GitStatusResult { }; } -describe("when: branch is clean and has an open PR", () => { +describe("when: ref is clean and has an open PR", () => { it("resolveQuickAction opens the existing PR", () => { const quick = resolveQuickAction( status({ @@ -39,8 +39,8 @@ describe("when: branch is clean and has an open PR", () => { number: 10, title: "Open PR", url: "https://example.com/pr/10", - baseBranch: "main", - headBranch: "feature/test", + baseRef: "main", + headRef: "feature/test", state: "open", }, }), @@ -56,8 +56,8 @@ describe("when: branch is clean and has an open PR", () => { number: 11, title: "Existing PR", url: "https://example.com/pr/11", - baseBranch: "main", - headBranch: "feature/test", + baseRef: "main", + headRef: "feature/test", state: "open", }, }), @@ -150,7 +150,7 @@ describe("when: git status is unavailable", () => { }); }); -describe("when: branch is clean, ahead, and has an open PR", () => { +describe("when: ref is clean, ahead, and has an open PR", () => { it("resolveQuickAction prefers push", () => { const quick = resolveQuickAction( status({ @@ -159,8 +159,8 @@ describe("when: branch is clean, ahead, and has an open PR", () => { number: 13, title: "Open PR", url: "https://example.com/pr/13", - baseBranch: "main", - headBranch: "feature/test", + baseRef: "main", + headRef: "feature/test", state: "open", }, }), @@ -177,8 +177,8 @@ describe("when: branch is clean, ahead, and has an open PR", () => { number: 12, title: "Existing PR", url: "https://example.com/pr/12", - baseBranch: "main", - headBranch: "feature/test", + baseRef: "main", + headRef: "feature/test", state: "open", }, }), @@ -212,7 +212,7 @@ describe("when: branch is clean, ahead, and has an open PR", () => { }); }); -describe("when: branch is clean, ahead, and has no open PR", () => { +describe("when: ref is clean, ahead, and has no open PR", () => { it("resolveQuickAction pushes and creates a PR", () => { const quick = resolveQuickAction(status({ aheadCount: 2, pr: null }), false); assert.deepInclude(quick, { @@ -253,7 +253,33 @@ describe("when: branch is clean, ahead, and has no open PR", () => { }); }); -describe("when: branch is clean, up to date, and has no open PR", () => { +describe("when: source control provider uses merge requests", () => { + it("uses GitLab MR terminology in quick actions and menu items", () => { + const gitlabStatus = status({ + aheadCount: 2, + sourceControlProvider: { + kind: "gitlab", + name: "GitLab", + baseUrl: "https://gitlab.com", + }, + }); + + const quick = resolveQuickAction(gitlabStatus, false); + const items = buildMenuItems(gitlabStatus, false); + + assert.deepInclude(quick, { + kind: "run_action", + action: "create_pr", + label: "Push & create MR", + }); + assert.deepInclude(items[2], { + id: "pr", + label: "Create MR", + }); + }); +}); + +describe("when: ref is clean, up to date, and has no open PR", () => { it("resolveQuickAction returns disabled no-action state", () => { const quick = resolveQuickAction( 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", () => { }); }); -describe("when: branch is behind upstream", () => { +describe("when: ref is behind upstream", () => { it("resolveQuickAction returns pull", () => { const quick = resolveQuickAction(status({ behindCount: 2 }), false); assert.deepInclude(quick, { kind: "run_pull", label: "Pull", disabled: false }); @@ -330,11 +356,11 @@ describe("when: branch is behind upstream", () => { }); }); -describe("when: branch has diverged from upstream", () => { +describe("when: ref has diverged from upstream", () => { it("resolveQuickAction returns a disabled sync hint", () => { const quick = resolveQuickAction(status({ aheadCount: 2, behindCount: 1 }), false); assert.deepEqual(quick, { - label: "Sync branch", + label: "Sync ref", disabled: true, kind: "show_hint", hint: "Branch has diverged from upstream. Rebase/merge first.", @@ -375,8 +401,8 @@ describe("when: working tree has local changes", () => { number: 16, title: "Existing PR", url: "https://example.com/pr/16", - baseBranch: "main", - headBranch: "feature/test", + baseRef: "main", + headRef: "feature/test", state: "open", }, }), @@ -420,10 +446,10 @@ describe("when: working tree has local changes", () => { }); }); -describe("when: on default branch without open PR", () => { +describe("when: on default ref without open PR", () => { it("resolveQuickAction returns commit and push when local changes exist", () => { const quick = resolveQuickAction( - status({ branch: "main", hasWorkingTreeChanges: true }), + status({ refName: "main", hasWorkingTreeChanges: true }), false, true, ); @@ -435,9 +461,9 @@ describe("when: on default branch without open PR", () => { }); }); - it("resolveQuickAction returns push when branch is ahead", () => { + it("resolveQuickAction returns push when ref is ahead", () => { const quick = resolveQuickAction( - status({ branch: "main", aheadCount: 2, pr: null }), + status({ refName: "main", aheadCount: 2, pr: null }), false, true, ); @@ -450,7 +476,7 @@ describe("when: on default branch without open PR", () => { }); }); -describe("when: working tree has local changes and branch is behind upstream", () => { +describe("when: working tree has local changes and ref is behind upstream", () => { it("resolveQuickAction still prefers commit, push, and create PR", () => { const quick = resolveQuickAction( status({ hasWorkingTreeChanges: true, behindCount: 1 }), @@ -497,14 +523,14 @@ describe("when: working tree has local changes and branch is behind upstream", ( describe("when: HEAD is detached and there are no local changes", () => { it("resolveQuickAction shows detached head hint", () => { const quick = resolveQuickAction( - status({ branch: null, hasWorkingTreeChanges: false, hasUpstream: false }), + status({ refName: null, hasWorkingTreeChanges: false, hasUpstream: false }), false, ); assert.deepInclude(quick, { kind: "show_hint", label: "Commit", disabled: true }); }); it("buildMenuItems keeps commit, push, and PR disabled", () => { - const items = buildMenuItems(status({ branch: null, hasWorkingTreeChanges: false }), false); + const items = buildMenuItems(status({ refName: null, hasWorkingTreeChanges: false }), false); assert.deepEqual(items, [ { id: "commit", @@ -534,7 +560,7 @@ describe("when: HEAD is detached and there are no local changes", () => { }); }); -describe("when: branch has no upstream configured", () => { +describe("when: ref has no upstream configured", () => { it("resolveQuickAction is disabled when clean, no upstream, and no local commits are ahead", () => { const quick = resolveQuickAction( status({ hasUpstream: false, pr: null, aheadCount: 0 }), @@ -557,8 +583,8 @@ describe("when: branch has no upstream configured", () => { number: 14, title: "Existing PR", url: "https://example.com/pr/14", - baseBranch: "main", - headBranch: "feature/test", + baseRef: "main", + headRef: "feature/test", state: "open", }, }), @@ -580,8 +606,8 @@ describe("when: branch has no upstream configured", () => { number: 15, title: "Existing PR", url: "https://example.com/pr/15", - baseBranch: "main", - headBranch: "feature/test", + baseRef: "main", + headRef: "feature/test", state: "open", }, }), @@ -656,7 +682,7 @@ describe("when: branch has no upstream configured", () => { assert.deepEqual(quick, { kind: "show_hint", label: "Push", - hint: 'Add an "origin" remote before pushing or creating a PR.', + hint: 'Add an "origin" remote before pushing or creating a pull request.', disabled: true, }); }); @@ -725,10 +751,10 @@ describe("when: branch has no upstream configured", () => { ]); }); - it("resolveQuickAction is disabled on default branch when no upstream exists and no commits are ahead", () => { + it("resolveQuickAction is disabled on default ref when no upstream exists and no commits are ahead", () => { const quick = resolveQuickAction( status({ - branch: "main", + refName: "main", hasUpstream: false, aheadCount: 0, pr: null, @@ -744,10 +770,10 @@ describe("when: branch has no upstream configured", () => { }); }); - it("resolveQuickAction uses push-only on default branch when no upstream exists and commits are ahead", () => { + it("resolveQuickAction uses push-only on default ref when no upstream exists and commits are ahead", () => { const quick = resolveQuickAction( status({ - branch: "main", + refName: "main", hasUpstream: false, aheadCount: 1, pr: null, @@ -763,7 +789,7 @@ describe("when: branch has no upstream configured", () => { }); }); - it("buildMenuItems still disables push and create PR when branch is behind", () => { + it("buildMenuItems still disables push and create PR when ref is behind", () => { const items = buildMenuItems( status({ hasUpstream: false, @@ -803,7 +829,7 @@ describe("when: branch has no upstream configured", () => { }); describe("requiresDefaultBranchConfirmation", () => { - it("requires confirmation for push actions on default branch", () => { + it("requires confirmation for push actions on default ref", () => { assert.isFalse(requiresDefaultBranchConfirmation("commit", true)); assert.isTrue(requiresDefaultBranchConfirmation("push", true)); assert.isTrue(requiresDefaultBranchConfirmation("create_pr", true)); @@ -823,9 +849,9 @@ describe("resolveDefaultBranchActionDialogCopy", () => { }); assert.deepEqual(copy, { - title: "Push to default branch?", + title: "Push to default ref?", description: - '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.', + '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.', continueLabel: "Push to main", }); }); @@ -838,9 +864,9 @@ describe("resolveDefaultBranchActionDialogCopy", () => { }); assert.deepEqual(copy, { - title: "Push & create PR from default branch?", + title: "Push & create PR from default ref?", description: - '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.', + '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.', continueLabel: "Push & create PR", }); }); @@ -853,9 +879,9 @@ describe("resolveDefaultBranchActionDialogCopy", () => { }); assert.deepEqual(copy, { - title: "Commit, push & create PR from default branch?", + title: "Commit, push & create PR from default ref?", description: - '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.', + '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.', continueLabel: "Commit, push & create PR", }); }); @@ -884,7 +910,7 @@ describe("buildGitActionProgressStages", () => { "Pushing to origin/feature/test...", "Preparing PR...", "Generating PR content...", - "Creating GitHub pull request...", + "Creating pull request...", ]); }); @@ -898,7 +924,7 @@ describe("buildGitActionProgressStages", () => { assert.deepEqual(stages, [ "Preparing PR...", "Generating PR content...", - "Creating GitHub pull request...", + "Creating pull request...", ]); }); @@ -928,7 +954,7 @@ describe("buildGitActionProgressStages", () => { "Pushing to origin/feature/test...", "Preparing PR...", "Generating PR content...", - "Creating GitHub pull request...", + "Creating pull request...", ]); }); }); @@ -944,7 +970,7 @@ describe("resolveThreadBranchUpdate", () => { commit: { status: "created", commitSha: "89abcdef01234567", - subject: "feat: add branch sync", + subject: "feat: add ref sync", }, push: { status: "pushed", branch: "feature/fix-toast-copy" }, pr: { status: "skipped_not_requested" }, @@ -968,7 +994,7 @@ describe("resolveThreadBranchUpdate", () => { commit: { status: "created", commitSha: "89abcdef01234567", - subject: "feat: add branch sync", + subject: "feat: add ref sync", }, push: { status: "pushed", branch: "feature/fix-toast-copy" }, pr: { status: "skipped_not_requested" }, @@ -985,8 +1011,8 @@ describe("resolveThreadBranchUpdate", () => { describe("resolveLiveThreadBranchUpdate", () => { it("returns a branch update when live git status differs from stored thread metadata", () => { const update = resolveLiveThreadBranchUpdate({ - threadBranch: "feature/old-branch", - gitStatus: status({ branch: "effect-atom" }), + threadBranch: "feature/old-ref", + gitStatus: status({ refName: "effect-atom" }), }); assert.deepEqual(update, { @@ -996,35 +1022,35 @@ describe("resolveLiveThreadBranchUpdate", () => { it("returns null when live git status is unavailable", () => { const update = resolveLiveThreadBranchUpdate({ - threadBranch: "feature/old-branch", + threadBranch: "feature/old-ref", gitStatus: null, }); assert.equal(update, null); }); - it("returns null when the stored thread branch already matches git status", () => { + it("returns null when the stored thread ref already matches git status", () => { const update = resolveLiveThreadBranchUpdate({ threadBranch: "effect-atom", - gitStatus: status({ branch: "effect-atom" }), + gitStatus: status({ refName: "effect-atom" }), }); assert.equal(update, null); }); - it("returns null when git status is detached HEAD but the thread already has a branch", () => { + it("returns null when git status is detached HEAD but the thread already has a ref", () => { const update = resolveLiveThreadBranchUpdate({ threadBranch: "effect-atom", - gitStatus: status({ branch: null }), + gitStatus: status({ refName: null }), }); assert.equal(update, null); }); - it("does not regress a semantic thread branch back to a temporary worktree branch", () => { + it("does not regress a semantic thread ref back to a temporary worktree ref", () => { const update = resolveLiveThreadBranchUpdate({ threadBranch: "t3code/github-query-rate-limit", - gitStatus: status({ branch: "t3code/bda76797" }), + gitStatus: status({ refName: "t3code/bda76797" }), }); assert.equal(update, null); @@ -1032,31 +1058,31 @@ describe("resolveLiveThreadBranchUpdate", () => { }); describe("resolveAutoFeatureBranchName", () => { - it("uses semantic preferred branch names when available", () => { - const branch = resolveAutoFeatureBranchName(["main", "feature/other"], "fix toast copy"); - assert.equal(branch, "feature/fix-toast-copy"); + it("uses semantic preferred ref names when available", () => { + const ref = resolveAutoFeatureBranchName(["main", "feature/other"], "fix toast copy"); + assert.equal(ref, "feature/fix-toast-copy"); }); - it("normalizes preferred names that already include a branch namespace", () => { - const branch = resolveAutoFeatureBranchName(["main"], "feature/refine-toolbar-actions"); - assert.equal(branch, "feature/refine-toolbar-actions"); + it("normalizes preferred names that already include a ref namespace", () => { + const ref = resolveAutoFeatureBranchName(["main"], "feature/refine-toolbar-actions"); + assert.equal(ref, "feature/refine-toolbar-actions"); }); - it("increments suffix when the preferred branch name already exists", () => { - const branch = resolveAutoFeatureBranchName( + it("increments suffix when the preferred ref name already exists", () => { + const ref = resolveAutoFeatureBranchName( ["main", "feature/fix-toast-copy", "feature/fix-toast-copy-2"], "fix toast copy", ); - assert.equal(branch, "feature/fix-toast-copy-3"); + assert.equal(ref, "feature/fix-toast-copy-3"); }); - it("treats existing branch names as case-insensitive for collision checks", () => { - const branch = resolveAutoFeatureBranchName(["Feature/Ticket-1"], "feature/ticket-1"); - assert.equal(branch, "feature/ticket-1-2"); + it("treats existing ref names as case-insensitive for collision checks", () => { + const ref = resolveAutoFeatureBranchName(["Feature/Ticket-1"], "feature/ticket-1"); + assert.equal(ref, "feature/ticket-1-2"); }); it("falls back to feature/update when no preferred name is provided", () => { - const branch = resolveAutoFeatureBranchName(["main"]); - assert.equal(branch, "feature/update"); + const ref = resolveAutoFeatureBranchName(["main"]); + assert.equal(ref, "feature/update"); }); }); diff --git a/apps/web/src/components/GitActionsControl.logic.ts b/apps/web/src/components/GitActionsControl.logic.ts index e4e611fb87c6c32ecc208174a885b723358d5052..70ca9decb63f0445013a5ac30abef32007438a16 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -1,9 +1,14 @@ import type { GitRunStackedActionResult, GitStackedAction, - GitStatusResult, + VcsStatusResult, } from "@t3tools/contracts"; import { isTemporaryWorktreeBranch } from "@t3tools/shared/git"; +import { + DEFAULT_CHANGE_REQUEST_TERMINOLOGY, + getChangeRequestTerminology, + type ChangeRequestTerminology, +} from "../sourceControlPresentation"; export type GitActionIconName = "commit" | "push" | "pr"; @@ -38,6 +43,14 @@ export type DefaultBranchConfirmableAction = | "commit_push" | "commit_push_pr"; +function resolveChangeRequestTerminology( + gitStatus: VcsStatusResult | null, +): ChangeRequestTerminology { + return gitStatus?.sourceControlProvider + ? getChangeRequestTerminology(gitStatus.sourceControlProvider) + : DEFAULT_CHANGE_REQUEST_TERMINOLOGY; +} + export function buildGitActionProgressStages(input: { action: GitStackedAction; hasCustomCommitMessage: boolean; @@ -45,13 +58,15 @@ export function buildGitActionProgressStages(input: { pushTarget?: string; featureBranch?: boolean; shouldPushBeforePr?: boolean; + terminology?: ChangeRequestTerminology; }): string[] { - const branchStages = input.featureBranch ? ["Preparing feature branch..."] : []; + const terminology = input.terminology ?? DEFAULT_CHANGE_REQUEST_TERMINOLOGY; + const branchStages = input.featureBranch ? ["Preparing feature ref..."] : []; const pushStage = input.pushTarget ? `Pushing to ${input.pushTarget}...` : "Pushing..."; const prStages = [ - "Preparing PR...", - "Generating PR content...", - "Creating GitHub pull request...", + `Preparing ${terminology.shortLabel}...`, + `Generating ${terminology.shortLabel} content...`, + `Creating ${terminology.singular}...`, ]; if (input.action === "push") { @@ -77,17 +92,18 @@ export function buildGitActionProgressStages(input: { } export function buildMenuItems( - gitStatus: GitStatusResult | null, + gitStatus: VcsStatusResult | null, isBusy: boolean, - hasOriginRemote = true, + hasPrimaryRemote = true, ): GitActionMenuItem[] { if (!gitStatus) return []; + const terminology = resolveChangeRequestTerminology(gitStatus); - const hasBranch = gitStatus.branch !== null; + const hasBranch = gitStatus.refName !== null; const hasChanges = gitStatus.hasWorkingTreeChanges; const hasOpenPr = gitStatus.pr?.state === "open"; const isBehind = gitStatus.behindCount > 0; - const canPushWithoutUpstream = hasOriginRemote && !gitStatus.hasUpstream; + const canPushWithoutUpstream = hasPrimaryRemote && !gitStatus.hasUpstream; const canCommit = !isBusy && hasChanges; const canPush = !isBusy && @@ -126,14 +142,14 @@ export function buildMenuItems( hasOpenPr ? { id: "pr", - label: "View PR", + label: `View ${terminology.shortLabel}`, disabled: !canOpenPr, icon: "pr", kind: "open_pr", } : { id: "pr", - label: "Create PR", + label: `Create ${terminology.shortLabel}`, disabled: !canCreatePr, icon: "pr", kind: "open_dialog", @@ -143,10 +159,10 @@ export function buildMenuItems( } export function resolveQuickAction( - gitStatus: GitStatusResult | null, + gitStatus: VcsStatusResult | null, isBusy: boolean, - isDefaultBranch = false, - hasOriginRemote = true, + isDefaultRef = false, + hasPrimaryRemote = true, ): GitQuickAction { if (isBusy) { return { label: "Commit", disabled: true, kind: "show_hint", hint: "Git action in progress." }; @@ -161,31 +177,32 @@ export function resolveQuickAction( }; } - const hasBranch = gitStatus.branch !== null; + const hasBranch = gitStatus.refName !== null; const hasChanges = gitStatus.hasWorkingTreeChanges; const hasOpenPr = gitStatus.pr?.state === "open"; const isAhead = gitStatus.aheadCount > 0; const isBehind = gitStatus.behindCount > 0; const isDiverged = isAhead && isBehind; + const terminology = resolveChangeRequestTerminology(gitStatus); if (!hasBranch) { return { label: "Commit", disabled: true, kind: "show_hint", - hint: "Create and checkout a branch before pushing or opening a PR.", + hint: `Create and checkout a ref before pushing or opening a ${terminology.singular}.`, }; } if (hasChanges) { - if (!gitStatus.hasUpstream && !hasOriginRemote) { + if (!gitStatus.hasUpstream && !hasPrimaryRemote) { return { label: "Commit", disabled: false, kind: "run_action", action: "commit" }; } - if (hasOpenPr || isDefaultBranch) { + if (hasOpenPr || isDefaultRef) { return { label: "Commit & push", disabled: false, kind: "run_action", action: "commit_push" }; } return { - label: "Commit, push & PR", + label: `Commit, push & ${terminology.shortLabel}`, disabled: false, kind: "run_action", action: "commit_push_pr", @@ -193,20 +210,20 @@ export function resolveQuickAction( } if (!gitStatus.hasUpstream) { - if (!hasOriginRemote) { + if (!hasPrimaryRemote) { if (hasOpenPr && !isAhead) { - return { label: "View PR", disabled: false, kind: "open_pr" }; + return { label: `View ${terminology.shortLabel}`, disabled: false, kind: "open_pr" }; } return { label: "Push", disabled: true, kind: "show_hint", - hint: 'Add an "origin" remote before pushing or creating a PR.', + hint: `Add an "origin" remote before pushing or creating a ${terminology.singular}.`, }; } if (!isAhead) { if (hasOpenPr) { - return { label: "View PR", disabled: false, kind: "open_pr" }; + return { label: `View ${terminology.shortLabel}`, disabled: false, kind: "open_pr" }; } return { label: "Push", @@ -215,16 +232,16 @@ export function resolveQuickAction( hint: "No local commits to push.", }; } - if (hasOpenPr || isDefaultBranch) { + if (hasOpenPr || isDefaultRef) { return { label: "Push", disabled: false, kind: "run_action", - action: isDefaultBranch ? "commit_push" : "push", + action: isDefaultRef ? "commit_push" : "push", }; } return { - label: "Push & create PR", + label: `Push & create ${terminology.shortLabel}`, disabled: false, kind: "run_action", action: "create_pr", @@ -233,7 +250,7 @@ export function resolveQuickAction( if (isDiverged) { return { - label: "Sync branch", + label: "Sync ref", disabled: true, kind: "show_hint", hint: "Branch has diverged from upstream. Rebase/merge first.", @@ -249,16 +266,16 @@ export function resolveQuickAction( } if (isAhead) { - if (hasOpenPr || isDefaultBranch) { + if (hasOpenPr || isDefaultRef) { return { label: "Push", disabled: false, kind: "run_action", - action: isDefaultBranch ? "commit_push" : "push", + action: isDefaultRef ? "commit_push" : "push", }; } return { - label: "Push & create PR", + label: `Push & create ${terminology.shortLabel}`, disabled: false, kind: "run_action", action: "create_pr", @@ -266,7 +283,7 @@ export function resolveQuickAction( } if (hasOpenPr && gitStatus.hasUpstream) { - return { label: "View PR", disabled: false, kind: "open_pr" }; + return { label: `View ${terminology.shortLabel}`, disabled: false, kind: "open_pr" }; } return { @@ -279,9 +296,9 @@ export function resolveQuickAction( export function requiresDefaultBranchConfirmation( action: GitStackedAction, - isDefaultBranch: boolean, + isDefaultRef: boolean, ): boolean { - if (!isDefaultBranch) return false; + if (!isDefaultRef) return false; return ( action === "push" || action === "create_pr" || @@ -294,20 +311,22 @@ export function resolveDefaultBranchActionDialogCopy(input: { action: DefaultBranchConfirmableAction; branchName: string; includesCommit: boolean; + terminology?: ChangeRequestTerminology; }): DefaultBranchActionDialogCopy { const branchLabel = input.branchName; - const suffix = ` on "${branchLabel}". You can continue on this branch or create a feature branch and run the same action there.`; + const suffix = ` on "${branchLabel}". You can continue on this ref or create a feature ref and run the same action there.`; + const terminology = input.terminology ?? DEFAULT_CHANGE_REQUEST_TERMINOLOGY; if (input.action === "push" || input.action === "commit_push") { if (input.includesCommit) { return { - title: "Commit & push to default branch?", + title: "Commit & push to default ref?", description: `This action will commit and push changes${suffix}`, continueLabel: `Commit & push to ${branchLabel}`, }; } return { - title: "Push to default branch?", + title: "Push to default ref?", description: `This action will push local commits${suffix}`, continueLabel: `Push to ${branchLabel}`, }; @@ -315,15 +334,15 @@ export function resolveDefaultBranchActionDialogCopy(input: { if (input.includesCommit) { return { - title: "Commit, push & create PR from default branch?", - description: `This action will commit, push, and create a PR${suffix}`, - continueLabel: `Commit, push & create PR`, + title: `Commit, push & create ${terminology.shortLabel} from default ref?`, + description: `This action will commit, push, and create a ${terminology.singular}${suffix}`, + continueLabel: `Commit, push & create ${terminology.shortLabel}`, }; } return { - title: "Push & create PR from default branch?", - description: `This action will push local commits and create a PR${suffix}`, - continueLabel: "Push & create PR", + title: `Push & create ${terminology.shortLabel} from default ref?`, + description: `This action will push local commits and create a ${terminology.singular}${suffix}`, + continueLabel: `Push & create ${terminology.shortLabel}`, }; } @@ -341,31 +360,31 @@ export function resolveThreadBranchUpdate( export function resolveLiveThreadBranchUpdate(input: { threadBranch: string | null; - gitStatus: GitStatusResult | null; + gitStatus: VcsStatusResult | null; }): { branch: string | null } | null { if (!input.gitStatus) { return null; } - if (input.gitStatus.branch === null && input.threadBranch !== null) { + if (input.gitStatus.refName === null && input.threadBranch !== null) { return null; } - if (input.threadBranch === input.gitStatus.branch) { + if (input.threadBranch === input.gitStatus.refName) { return null; } if ( input.threadBranch !== null && - input.gitStatus.branch !== null && + input.gitStatus.refName !== null && !isTemporaryWorktreeBranch(input.threadBranch) && - isTemporaryWorktreeBranch(input.gitStatus.branch) + isTemporaryWorktreeBranch(input.gitStatus.refName) ) { return null; } return { - branch: input.gitStatus.branch, + branch: input.gitStatus.refName, }; } diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 6141d3546ea9bd8377f2f11adce71da1776e3f22..b4fae359293d76cb7f72572893876f0fd43f4898 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -3,12 +3,11 @@ import type { GitActionProgressEvent, GitRunStackedActionResult, GitStackedAction, - GitStatusResult, + VcsStatusResult, } from "@t3tools/contracts"; import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react"; -import { GitHubIcon } from "./Icons"; import { buildGitActionProgressStages, buildMenuItems, @@ -52,6 +51,7 @@ import { resolvePathLinkTarget } from "~/terminal-links"; import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; import { readEnvironmentApi } from "~/environmentApi"; import { readLocalApi } from "~/localApi"; +import { getSourceControlPresentation } from "~/sourceControlPresentation"; import { useStore } from "~/store"; import { createThreadSelectorByRef } from "~/storeSelectors"; @@ -89,7 +89,7 @@ interface RunGitActionWithToastInput { commitMessage?: string; onConfirmed?: () => void; skipDefaultBranchPrompt?: boolean; - statusOverride?: GitStatusResult | null; + statusOverride?: VcsStatusResult | null; featureBranch?: boolean; progressToastId?: GitActionToastId; filePaths?: string[]; @@ -121,22 +121,23 @@ function getMenuActionDisabledReason({ item, gitStatus, isBusy, - hasOriginRemote, + hasPrimaryRemote, }: { item: GitActionMenuItem; - gitStatus: GitStatusResult | null; + gitStatus: VcsStatusResult | null; isBusy: boolean; - hasOriginRemote: boolean; + hasPrimaryRemote: boolean; }): string | null { if (!item.disabled) return null; if (isBusy) return "Git action in progress."; if (!gitStatus) return "Git status is unavailable."; - const hasBranch = gitStatus.branch !== null; + const hasBranch = gitStatus.refName !== null; const hasChanges = gitStatus.hasWorkingTreeChanges; const hasOpenPr = gitStatus.pr?.state === "open"; const isAhead = gitStatus.aheadCount > 0; const isBehind = gitStatus.behindCount > 0; + const terminology = getSourceControlPresentation(gitStatus.sourceControlProvider).terminology; if (item.id === "commit") { if (!hasChanges) { @@ -147,7 +148,7 @@ function getMenuActionDisabledReason({ if (item.id === "push") { if (!hasBranch) { - return "Detached HEAD: checkout a branch before pushing."; + return "Detached HEAD: checkout a refName before pushing."; } if (hasChanges) { return "Commit or stash local changes before pushing."; @@ -155,7 +156,7 @@ function getMenuActionDisabledReason({ if (isBehind) { return "Branch is behind upstream. Pull/rebase before pushing."; } - if (!gitStatus.hasUpstream && !hasOriginRemote) { + if (!gitStatus.hasUpstream && !hasPrimaryRemote) { return 'Add an "origin" remote before pushing.'; } if (!isAhead) { @@ -165,46 +166,58 @@ function getMenuActionDisabledReason({ } if (hasOpenPr) { - return "View PR is currently unavailable."; + return `View ${terminology.singular} is currently unavailable.`; } if (!hasBranch) { - return "Detached HEAD: checkout a branch before creating a PR."; + return `Detached HEAD: checkout a refName before creating a ${terminology.singular}.`; } if (hasChanges) { - return "Commit local changes before creating a PR."; + return `Commit local changes before creating a ${terminology.singular}.`; } - if (!gitStatus.hasUpstream && !hasOriginRemote) { - return 'Add an "origin" remote before creating a PR.'; + if (!gitStatus.hasUpstream && !hasPrimaryRemote) { + return `Add an "origin" remote before creating a ${terminology.singular}.`; } if (!isAhead) { - return "No local commits to include in a PR."; + return `No local commits to include in a ${terminology.singular}.`; } if (isBehind) { - return "Branch is behind upstream. Pull/rebase before creating a PR."; + return `Branch is behind upstream. Pull/rebase before creating a ${terminology.singular}.`; } - return "Create PR is currently unavailable."; + return `Create ${terminology.singular} is currently unavailable.`; } const COMMIT_DIALOG_TITLE = "Commit changes"; const COMMIT_DIALOG_DESCRIPTION = "Review and confirm your commit. Leave the message blank to auto-generate one."; -function GitActionItemIcon({ icon }: { icon: GitActionIconName }) { +function GitActionItemIcon({ + icon, + SourceControlIcon, +}: { + icon: GitActionIconName; + SourceControlIcon: ReturnType["Icon"]; +}) { if (icon === "commit") return ; if (icon === "push") return ; - return ; + return ; } -function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) { +function GitQuickActionIcon({ + quickAction, + SourceControlIcon, +}: { + quickAction: GitQuickAction; + SourceControlIcon: ReturnType["Icon"]; +}) { const iconClassName = "size-3.5"; - if (quickAction.kind === "open_pr") return ; + if (quickAction.kind === "open_pr") return ; if (quickAction.kind === "run_pull") return ; if (quickAction.kind === "run_action") { if (quickAction.action === "commit") return ; if (quickAction.action === "push" || quickAction.action === "commit_push") { return ; } - return ; + return ; } if (quickAction.label === "Commit") return ; return ; @@ -322,9 +335,15 @@ export default function GitActionsControl({ environmentId: activeEnvironmentId, cwd: gitCwd, }); + const sourceControlPresentation = useMemo( + () => getSourceControlPresentation(gitStatus?.sourceControlProvider), + [gitStatus?.sourceControlProvider], + ); + const changeRequestTerminology = sourceControlPresentation.terminology; + const SourceControlIcon = sourceControlPresentation.Icon; // Default to true while loading so we don't flash init controls. const isRepo = gitStatus?.isRepo ?? true; - const hasOriginRemote = gitStatus?.hasOriginRemote ?? false; + const hasPrimaryRemote = gitStatus?.hasPrimaryRemote ?? false; const gitStatusForActions = gitStatus; const allFiles = gitStatusForActions?.workingTree.files ?? []; @@ -382,18 +401,18 @@ export default function GitActionsControl({ persistThreadBranchSync, ]); - const isDefaultBranch = useMemo(() => { - return gitStatusForActions?.isDefaultBranch ?? false; - }, [gitStatusForActions?.isDefaultBranch]); + const isDefaultRef = useMemo(() => { + return gitStatusForActions?.isDefaultRef ?? false; + }, [gitStatusForActions?.isDefaultRef]); const gitActionMenuItems = useMemo( - () => buildMenuItems(gitStatusForActions, isGitActionRunning, hasOriginRemote), - [gitStatusForActions, hasOriginRemote, isGitActionRunning], + () => buildMenuItems(gitStatusForActions, isGitActionRunning, hasPrimaryRemote), + [gitStatusForActions, hasPrimaryRemote, isGitActionRunning], ); const quickAction = useMemo( () => - resolveQuickAction(gitStatusForActions, isGitActionRunning, isDefaultBranch, hasOriginRemote), - [gitStatusForActions, hasOriginRemote, isDefaultBranch, isGitActionRunning], + resolveQuickAction(gitStatusForActions, isGitActionRunning, isDefaultRef, hasPrimaryRemote), + [gitStatusForActions, hasPrimaryRemote, isDefaultRef, isGitActionRunning], ); const quickActionDisabledReason = quickAction.disabled ? (quickAction.hint ?? "This action is currently unavailable.") @@ -403,6 +422,7 @@ export default function GitActionsControl({ action: pendingDefaultBranchAction.action, branchName: pendingDefaultBranchAction.branchName, includesCommit: pendingDefaultBranchAction.includesCommit, + terminology: changeRequestTerminology, }) : null; @@ -468,7 +488,7 @@ export default function GitActionsControl({ if (!prUrl) { toastManager.add({ type: "error", - title: "No open PR found.", + title: "No open pull request found.", data: threadToastData, }); return; @@ -477,7 +497,7 @@ export default function GitActionsControl({ toastManager.add( stackedThreadToast({ type: "error", - title: "Unable to open PR link", + title: "Unable to open pull request link", description: err instanceof Error ? err.message : "An error occurred.", ...(threadToastData !== undefined ? { data: threadToastData } : {}), }), @@ -497,8 +517,8 @@ export default function GitActionsControl({ filePaths, }: RunGitActionWithToastInput) => { const actionStatus = statusOverride ?? gitStatusForActions; - const actionBranch = actionStatus?.branch ?? null; - const actionIsDefaultBranch = featureBranch ? false : isDefaultBranch; + const actionBranch = actionStatus?.refName ?? null; + const actionIsDefaultBranch = featureBranch ? false : isDefaultRef; const actionCanCommit = action === "commit" || action === "commit_push" || action === "commit_push_pr"; const includesCommit = @@ -534,6 +554,7 @@ export default function GitActionsControl({ hasCustomCommitMessage: !!commitMessage?.trim(), hasWorkingTreeChanges: !!actionStatus?.hasWorkingTreeChanges, featureBranch, + terminology: changeRequestTerminology, shouldPushBeforePr: action === "create_pr" && (!actionStatus?.hasUpstream || (actionStatus?.aheadCount ?? 0) > 0), @@ -775,8 +796,8 @@ export default function GitActionsControl({ title: result.status === "pulled" ? "Pulled" : "Already up to date", description: result.status === "pulled" - ? `Updated ${result.branch} from ${result.upstreamBranch ?? "upstream"}` - : `${result.branch} is already synchronized.`, + ? `Updated ${result.refName} from ${result.upstreamRef ?? "upstream"}` + : `${result.refName} is already synchronized.`, data: threadToastData, }), error: (err) => ({ @@ -889,7 +910,10 @@ export default function GitActionsControl({ /> } > - + {quickAction.label} @@ -905,7 +929,7 @@ export default function GitActionsControl({ disabled={isGitActionRunning || quickAction.disabled} onClick={runQuickAction} > - + {quickAction.label} @@ -934,7 +958,7 @@ export default function GitActionsControl({ item, gitStatus: gitStatusForActions, isBusy: isGitActionRunning, - hasOriginRemote, + hasPrimaryRemote, }); if (item.disabled && disabledReason) { return ( @@ -945,7 +969,10 @@ export default function GitActionsControl({ render={} > - + {item.label} @@ -964,18 +991,19 @@ export default function GitActionsControl({ openDialogForMenuItem(item); }} > - + {item.label} ); })} - {gitStatusForActions?.branch === null && ( + {gitStatusForActions?.refName === null && (

- Detached HEAD: create and checkout a branch to enable push and PR actions. + Detached HEAD: create and checkout a refName to enable push and pull request + actions.

)} {gitStatusForActions && - gitStatusForActions.branch !== null && + gitStatusForActions.refName !== null && !gitStatusForActions.hasWorkingTreeChanges && gitStatusForActions.behindCount > 0 && gitStatusForActions.aheadCount === 0 && ( @@ -1013,10 +1041,12 @@ export default function GitActionsControl({ Branch - {gitStatusForActions?.branch ?? "(detached HEAD)"} + {gitStatusForActions?.refName ?? "(detached HEAD)"} - {isDefaultBranch && ( - Warning: default branch + {isDefaultRef && ( + + Warning: default refName + )}
@@ -1149,7 +1179,7 @@ export default function GitActionsControl({ disabled={noneSelected} onClick={runDialogActionOnNewBranch} > - Commit on new branch + Commit on new refName } /> @@ -1002,7 +1002,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec toastManager.add( stackedThreadToast({ type: "error", - title: "Unable to open PR link", + title: "Unable to open pull request link", description: error instanceof Error ? error.message : "An error occurred.", }), ); diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 497e0f88339852c12e278f8deac6a743e27324ff..5ed0c0e4c40fc7a34a08a1c018634231732dd7ba 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -1,5 +1,5 @@ import { scopeProjectRef, scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime"; -import type { GitStatusResult } from "@t3tools/contracts"; +import type { VcsStatusResult } from "@t3tools/contracts"; import { CloudIcon, GitPullRequestIcon, TerminalIcon } from "lucide-react"; import { useMemo } from "react"; import { usePrimaryEnvironmentId } from "../environments/primary"; @@ -11,12 +11,13 @@ import { useGitStatus } from "../lib/gitStatusState"; import { type AppState, selectProjectByRef, useStore } from "../store"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { useUiStateStore } from "../uiStateStore"; +import { resolveChangeRequestPresentation } from "../sourceControlPresentation"; import { resolveThreadStatusPill, type ThreadStatusPill } from "./Sidebar.logic"; import type { SidebarThreadSummary } from "../types"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; export interface PrStatusIndicator { - label: "PR open" | "PR closed" | "PR merged"; + label: string; colorClass: string; tooltip: string; url: string; @@ -28,43 +29,51 @@ export interface TerminalStatusIndicator { pulse: boolean; } -export type ThreadPr = GitStatusResult["pr"]; +export type ThreadPr = VcsStatusResult["pr"]; -export function prStatusIndicator(pr: ThreadPr): PrStatusIndicator | null { +export function prStatusIndicator( + pr: ThreadPr, + provider: VcsStatusResult["sourceControlProvider"] | null | undefined, +): PrStatusIndicator | null { if (!pr) return null; + const presentation = resolveChangeRequestPresentation(provider); if (pr.state === "open") { return { - label: "PR open", + label: `${presentation.shortName} open`, colorClass: "text-emerald-600 dark:text-emerald-300/90", - tooltip: `#${pr.number} PR open: ${pr.title}`, + tooltip: `#${pr.number} ${presentation.shortName} open: ${pr.title}`, url: pr.url, }; } if (pr.state === "closed") { return { - label: "PR closed", + label: `${presentation.shortName} closed`, colorClass: "text-zinc-500 dark:text-zinc-400/80", - tooltip: `#${pr.number} PR closed: ${pr.title}`, + tooltip: `#${pr.number} ${presentation.shortName} closed: ${pr.title}`, url: pr.url, }; } if (pr.state === "merged") { return { - label: "PR merged", + label: `${presentation.shortName} merged`, colorClass: "text-violet-600 dark:text-violet-300/90", - tooltip: `#${pr.number} PR merged: ${pr.title}`, + tooltip: `#${pr.number} ${presentation.shortName} merged: ${pr.title}`, url: pr.url, }; } return null; } +export function ChangeRequestStatusIcon({ className }: { className?: string }) { + return ; +} + export function resolveThreadPr( threadBranch: string | null, - gitStatus: GitStatusResult | null, + gitStatus: VcsStatusResult | null, ): ThreadPr | null { - if (threadBranch === null || gitStatus === null || gitStatus.branch !== threadBranch) { + if (threadBranch === null || gitStatus === null || gitStatus.refName !== threadBranch) { return null; } @@ -124,7 +133,7 @@ export function ThreadStatusLabel({ /** * Non-interactive leading status icons for a thread row in compact contexts - * like the command palette. Shows the PR state icon (if present) and the + * like the command palette. Shows the change request state icon (if present) and the * thread status dot, matching the sidebar's leading indicators. */ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummary }) { @@ -146,7 +155,7 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar cwd: thread.branch != null ? gitCwd : null, }); const pr = resolveThreadPr(thread.branch, gitStatus.data); - const prStatus = prStatusIndicator(pr); + const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); const threadStatus = resolveThreadStatusPill({ thread: { ...thread, @@ -170,7 +179,7 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar /> } > - + {prStatus.tooltip} diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 6d492618f5b0030b1bdb19d628c762a5a2534800..236e1db56556fc6f3844566ce21dc4348bfacf0a 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -1,7 +1,7 @@ "use client"; import { ChevronDownIcon, PlusIcon, Trash2Icon, XIcon } from "lucide-react"; -import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { useEffect, useState, type ReactNode } from "react"; import { isProviderDriverKind, type ProviderInstanceConfig, @@ -24,6 +24,7 @@ import type { DriverOption } from "./providerDriverMeta"; import { ProviderSettingsForm } from "./ProviderSettingsForm"; import { ProviderModelsSection } from "./ProviderModelsSection"; import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; +import { RedactedSensitiveText } from "./RedactedSensitiveText"; import { PROVIDER_STATUS_STYLES, getProviderSummary, @@ -42,8 +43,6 @@ const PROVIDER_ACCENT_SWATCHES = [ const ENVIRONMENT_VARIABLE_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/; -const REDACTED_EMAIL_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789"; - let environmentVariableDraftId = 0; const nextEnvironmentVariableDraftId = () => `provider-env-${environmentVariableDraftId++}`; @@ -68,25 +67,6 @@ function makeEnvironmentDraftRow( }; } -function redactedEmailPlaceholder(email: string): string { - let state = 0x811c9dc5; - for (let index = 0; index < email.length; index += 1) { - state ^= email.charCodeAt(index); - state = Math.imul(state, 0x01000193); - } - - const nextChar = () => { - state = Math.imul(state ^ (state >>> 13), 0x85ebca6b); - state = Math.imul(state ^ (state >>> 16), 0xc2b2ae35); - return REDACTED_EMAIL_ALPHABET[Math.abs(state) % REDACTED_EMAIL_ALPHABET.length] ?? "x"; - }; - - return Array.from(email, (char) => { - if (char === "@" || char === "." || char === "-" || char === "_") return char; - return nextChar(); - }).join(""); -} - /** * Read a string[] at `key` from the opaque config blob, filtering out * non-string entries. Used for `customModels`, which is always typed as @@ -146,35 +126,19 @@ function ProviderAuthEmail(props: { readonly prefix?: string; readonly separator?: boolean; }) { - const [revealed, setRevealed] = useState(false); const trimmed = props.email?.trim(); - const redacted = useMemo(() => (trimmed ? redactedEmailPlaceholder(trimmed) : ""), [trimmed]); if (!trimmed) return null; return ( {props.separator ? · : null} {props.prefix ? {props.prefix} : null} - - setRevealed((value) => !value)} - aria-label={revealed ? "Hide account email" : "Reveal account email"} - > - {revealed ? trimmed : redacted} - - } - /> - - {revealed ? "Click to hide email" : "Click to reveal email"} - - + ); } diff --git a/apps/web/src/components/settings/RedactedSensitiveText.tsx b/apps/web/src/components/settings/RedactedSensitiveText.tsx new file mode 100644 index 0000000000000000000000000000000000000000..95b31b403698edef839f9c9f9cf00b7bb4932c8b --- /dev/null +++ b/apps/web/src/components/settings/RedactedSensitiveText.tsx @@ -0,0 +1,61 @@ +import { useMemo, useState } from "react"; + +import { cn } from "../../lib/utils"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; + +const REDACTED_TEXT_ALPHABET = "abcdefghjkmnpqrstuvwxyz23456789"; + +function redactedPlaceholder(value: string): string { + let state = 0x811c9dc5; + for (let index = 0; index < value.length; index += 1) { + state ^= value.charCodeAt(index); + state = Math.imul(state, 0x01000193); + } + + const nextChar = () => { + state = Math.imul(state ^ (state >>> 13), 0x85ebca6b); + state = Math.imul(state ^ (state >>> 16), 0xc2b2ae35); + return REDACTED_TEXT_ALPHABET[Math.abs(state) % REDACTED_TEXT_ALPHABET.length] ?? "x"; + }; + + return Array.from(value, (char) => { + if (char === "@" || char === "." || char === "-" || char === "_") return char; + return nextChar(); + }).join(""); +} + +export function RedactedSensitiveText(props: { + readonly value: string | null | undefined; + readonly ariaLabel: string; + readonly revealTooltip: string; + readonly hideTooltip: string; + readonly className?: string; +}) { + const [revealed, setRevealed] = useState(false); + const value = props.value?.trim(); + const redacted = useMemo(() => (value ? redactedPlaceholder(value) : ""), [value]); + + if (!value) return null; + + return ( + + setRevealed((current) => !current)} + aria-label={props.ariaLabel} + > + {revealed ? value : redacted} + + } + /> + {revealed ? props.hideTooltip : props.revealTooltip} + + ); +} diff --git a/apps/web/src/components/settings/SettingsPanels.browser.tsx b/apps/web/src/components/settings/SettingsPanels.browser.tsx index 4406d1607c58342d91e81d86d15863ae31862503..51cfe653279379aad0e1c6d99a9582e5e02d289c 100644 --- a/apps/web/src/components/settings/SettingsPanels.browser.tsx +++ b/apps/web/src/components/settings/SettingsPanels.browser.tsx @@ -11,17 +11,19 @@ import { type DesktopUpdateState, type LocalApi, type ServerConfig, + type SourceControlDiscoveryResult, } from "@t3tools/contracts"; -import { DateTime } from "effect"; +import { DateTime, Option } from "effect"; import { page } from "vitest/browser"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { render } from "vitest-browser-react"; import { __resetLocalApiForTests } from "../../localApi"; -import { AppAtomRegistryProvider } from "../../rpc/atomRegistry"; +import { AppAtomRegistryProvider, resetAppAtomRegistryForTests } from "../../rpc/atomRegistry"; import { resetServerStateForTests, setServerConfigSnapshot } from "../../rpc/serverState"; import { ConnectionsSettings } from "./ConnectionsSettings"; import { GeneralSettingsPanel } from "./SettingsPanels"; +import { SourceControlSettingsPanel } from "./SourceControlSettings"; const authAccessHarness = vi.hoisted(() => { type Snapshot = AuthAccessSnapshot; @@ -723,3 +725,148 @@ describe("GeneralSettingsPanel observability", () => { await expect.element(page.getByPlaceholder("Optional")).toBeInTheDocument(); }); }); + +describe("SourceControlSettingsPanel discovery states", () => { + let mounted: + | (Awaited> & { + cleanup?: () => Promise; + unmount?: () => Promise; + }) + | null = null; + + beforeEach(async () => { + resetAppAtomRegistryForTests(); + await __resetLocalApiForTests(); + document.body.innerHTML = ""; + }); + + afterEach(async () => { + if (mounted) { + const teardown = mounted.cleanup ?? mounted.unmount; + await teardown?.call(mounted).catch(() => {}); + } + mounted = null; + Reflect.deleteProperty(window, "nativeApi"); + document.body.innerHTML = ""; + await __resetLocalApiForTests(); + resetAppAtomRegistryForTests(); + }); + + function setSourceControlDiscoveryStub( + discoverSourceControl: () => Promise, + ) { + window.nativeApi = { + server: { + discoverSourceControl, + }, + } as LocalApi; + } + + it("shows skeleton sections while the first source control scan is pending", async () => { + setSourceControlDiscoveryStub(() => new Promise(() => {})); + + mounted = await render( + + + , + ); + + await expect.element(page.getByText("Version Control")).toBeInTheDocument(); + await expect.element(page.getByText("Source Control Providers")).toBeInTheDocument(); + await expect + .element(page.getByRole("button", { name: "Scan source control tools" })) + .toBeDisabled(); + await expect.element(page.getByText("No source control tools found")).not.toBeInTheDocument(); + }); + + it("uses the shared empty state when discovery completes without tools", async () => { + setSourceControlDiscoveryStub(async () => ({ + versionControlSystems: [], + sourceControlProviders: [], + })); + + mounted = await render( + + + , + ); + + await expect.element(page.getByText("No source control tools found")).toBeInTheDocument(); + await expect + .element(page.getByText("Install a supported Git or pull request CLI, then scan again.")) + .toBeInTheDocument(); + await expect.element(page.getByRole("button", { name: "Scan" })).toBeInTheDocument(); + }); + + it("keeps discovered rows instead of showing the empty state", async () => { + setSourceControlDiscoveryStub(async () => ({ + versionControlSystems: [ + { + kind: "git", + label: "Git", + executable: "git", + implemented: true, + status: "available", + version: Option.some("git version 2.50.0"), + installHint: "Install Git.", + detail: Option.none(), + }, + ], + sourceControlProviders: [], + })); + + mounted = await render( + + + , + ); + + await expect.element(page.getByRole("heading", { name: "Git" })).toBeInTheDocument(); + await expect.element(page.getByText("No source control tools found")).not.toBeInTheDocument(); + }); + + it("does not rescan on remount while the discovery atom is fresh", async () => { + let calls = 0; + setSourceControlDiscoveryStub(async () => { + calls += 1; + return { + versionControlSystems: [ + { + kind: "git", + label: "Git", + executable: "git", + implemented: true, + status: "available", + version: Option.some("git version 2.50.0"), + installHint: "Install Git.", + detail: Option.none(), + }, + ], + sourceControlProviders: [], + }; + }); + + mounted = await render( + + + , + ); + + await expect.element(page.getByRole("heading", { name: "Git" })).toBeInTheDocument(); + expect(calls).toBe(1); + + const teardown = mounted.cleanup ?? mounted.unmount; + await teardown?.call(mounted).catch(() => {}); + mounted = null; + document.body.innerHTML = ""; + + mounted = await render( + + + , + ); + + await expect.element(page.getByRole("heading", { name: "Git" })).toBeInTheDocument(); + expect(calls).toBe(1); + }); +}); diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index 510b332a03c5cb13165a9064fd53384147aa216e..88260355d979dfeb190ce6dd4c31ece671f1c0ea 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -1,5 +1,5 @@ import { useCallback, type ComponentType } from "react"; -import { ArchiveIcon, ArrowLeftIcon, Link2Icon, Settings2Icon } from "lucide-react"; +import { ArchiveIcon, ArrowLeftIcon, GitBranchIcon, Link2Icon, Settings2Icon } from "lucide-react"; import { useCanGoBack, useNavigate } from "@tanstack/react-router"; import { @@ -15,6 +15,7 @@ import { export type SettingsSectionPath = | "/settings/general" + | "/settings/source-control" | "/settings/connections" | "/settings/archived"; @@ -24,6 +25,7 @@ export const SETTINGS_NAV_ITEMS: ReadonlyArray<{ icon: ComponentType<{ className?: string }>; }> = [ { label: "General", to: "/settings/general", icon: Settings2Icon }, + { label: "Source Control", to: "/settings/source-control", icon: GitBranchIcon }, { label: "Connections", to: "/settings/connections", icon: Link2Icon }, { label: "Archive", to: "/settings/archived", icon: ArchiveIcon }, ]; diff --git a/apps/web/src/components/settings/SourceControlSettings.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c5f7909e865e7f152329f4829b4e352ff0d1eff8 --- /dev/null +++ b/apps/web/src/components/settings/SourceControlSettings.tsx @@ -0,0 +1,471 @@ +import { GitPullRequestIcon, RefreshCwIcon } from "lucide-react"; +import { Option } from "effect"; +import { type ReactNode, useId } from "react"; +import type { + SourceControlProviderKind, + SourceControlDiscoveryResult, + SourceControlProviderAuth, + SourceControlProviderDiscoveryItem, + VcsDiscoveryItem, +} from "@t3tools/contracts"; + +import { cn } from "../../lib/utils"; +import { + refreshSourceControlDiscovery, + useSourceControlDiscovery, +} from "../../lib/sourceControlDiscoveryState"; +import { Badge } from "../ui/badge"; +import { Button } from "../ui/button"; +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "../ui/empty"; +import { Skeleton } from "../ui/skeleton"; +import { Switch } from "../ui/switch"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { GitHubIcon, type Icon } from "../Icons"; +import { RedactedSensitiveText } from "./RedactedSensitiveText"; +import { SettingsPageContainer, SettingsSection } from "./settingsLayout"; + +const EMPTY_DISCOVERY_RESULT: SourceControlDiscoveryResult = { + versionControlSystems: [], + sourceControlProviders: [], +}; + +const GitLabIcon: Icon = (props) => ( + + + + + + +); + +const AzureDevOpsIcon: Icon = (props) => { + const id = useId().replaceAll(":", ""); + const gradientA = `${id}-azure-a`; + const gradientB = `${id}-azure-b`; + const gradientC = `${id}-azure-c`; + + return ( + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +const BitbucketIcon: Icon = (props) => { + const id = useId().replaceAll(":", ""); + const gradientId = `${id}-bitbucket-a`; + + return ( + + + + + + + + + + ); +}; + +const SOURCE_CONTROL_PROVIDER_ICONS: Partial> = { + github: GitHubIcon, + gitlab: GitLabIcon, + "azure-devops": AzureDevOpsIcon, + bitbucket: BitbucketIcon, +}; + +function optionLabel(value: Option.Option): string | null { + return Option.getOrNull(value); +} + +function isProviderDiscoveryItem( + item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem, +): item is SourceControlProviderDiscoveryItem { + return "auth" in item; +} + +function authPresentation(auth: SourceControlProviderAuth): { + readonly label: string; + readonly badge: "warning" | null; +} { + if (auth.status === "authenticated") { + return { label: "Signed in", badge: null }; + } + if (auth.status === "unauthenticated") { + return { label: "Sign in", badge: "warning" }; + } + return { label: "Sign in", badge: null }; +} + +function RedactedAccount(props: { readonly account: string | null }) { + return ( + + ); +} + +function itemStatusDot(item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem): string { + if (!item.implemented) return "bg-muted-foreground/35"; + if (item.status !== "available") return "bg-warning"; + if (isProviderDiscoveryItem(item) && item.auth.status !== "authenticated") return "bg-warning"; + return "bg-success"; +} + +function SourceControlItemMark({ + item, +}: { + readonly item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem; +}) { + const dotClassName = itemStatusDot(item); + const Icon = isProviderDiscoveryItem(item) ? SOURCE_CONTROL_PROVIDER_ICONS[item.kind] : null; + + if (!Icon) { + return ; + } + + return ( + + + + + ); +} + +function itemSummary({ + item, + auth, + authAccount, +}: { + readonly item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem; + readonly auth: SourceControlProviderAuth | null; + readonly authAccount: string | null; +}) { + if (!item.implemented) { + return Support for {item.label} is coming soon.; + } + + if (item.status !== "available") { + return Not found - {item.installHint}; + } + + if (auth) { + if (auth.status === "authenticated") { + return ( + <> + Authenticated + {authAccount ? ( + <> + as + + + ) : null} + + ); + } + if (auth.status === "unauthenticated") { + return Sign in with the {item.executable} CLI to enable pull request actions.; + } + return ( + + Install and sign in with the {item.executable} CLI to enable pull request actions. + + ); + } + + return Available; +} + +function DiscoveryItemRow({ + item, +}: { + readonly item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem; +}) { + const version = optionLabel(item.version); + const enabled = item.implemented && item.status === "available"; + const auth = isProviderDiscoveryItem(item) ? item.auth : null; + const authStatus = auth ? authPresentation(auth) : null; + const authAccount = auth ? optionLabel(auth.account) : null; + + return ( +
+
+
+
+
+ +

+ {item.label} +

+ {version ? {version} : null} + {!item.implemented ? ( + + Coming Soon + + ) : null} + {authStatus?.badge ? ( + + {authStatus.label} + + ) : null} +
+

+ {itemSummary({ item, auth, authAccount })} +

+
+
+ {item.implemented ? ( + + ) : null} +
+
+
+
+ ); +} + +function SourceControlSectionSkeleton({ + title, + headerAction, +}: { + readonly title: string; + readonly headerAction?: ReactNode; +}) { + return ( + + {Array.from({ length: 2 }, (_, index) => ( +
+
+
+
+ + + +
+ +
+
+ + +
+
+
+ ))} +
+ ); +} + +function EmptySourceControlDiscovery({ + error, + isPending, + onScan, +}: { + readonly error: string | null; + readonly isPending: boolean; + readonly onScan: () => void; +}) { + const hasError = error !== null; + + return ( + + + + + + + + {hasError ? "Could not scan source control" : "No source control tools found"} + + + {hasError ? error : "Install a supported Git or pull request CLI, then scan again."} + + + + + + + + ); +} + +export function SourceControlSettingsPanel() { + const discovery = useSourceControlDiscovery(); + + const result = discovery.data ?? EMPTY_DISCOVERY_RESULT; + const hasDiscoveryItems = + result.versionControlSystems.length > 0 || result.sourceControlProviders.length > 0; + const isInitialScanPending = discovery.isPending && discovery.data === null; + const handleScan = () => { + void refreshSourceControlDiscovery(); + }; + const scanButton = ( + + + + + } + /> + Scan source control tools + + ); + + return ( + + {isInitialScanPending ? ( + <> + + + + ) : hasDiscoveryItems ? ( + <> + {result.versionControlSystems.length > 0 ? ( + + {result.versionControlSystems.map((item) => ( + + ))} + + ) : null} + + {result.sourceControlProviders.length > 0 ? ( + + {result.sourceControlProviders.map((item) => ( + + ))} + + ) : null} + + ) : ( + + )} + + ); +} diff --git a/apps/web/src/components/settings/settingsLayout.tsx b/apps/web/src/components/settings/settingsLayout.tsx index f41e32f9e5ce6dd4bdeda542cf8884736a873470..98769aca7471a1c2e10f914dca47c9024eb30baa 100644 --- a/apps/web/src/components/settings/settingsLayout.tsx +++ b/apps/web/src/components/settings/settingsLayout.tsx @@ -28,13 +28,13 @@ export function SettingsSection({ }) { return (
-
+

{icon} {title}

- {headerAction} +
{headerAction}
{children} diff --git a/apps/web/src/environmentApi.ts b/apps/web/src/environmentApi.ts index 64df079df49b71a91136562fa817253148d08d4f..d2c84b1df1efca062710cdcea5726467770cbe63 100644 --- a/apps/web/src/environmentApi.ts +++ b/apps/web/src/environmentApi.ts @@ -23,16 +23,18 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi { filesystem: { browse: rpcClient.filesystem.browse, }, + vcs: { + pull: rpcClient.vcs.pull, + refreshStatus: rpcClient.vcs.refreshStatus, + onStatus: (input, callback, options) => rpcClient.vcs.onStatus(input, callback, options), + listRefs: rpcClient.vcs.listRefs, + createWorktree: rpcClient.vcs.createWorktree, + removeWorktree: rpcClient.vcs.removeWorktree, + createRef: rpcClient.vcs.createRef, + switchRef: rpcClient.vcs.switchRef, + init: rpcClient.vcs.init, + }, git: { - pull: rpcClient.git.pull, - refreshStatus: rpcClient.git.refreshStatus, - onStatus: (input, callback, options) => rpcClient.git.onStatus(input, callback, options), - listBranches: rpcClient.git.listBranches, - createWorktree: rpcClient.git.createWorktree, - removeWorktree: rpcClient.git.removeWorktree, - createBranch: rpcClient.git.createBranch, - checkout: rpcClient.git.checkout, - init: rpcClient.git.init, resolvePullRequest: rpcClient.git.resolvePullRequest, preparePullRequestThread: rpcClient.git.preparePullRequestThread, }, diff --git a/apps/web/src/environments/runtime/connection.test.ts b/apps/web/src/environments/runtime/connection.test.ts index 57f23241456bcbb885308296417f19b2847d5605..64ff7a1c3ca6aab4ae08e277635ffc3b91b470f9 100644 --- a/apps/web/src/environments/runtime/connection.test.ts +++ b/apps/web/src/environments/runtime/connection.test.ts @@ -85,16 +85,7 @@ function createTestClient() { openInEditor: vi.fn(async () => undefined), }, git: { - pull: vi.fn(async () => undefined), - refreshStatus: vi.fn(async () => undefined), - onStatus: vi.fn(() => () => undefined), runStackedAction: vi.fn(async () => ({}) as any), - listBranches: vi.fn(async () => []), - createWorktree: vi.fn(async () => undefined), - removeWorktree: vi.fn(async () => undefined), - createBranch: vi.fn(async () => undefined), - checkout: vi.fn(async () => undefined), - init: vi.fn(async () => undefined), resolvePullRequest: vi.fn(async () => undefined), preparePullRequestThread: vi.fn(async () => undefined), }, diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index de60ffb18dc7cdc5dc20b8e78dd028d61d1941f7..46a1955cc49df7ed2ac6a0bc808e5da3a4b183ce 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -209,7 +209,7 @@ export function useThreadActions() { } try { - await ensureEnvironmentApi(threadRef.environmentId).git.removeWorktree({ + await ensureEnvironmentApi(threadRef.environmentId).vcs.removeWorktree({ cwd: threadProject.cwd, path: orphanedWorktreePath, force: true, diff --git a/apps/web/src/lib/gitReactQuery.test.ts b/apps/web/src/lib/gitReactQuery.test.ts index 93ba66e4a2433e54b6223f4f53ea508cdc551000..2995207b9389359ef8a2f2456b47c79b89896e96 100644 --- a/apps/web/src/lib/gitReactQuery.test.ts +++ b/apps/web/src/lib/gitReactQuery.test.ts @@ -11,7 +11,7 @@ vi.mock("../wsRpcClient", () => ({ })); import type { InfiniteData } from "@tanstack/react-query"; -import { EnvironmentId, type GitListBranchesResult } from "@t3tools/contracts"; +import { EnvironmentId, type VcsListRefsResult } from "@t3tools/contracts"; import { gitBranchSearchInfiniteQueryOptions, @@ -22,15 +22,15 @@ import { invalidateGitQueries, } from "./gitReactQuery"; -const BRANCH_QUERY_RESULT: GitListBranchesResult = { - branches: [], +const BRANCH_QUERY_RESULT: VcsListRefsResult = { + refs: [], isRepo: true, - hasOriginRemote: true, + hasPrimaryRemote: true, nextCursor: null, totalCount: 0, }; -const BRANCH_SEARCH_RESULT: InfiniteData = { +const BRANCH_SEARCH_RESULT: InfiniteData = { pages: [BRANCH_QUERY_RESULT], pageParams: [0], }; diff --git a/apps/web/src/lib/gitReactQuery.ts b/apps/web/src/lib/gitReactQuery.ts index 68d2b5bf53cb2c9b2d71ac2796c49557fcca82b7..44dd705ceb5aff25771f5827219bdf5837fcd4d1 100644 --- a/apps/web/src/lib/gitReactQuery.ts +++ b/apps/web/src/lib/gitReactQuery.ts @@ -19,17 +19,17 @@ const GIT_BRANCHES_PAGE_SIZE = 100; export const gitQueryKeys = { all: ["git"] as const, - branches: (environmentId: EnvironmentId | null, cwd: string | null) => - ["git", "branches", environmentId ?? null, cwd] as const, + refs: (environmentId: EnvironmentId | null, cwd: string | null) => + ["git", "refs", environmentId ?? null, cwd] as const, branchSearch: (environmentId: EnvironmentId | null, cwd: string | null, query: string) => - ["git", "branches", environmentId ?? null, cwd, "search", query] as const, + ["git", "refs", environmentId ?? null, cwd, "search", query] as const, }; export const gitMutationKeys = { init: (environmentId: EnvironmentId | null, cwd: string | null) => ["git", "mutation", "init", environmentId ?? null, cwd] as const, - checkout: (environmentId: EnvironmentId | null, cwd: string | null) => - ["git", "mutation", "checkout", environmentId ?? null, cwd] as const, + switchRef: (environmentId: EnvironmentId | null, cwd: string | null) => + ["git", "mutation", "switchRef", environmentId ?? null, cwd] as const, runStackedAction: (environmentId: EnvironmentId | null, cwd: string | null) => ["git", "mutation", "run-stacked-action", environmentId ?? null, cwd] as const, pull: (environmentId: EnvironmentId | null, cwd: string | null) => @@ -45,7 +45,7 @@ export function invalidateGitQueries( const environmentId = input?.environmentId ?? null; const cwd = input?.cwd ?? null; if (cwd !== null) { - return queryClient.invalidateQueries({ queryKey: gitQueryKeys.branches(environmentId, cwd) }); + return queryClient.invalidateQueries({ queryKey: gitQueryKeys.refs(environmentId, cwd) }); } return queryClient.invalidateQueries({ queryKey: gitQueryKeys.all }); @@ -60,9 +60,12 @@ function invalidateGitBranchQueries( return Promise.resolve(); } - return queryClient.invalidateQueries({ queryKey: gitQueryKeys.branches(environmentId, cwd) }); + return queryClient.invalidateQueries({ queryKey: gitQueryKeys.refs(environmentId, cwd) }); } +/** + * @deprecated Use a VCS-named query helper once the UI naming migration lands. + */ export function gitBranchSearchInfiniteQueryOptions(input: { environmentId: EnvironmentId | null; cwd: string | null; @@ -75,10 +78,10 @@ export function gitBranchSearchInfiniteQueryOptions(input: { queryKey: gitQueryKeys.branchSearch(input.environmentId, input.cwd, normalizedQuery), initialPageParam: 0, queryFn: async ({ pageParam }) => { - if (!input.cwd) throw new Error("Git branches are unavailable."); - if (!input.environmentId) throw new Error("Git branches are unavailable."); + if (!input.cwd) throw new Error("Git refs are unavailable."); + if (!input.environmentId) throw new Error("Git refs are unavailable."); const api = ensureEnvironmentApi(input.environmentId); - return api.git.listBranches({ + return api.vcs.listRefs({ cwd: input.cwd, ...(normalizedQuery.length > 0 ? { query: normalizedQuery } : {}), cursor: pageParam, @@ -121,6 +124,9 @@ export function gitResolvePullRequestQueryOptions(input: { }); } +/** + * @deprecated Use a VCS-named mutation helper once the UI naming migration lands. + */ export function gitInitMutationOptions(input: { environmentId: EnvironmentId | null; cwd: string | null; @@ -131,7 +137,7 @@ export function gitInitMutationOptions(input: { mutationFn: async () => { if (!input.cwd || !input.environmentId) throw new Error("Git init is unavailable."); const api = ensureEnvironmentApi(input.environmentId); - return api.git.init({ cwd: input.cwd }); + return api.vcs.init({ cwd: input.cwd }); }, onSettled: async () => { await invalidateGitBranchQueries(input.queryClient, input.environmentId, input.cwd); @@ -139,17 +145,20 @@ export function gitInitMutationOptions(input: { }); } +/** + * @deprecated Use a VCS-named mutation helper once the UI naming migration lands. + */ export function gitCheckoutMutationOptions(input: { environmentId: EnvironmentId | null; cwd: string | null; queryClient: QueryClient; }) { return mutationOptions({ - mutationKey: gitMutationKeys.checkout(input.environmentId, input.cwd), - mutationFn: async (branch: string) => { - if (!input.cwd || !input.environmentId) throw new Error("Git checkout is unavailable."); + mutationKey: gitMutationKeys.switchRef(input.environmentId, input.cwd), + mutationFn: async (refName: string) => { + if (!input.cwd || !input.environmentId) throw new Error("Git switchRef is unavailable."); const api = ensureEnvironmentApi(input.environmentId); - return api.git.checkout({ cwd: input.cwd, branch }); + return api.vcs.switchRef({ cwd: input.cwd, refName }); }, onSettled: async () => { await invalidateGitBranchQueries(input.queryClient, input.environmentId, input.cwd); @@ -198,6 +207,9 @@ export function gitRunStackedActionMutationOptions(input: { }); } +/** + * @deprecated Use a VCS-named mutation helper once the UI naming migration lands. + */ export function gitPullMutationOptions(input: { environmentId: EnvironmentId | null; cwd: string | null; @@ -208,7 +220,7 @@ export function gitPullMutationOptions(input: { mutationFn: async () => { if (!input.cwd || !input.environmentId) throw new Error("Git pull is unavailable."); const api = ensureEnvironmentApi(input.environmentId); - return api.git.pull({ cwd: input.cwd }); + return api.vcs.pull({ cwd: input.cwd }); }, onSuccess: async () => { await invalidateGitBranchQueries(input.queryClient, input.environmentId, input.cwd); @@ -216,6 +228,9 @@ export function gitPullMutationOptions(input: { }); } +/** + * @deprecated Use a VCS-named mutation helper once the UI naming migration lands. + */ export function gitCreateWorktreeMutationOptions(input: { environmentId: EnvironmentId | null; queryClient: QueryClient; @@ -223,12 +238,12 @@ export function gitCreateWorktreeMutationOptions(input: { return mutationOptions({ mutationKey: ["git", "mutation", "create-worktree", input.environmentId ?? null] as const, mutationFn: ( - args: Parameters["git"]["createWorktree"]>[0], + args: Parameters["vcs"]["createWorktree"]>[0], ) => { if (!input.environmentId) { throw new Error("Worktree creation is unavailable."); } - return ensureEnvironmentApi(input.environmentId).git.createWorktree(args); + return ensureEnvironmentApi(input.environmentId).vcs.createWorktree(args); }, onSuccess: async () => { await invalidateGitQueries(input.queryClient, { environmentId: input.environmentId }); @@ -236,6 +251,9 @@ export function gitCreateWorktreeMutationOptions(input: { }); } +/** + * @deprecated Use a VCS-named mutation helper once the UI naming migration lands. + */ export function gitRemoveWorktreeMutationOptions(input: { environmentId: EnvironmentId | null; queryClient: QueryClient; @@ -243,12 +261,12 @@ export function gitRemoveWorktreeMutationOptions(input: { return mutationOptions({ mutationKey: ["git", "mutation", "remove-worktree", input.environmentId ?? null] as const, mutationFn: ( - args: Parameters["git"]["removeWorktree"]>[0], + args: Parameters["vcs"]["removeWorktree"]>[0], ) => { if (!input.environmentId) { throw new Error("Worktree removal is unavailable."); } - return ensureEnvironmentApi(input.environmentId).git.removeWorktree(args); + return ensureEnvironmentApi(input.environmentId).vcs.removeWorktree(args); }, onSuccess: async () => { await invalidateGitQueries(input.queryClient, { environmentId: input.environmentId }); diff --git a/apps/web/src/lib/gitStatusState.test.ts b/apps/web/src/lib/gitStatusState.test.ts index 2e17cd15521d25e719005e33c93dae463e0beaa0..40766563fb7b88561f4d5a7c6cc5745f8e48b6bf 100644 --- a/apps/web/src/lib/gitStatusState.test.ts +++ b/apps/web/src/lib/gitStatusState.test.ts @@ -1,4 +1,4 @@ -import { EnvironmentId, type GitStatusResult } from "@t3tools/contracts"; +import { EnvironmentId, type VcsStatusResult } from "@t3tools/contracts"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { WsRpcClient } from "../rpc/wsRpcClient"; @@ -33,17 +33,17 @@ function registerListener(listeners: Set<(event: T) => void>, listener: (even }; } -const gitStatusListeners = new Set<(event: GitStatusResult) => void>(); +const gitStatusListeners = new Set<(event: VcsStatusResult) => void>(); const ENVIRONMENT_ID = EnvironmentId.make("environment-local"); const OTHER_ENVIRONMENT_ID = EnvironmentId.make("environment-remote"); const TARGET = { environmentId: ENVIRONMENT_ID, cwd: "/repo" } as const; const FRESH_TARGET = { environmentId: ENVIRONMENT_ID, cwd: "/fresh" } as const; -const BASE_STATUS: GitStatusResult = { +const BASE_STATUS: VcsStatusResult = { isRepo: true, - hasOriginRemote: true, - isDefaultBranch: false, - branch: "feature/push-status", + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "feature/push-status", hasWorkingTreeChanges: false, workingTree: { files: [], insertions: 0, deletions: 0 }, hasUpstream: true, @@ -55,21 +55,21 @@ const BASE_STATUS: GitStatusResult = { const gitClient = { refreshStatus: vi.fn(async (input: { cwd: string }) => ({ ...BASE_STATUS, - branch: `${input.cwd}-refreshed`, + refName: `${input.cwd}-refreshed`, })), - onStatus: vi.fn((input: { cwd: string }, listener: (event: GitStatusResult) => void) => + onStatus: vi.fn((input: { cwd: string }, listener: (event: VcsStatusResult) => void) => registerListener(gitStatusListeners, listener), ), }; -function emitGitStatus(event: GitStatusResult) { +function emitGitStatus(event: VcsStatusResult) { for (const listener of gitStatusListeners) { listener(event); } } function createRegisteredGitStatusClient(environmentId: EnvironmentId) { - const listeners = new Set<(event: GitStatusResult) => void>(); + const listeners = new Set<(event: VcsStatusResult) => void>(); const client = { dispose: vi.fn(async () => undefined), reconnect: vi.fn(async () => undefined), @@ -89,22 +89,24 @@ function createRegisteredGitStatusClient(environmentId: EnvironmentId) { shell: { openInEditor: vi.fn(async () => undefined), }, - git: { + vcs: { pull: vi.fn(async () => undefined), refreshStatus: vi.fn(async (input: { cwd: string }) => ({ ...BASE_STATUS, - branch: `${input.cwd}-refreshed`, + refName: `${input.cwd}-refreshed`, })), - onStatus: vi.fn((_: { cwd: string }, listener: (event: GitStatusResult) => void) => + onStatus: vi.fn((_: { cwd: string }, listener: (event: VcsStatusResult) => void) => registerListener(listeners, listener), ), - runStackedAction: vi.fn(async () => ({}) as any), - listBranches: vi.fn(async () => []), + listRefs: vi.fn(async () => []), createWorktree: vi.fn(async () => undefined), removeWorktree: vi.fn(async () => undefined), - createBranch: vi.fn(async () => undefined), - checkout: vi.fn(async () => undefined), + createRef: vi.fn(async () => undefined), + switchRef: vi.fn(async () => undefined), init: vi.fn(async () => undefined), + }, + git: { + runStackedAction: vi.fn(async () => ({}) as any), resolvePullRequest: vi.fn(async () => undefined), preparePullRequestThread: vi.fn(async () => undefined), }, @@ -155,7 +157,7 @@ function createRegisteredGitStatusClient(environmentId: EnvironmentId) { return { client, - emit: (event: GitStatusResult) => { + emit: (event: VcsStatusResult) => { for (const listener of listeners) { listener(event); } @@ -219,7 +221,7 @@ describe("gitStatusState", () => { expect(gitClient.onStatus).toHaveBeenCalledOnce(); expect(gitClient.refreshStatus).toHaveBeenCalledWith({ cwd: "/repo" }); - expect(refreshed).toEqual({ ...BASE_STATUS, branch: "/repo-refreshed" }); + expect(refreshed).toEqual({ ...BASE_STATUS, refName: "/repo-refreshed" }); expect(getGitStatusSnapshot(TARGET)).toEqual({ data: BASE_STATUS, error: null, @@ -231,17 +233,17 @@ describe("gitStatusState", () => { }); it("keeps git status subscriptions isolated by environment when cwds match", () => { - const localListeners = new Set<(event: GitStatusResult) => void>(); - const remoteListeners = new Set<(event: GitStatusResult) => void>(); + const localListeners = new Set<(event: VcsStatusResult) => void>(); + const remoteListeners = new Set<(event: VcsStatusResult) => void>(); const localClient = { refreshStatus: vi.fn(), - onStatus: vi.fn((_: { cwd: string }, listener: (event: GitStatusResult) => void) => + onStatus: vi.fn((_: { cwd: string }, listener: (event: VcsStatusResult) => void) => registerListener(localListeners, listener), ), }; const remoteClient = { refreshStatus: vi.fn(), - onStatus: vi.fn((_: { cwd: string }, listener: (event: GitStatusResult) => void) => + onStatus: vi.fn((_: { cwd: string }, listener: (event: VcsStatusResult) => void) => registerListener(remoteListeners, listener), ), }; @@ -254,11 +256,11 @@ describe("gitStatusState", () => { listener(BASE_STATUS); } for (const listener of remoteListeners) { - listener({ ...BASE_STATUS, branch: "remote-branch" }); + listener({ ...BASE_STATUS, refName: "remote-refName" }); } - expect(getGitStatusSnapshot(TARGET).data?.branch).toBe("feature/push-status"); - expect(getGitStatusSnapshot(remoteTarget).data?.branch).toBe("remote-branch"); + expect(getGitStatusSnapshot(TARGET).data?.refName).toBe("feature/push-status"); + expect(getGitStatusSnapshot(remoteTarget).data?.refName).toBe("remote-refName"); releaseLocal(); releaseRemote(); @@ -292,7 +294,7 @@ describe("gitStatusState", () => { const release = watchGitStatus(TARGET); firstClient.emit(BASE_STATUS); - expect(getGitStatusSnapshot(TARGET).data?.branch).toBe("feature/push-status"); + expect(getGitStatusSnapshot(TARGET).data?.refName).toBe("feature/push-status"); serviceHarness.connections.delete(ENVIRONMENT_ID); for (const listener of serviceHarness.listeners) { @@ -307,10 +309,10 @@ describe("gitStatusState", () => { }); const secondClient = createRegisteredGitStatusClient(ENVIRONMENT_ID); - secondClient.emit({ ...BASE_STATUS, branch: "reconnected-branch" }); + secondClient.emit({ ...BASE_STATUS, refName: "reconnected-refName" }); expect(getGitStatusSnapshot(TARGET)).toEqual({ - data: { ...BASE_STATUS, branch: "reconnected-branch" }, + data: { ...BASE_STATUS, refName: "reconnected-refName" }, error: null, cause: null, isPending: false, diff --git a/apps/web/src/lib/gitStatusState.ts b/apps/web/src/lib/gitStatusState.ts index 2c23ae5b826d71675f5c6ef2ac1d0d0cc0ddc2dd..4304dcc4aab4efd316bd9553125af124318309b8 100644 --- a/apps/web/src/lib/gitStatusState.ts +++ b/apps/web/src/lib/gitStatusState.ts @@ -2,7 +2,7 @@ import { useAtomValue } from "@effect/atom-react"; import { type EnvironmentId, type GitManagerServiceError, - type GitStatusResult, + type VcsStatusResult, } from "@t3tools/contracts"; import { Cause } from "effect"; import { Atom } from "effect/unstable/reactivity"; @@ -16,13 +16,13 @@ import { import type { WsRpcClient } from "~/rpc/wsRpcClient"; interface GitStatusState { - readonly data: GitStatusResult | null; + readonly data: VcsStatusResult | null; readonly error: GitManagerServiceError | null; readonly cause: Cause.Cause | null; readonly isPending: boolean; } -type GitStatusClient = Pick; +type GitStatusClient = Pick; interface ResolvedGitStatusClient { readonly clientIdentity: string; readonly client: GitStatusClient; @@ -56,7 +56,7 @@ const EMPTY_GIT_STATUS_ATOM = Atom.make(EMPTY_GIT_STATUS_STATE).pipe( const NOOP: () => void = () => undefined; const watchedGitStatuses = new Map(); const knownGitStatusKeys = new Set(); -const gitStatusRefreshInFlight = new Map>(); +const gitStatusRefreshInFlight = new Map>(); const gitStatusLastRefreshAtByKey = new Map(); const GIT_STATUS_REFRESH_DEBOUNCE_MS = 1_000; @@ -83,7 +83,7 @@ function readResolvedGitStatusClient(target: GitStatusTarget): ResolvedGitStatus } const connection = readEnvironmentConnection(target.environmentId); return connection - ? { clientIdentity: connection.environmentId, client: connection.client.git } + ? { clientIdentity: connection.environmentId, client: connection.client.vcs } : null; } @@ -119,7 +119,7 @@ export function watchGitStatus(target: GitStatusTarget, client?: GitStatusClient export function refreshGitStatus( target: GitStatusTarget, client?: GitStatusClient, -): Promise { +): Promise { const targetKey = getGitStatusTargetKey(target); if (targetKey === null || target.cwd === null) { return Promise.resolve(null); @@ -245,7 +245,7 @@ function subscribeToGitStatus(targetKey: string, cwd: string, client: GitStatusC markGitStatusPending(targetKey); return client.onStatus( { cwd }, - (status: GitStatusResult) => { + (status: VcsStatusResult) => { appAtomRegistry.set(gitStatusStateAtom(targetKey), { data: status, error: null, diff --git a/apps/web/src/lib/sourceControlDiscoveryState.ts b/apps/web/src/lib/sourceControlDiscoveryState.ts new file mode 100644 index 0000000000000000000000000000000000000000..fad4f534908aee69c69c21fb0e174a3b01803def --- /dev/null +++ b/apps/web/src/lib/sourceControlDiscoveryState.ts @@ -0,0 +1,56 @@ +import { useAtomValue } from "@effect/atom-react"; +import { + EMPTY_SOURCE_CONTROL_DISCOVERY_ATOM, + EMPTY_SOURCE_CONTROL_DISCOVERY_STATE, + type SourceControlDiscoveryState, + createSourceControlDiscoveryManager, + getSourceControlDiscoveryTargetKey, + sourceControlDiscoveryStateAtom, +} from "@t3tools/client-runtime"; +import type { SourceControlDiscoveryResult } from "@t3tools/contracts"; +import { Effect } from "effect"; +import { Atom } from "effect/unstable/reactivity"; + +import { readLocalApi } from "../localApi"; +import { appAtomRegistry } from "../rpc/atomRegistry"; + +const SOURCE_CONTROL_DISCOVERY_TARGET = { key: "primary" } as const; +const SOURCE_CONTROL_DISCOVERY_STALE_TIME_MS = 30_000; +const SOURCE_CONTROL_DISCOVERY_IDLE_TTL_MS = 5 * 60_000; + +export const sourceControlDiscoveryManager = createSourceControlDiscoveryManager({ + getRegistry: () => appAtomRegistry, + getClient: () => readLocalApi()?.server ?? null, +}); + +const sourceControlDiscoveryAutoRefreshAtom = Atom.make(() => + Effect.promise(() => refreshSourceControlDiscovery()), +).pipe( + Atom.swr({ + staleTime: SOURCE_CONTROL_DISCOVERY_STALE_TIME_MS, + revalidateOnMount: true, + }), + Atom.setIdleTTL(SOURCE_CONTROL_DISCOVERY_IDLE_TTL_MS), + Atom.withLabel("source-control-discovery:auto-refresh"), +); + +export function refreshSourceControlDiscovery(): Promise { + return sourceControlDiscoveryManager.refresh(SOURCE_CONTROL_DISCOVERY_TARGET); +} + +export function resetSourceControlDiscoveryStateForTests(): void { + sourceControlDiscoveryManager.reset(); +} + +export function useSourceControlDiscovery(): SourceControlDiscoveryState { + const targetKey = getSourceControlDiscoveryTargetKey(SOURCE_CONTROL_DISCOVERY_TARGET); + + useAtomValue(sourceControlDiscoveryAutoRefreshAtom); + + const state = useAtomValue( + targetKey !== null + ? sourceControlDiscoveryStateAtom(targetKey) + : EMPTY_SOURCE_CONTROL_DISCOVERY_ATOM, + ); + return targetKey === null ? EMPTY_SOURCE_CONTROL_DISCOVERY_STATE : state; +} diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index fbdd203e99fdba715dbfe1d381f77035bd275e2e..b627286199c7120f8e31c79266c69958169b8e30 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -3,7 +3,7 @@ import { DEFAULT_SERVER_SETTINGS, type DesktopBridge, EnvironmentId, - type GitStatusResult, + type VcsStatusResult, ProjectId, type OrchestrationShellStreamItem, ProviderDriverKind, @@ -34,7 +34,7 @@ function registerListener(listeners: Set<(event: T) => void>, listener: (even const terminalEventListeners = new Set<(event: TerminalEvent) => void>(); const shellStreamListeners = new Set<(event: OrchestrationShellStreamItem) => void>(); -const gitStatusListeners = new Set<(event: GitStatusResult) => void>(); +const gitStatusListeners = new Set<(event: VcsStatusResult) => void>(); const rpcClientMock = { dispose: vi.fn(), @@ -59,19 +59,21 @@ const rpcClientMock = { shell: { openInEditor: vi.fn(), }, - git: { + vcs: { pull: vi.fn(), refreshStatus: vi.fn(), - onStatus: vi.fn((input: { cwd: string }, listener: (event: GitStatusResult) => void) => + onStatus: vi.fn((input: { cwd: string }, listener: (event: VcsStatusResult) => void) => registerListener(gitStatusListeners, listener), ), - runStackedAction: vi.fn(), - listBranches: vi.fn(), + listRefs: vi.fn(), createWorktree: vi.fn(), removeWorktree: vi.fn(), - createBranch: vi.fn(), - checkout: vi.fn(), + createRef: vi.fn(), + switchRef: vi.fn(), init: vi.fn(), + }, + git: { + runStackedAction: vi.fn(), resolvePullRequest: vi.fn(), preparePullRequestThread: vi.fn(), }, @@ -259,11 +261,11 @@ const baseServerConfig: ServerConfig = { settings: DEFAULT_SERVER_SETTINGS, }; -const baseGitStatus: GitStatusResult = { +const baseGitStatus: VcsStatusResult = { isRepo: true, - hasOriginRemote: true, - isDefaultBranch: false, - branch: "feature/streamed", + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "feature/streamed", hasWorkingTreeChanges: false, workingTree: { files: [], insertions: 0, deletions: 0 }, hasUpstream: true, @@ -351,24 +353,24 @@ describe("wsApi", () => { const api = createEnvironmentApi(rpcClientMock as never); const onStatus = vi.fn(); - api.git.onStatus({ cwd: "/repo" }, onStatus); + api.vcs.onStatus({ cwd: "/repo" }, onStatus); const gitStatus = baseGitStatus; emitEvent(gitStatusListeners, gitStatus); - expect(rpcClientMock.git.onStatus).toHaveBeenCalledWith({ cwd: "/repo" }, onStatus, undefined); + expect(rpcClientMock.vcs.onStatus).toHaveBeenCalledWith({ cwd: "/repo" }, onStatus, undefined); expect(onStatus).toHaveBeenCalledWith(gitStatus); }); it("forwards git status refreshes directly to the RPC client", async () => { - rpcClientMock.git.refreshStatus.mockResolvedValue(baseGitStatus); + rpcClientMock.vcs.refreshStatus.mockResolvedValue(baseGitStatus); const { createEnvironmentApi } = await import("./environmentApi"); const api = createEnvironmentApi(rpcClientMock as never); - await api.git.refreshStatus({ cwd: "/repo" }); + await api.vcs.refreshStatus({ cwd: "/repo" }); - expect(rpcClientMock.git.refreshStatus).toHaveBeenCalledWith({ cwd: "/repo" }); + expect(rpcClientMock.vcs.refreshStatus).toHaveBeenCalledWith({ cwd: "/repo" }); }); it("forwards shell stream subscription options to the RPC client", async () => { diff --git a/apps/web/src/localApi.ts b/apps/web/src/localApi.ts index 4401b5b778e3f251df223dce8289002ff118d508..c872a5f1030447e39068b51b94882389ea248c6a 100644 --- a/apps/web/src/localApi.ts +++ b/apps/web/src/localApi.ts @@ -1,6 +1,7 @@ import type { ContextMenuItem, LocalApi } from "@t3tools/contracts"; import { resetGitStatusStateForTests } from "./lib/gitStatusState"; +import { resetSourceControlDiscoveryStateForTests } from "./lib/sourceControlDiscoveryState"; import { resetRequestLatencyStateForTests } from "./rpc/requestLatencyState"; import { resetServerStateForTests } from "./rpc/serverState"; import { resetWsConnectionStateForTests } from "./rpc/wsConnectionState"; @@ -115,6 +116,7 @@ export function createLocalApi(rpcClient: WsRpcClient): LocalApi { upsertKeybinding: rpcClient.server.upsertKeybinding, getSettings: rpcClient.server.getSettings, updateSettings: rpcClient.server.updateSettings, + discoverSourceControl: rpcClient.server.discoverSourceControl, }, }; } @@ -146,6 +148,7 @@ export async function __resetLocalApiForTests() { __resetClientSettingsPersistenceForTests(); await resetEnvironmentServiceForTests(); resetGitStatusStateForTests(); + resetSourceControlDiscoveryStateForTests(); resetRequestLatencyStateForTests(); resetSavedEnvironmentRegistryStoreForTests(); resetSavedEnvironmentRuntimeStoreForTests(); diff --git a/apps/web/src/pullRequestReference.test.ts b/apps/web/src/pullRequestReference.test.ts index 2a1ed520799d8a2037c097233eea77525429c89c..2067c41cc1a525b1ee9a9df8dbdd582a536bfea7 100644 --- a/apps/web/src/pullRequestReference.test.ts +++ b/apps/web/src/pullRequestReference.test.ts @@ -9,6 +9,24 @@ describe("parsePullRequestReference", () => { ); }); + it("accepts Azure DevOps pull request URLs", () => { + expect( + parsePullRequestReference("https://dev.azure.com/acme/project/_git/t3code/pullrequest/42"), + ).toBe("https://dev.azure.com/acme/project/_git/t3code/pullrequest/42"); + }); + + it("accepts GitLab merge request URLs", () => { + expect(parsePullRequestReference("https://gitlab.com/group/project/-/merge_requests/42")).toBe( + "https://gitlab.com/group/project/-/merge_requests/42", + ); + }); + + it("accepts legacy Azure DevOps pull request URLs", () => { + expect( + parsePullRequestReference("https://acme.visualstudio.com/project/_git/t3code/pullrequest/42"), + ).toBe("https://acme.visualstudio.com/project/_git/t3code/pullrequest/42"); + }); + it("accepts raw numbers", () => { expect(parsePullRequestReference("42")).toBe("42"); }); @@ -31,6 +49,20 @@ describe("parsePullRequestReference", () => { ).toBe("https://github.com/pingdotgg/t3code/pull/42"); }); + it("accepts glab mr checkout commands with raw numbers", () => { + expect(parsePullRequestReference("glab mr checkout 42")).toBe("42"); + }); + + it("accepts az repos pr checkout commands with raw numbers", () => { + expect(parsePullRequestReference("az repos pr checkout --id 42")).toBe("42"); + }); + + it("accepts az repos pr checkout commands with extra flags", () => { + expect(parsePullRequestReference("az repos pr checkout --id 42 --remote-name origin")).toBe( + "42", + ); + }); + it("rejects non-pull-request input", () => { expect(parsePullRequestReference("feature/my-branch")).toBeNull(); }); diff --git a/apps/web/src/pullRequestReference.ts b/apps/web/src/pullRequestReference.ts index b033db7d214d15bbbb0661719a487b90ad323087..93ae7caa0f7c2e9e861378360ac58043633974f0 100644 --- a/apps/web/src/pullRequestReference.ts +++ b/apps/web/src/pullRequestReference.ts @@ -1,7 +1,22 @@ const GITHUB_PULL_REQUEST_URL_PATTERN = /^https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/(\d+)(?:[/?#].*)?$/i; +const GITLAB_MERGE_REQUEST_URL_PATTERN = + /^https:\/\/[^/\s]*gitlab[^/\s]*\/.+\/-\/merge_requests\/(\d+)(?:[/?#].*)?$/i; +const AZURE_DEVOPS_PULL_REQUEST_URL_PATTERN = + /^https:\/\/(?:dev\.azure\.com\/[^/\s]+\/[^/\s]+|[^/\s]+\.visualstudio\.com\/[^/\s]+)\/_git\/[^/\s]+\/pullrequest\/(\d+)(?:[/?#].*)?$/i; const PULL_REQUEST_NUMBER_PATTERN = /^#?(\d+)$/; const GITHUB_CLI_PR_CHECKOUT_PATTERN = /^gh\s+pr\s+checkout\s+(.+)$/i; +const GITLAB_CLI_MR_CHECKOUT_PATTERN = /^glab\s+mr\s+checkout\s+(.+)$/i; +const AZURE_DEVOPS_CLI_PR_CHECKOUT_PATTERN = /^az\s+repos\s+pr\s+checkout\s+(.+)$/i; + +function parseAzureDevOpsCheckoutReference(args: string): string | null { + const parts = args.trim().split(/\s+/).filter(Boolean); + const idFlagIndex = parts.findIndex((part) => part === "--id" || part === "-i"); + if (idFlagIndex >= 0) { + return parts[idFlagIndex + 1] ?? null; + } + return parts.find((part) => !part.startsWith("-")) ?? null; +} export function parsePullRequestReference(input: string): string | null { const trimmed = input.trim(); @@ -10,12 +25,23 @@ export function parsePullRequestReference(input: string): string | null { } const ghCliCheckoutMatch = GITHUB_CLI_PR_CHECKOUT_PATTERN.exec(trimmed); - const normalizedInput = ghCliCheckoutMatch?.[1]?.trim() ?? trimmed; + const glabCliCheckoutMatch = GITLAB_CLI_MR_CHECKOUT_PATTERN.exec(trimmed); + const azureDevOpsCliCheckoutMatch = AZURE_DEVOPS_CLI_PR_CHECKOUT_PATTERN.exec(trimmed); + const normalizedInput = + ghCliCheckoutMatch?.[1]?.trim() ?? + glabCliCheckoutMatch?.[1]?.trim() ?? + (azureDevOpsCliCheckoutMatch?.[1] + ? parseAzureDevOpsCheckoutReference(azureDevOpsCliCheckoutMatch[1]) + : null) ?? + trimmed; if (normalizedInput.length === 0) { return null; } - const urlMatch = GITHUB_PULL_REQUEST_URL_PATTERN.exec(normalizedInput); + const urlMatch = + GITHUB_PULL_REQUEST_URL_PATTERN.exec(normalizedInput) ?? + GITLAB_MERGE_REQUEST_URL_PATTERN.exec(normalizedInput) ?? + AZURE_DEVOPS_PULL_REQUEST_URL_PATTERN.exec(normalizedInput); if (urlMatch?.[1]) { return normalizedInput; } diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 6494ecdb25f487bfa5d7fe6e494c67f8c6eee5b0..f5844f2c9411ee5432d9b8116d02f745ba584d62 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -13,6 +13,7 @@ import { Route as SettingsRouteImport } from './routes/settings' import { Route as PairRouteImport } from './routes/pair' import { Route as ChatRouteImport } from './routes/_chat' import { Route as ChatIndexRouteImport } from './routes/_chat.index' +import { Route as SettingsSourceControlRouteImport } from './routes/settings.source-control' import { Route as SettingsGeneralRouteImport } from './routes/settings.general' import { Route as SettingsConnectionsRouteImport } from './routes/settings.connections' import { Route as SettingsArchivedRouteImport } from './routes/settings.archived' @@ -38,6 +39,11 @@ const ChatIndexRoute = ChatIndexRouteImport.update({ path: '/', getParentRoute: () => ChatRoute, } as any) +const SettingsSourceControlRoute = SettingsSourceControlRouteImport.update({ + id: '/source-control', + path: '/source-control', + getParentRoute: () => SettingsRoute, +} as any) const SettingsGeneralRoute = SettingsGeneralRouteImport.update({ id: '/general', path: '/general', @@ -72,6 +78,7 @@ export interface FileRoutesByFullPath { '/settings/archived': typeof SettingsArchivedRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/general': typeof SettingsGeneralRoute + '/settings/source-control': typeof SettingsSourceControlRoute '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute '/draft/$draftId': typeof ChatDraftDraftIdRoute } @@ -81,6 +88,7 @@ export interface FileRoutesByTo { '/settings/archived': typeof SettingsArchivedRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/general': typeof SettingsGeneralRoute + '/settings/source-control': typeof SettingsSourceControlRoute '/': typeof ChatIndexRoute '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute '/draft/$draftId': typeof ChatDraftDraftIdRoute @@ -93,6 +101,7 @@ export interface FileRoutesById { '/settings/archived': typeof SettingsArchivedRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/general': typeof SettingsGeneralRoute + '/settings/source-control': typeof SettingsSourceControlRoute '/_chat/': typeof ChatIndexRoute '/_chat/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute '/_chat/draft/$draftId': typeof ChatDraftDraftIdRoute @@ -106,6 +115,7 @@ export interface FileRouteTypes { | '/settings/archived' | '/settings/connections' | '/settings/general' + | '/settings/source-control' | '/$environmentId/$threadId' | '/draft/$draftId' fileRoutesByTo: FileRoutesByTo @@ -115,6 +125,7 @@ export interface FileRouteTypes { | '/settings/archived' | '/settings/connections' | '/settings/general' + | '/settings/source-control' | '/' | '/$environmentId/$threadId' | '/draft/$draftId' @@ -126,6 +137,7 @@ export interface FileRouteTypes { | '/settings/archived' | '/settings/connections' | '/settings/general' + | '/settings/source-control' | '/_chat/' | '/_chat/$environmentId/$threadId' | '/_chat/draft/$draftId' @@ -167,6 +179,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ChatIndexRouteImport parentRoute: typeof ChatRoute } + '/settings/source-control': { + id: '/settings/source-control' + path: '/source-control' + fullPath: '/settings/source-control' + preLoaderRoute: typeof SettingsSourceControlRouteImport + parentRoute: typeof SettingsRoute + } '/settings/general': { id: '/settings/general' path: '/general' @@ -223,12 +242,14 @@ interface SettingsRouteChildren { SettingsArchivedRoute: typeof SettingsArchivedRoute SettingsConnectionsRoute: typeof SettingsConnectionsRoute SettingsGeneralRoute: typeof SettingsGeneralRoute + SettingsSourceControlRoute: typeof SettingsSourceControlRoute } const SettingsRouteChildren: SettingsRouteChildren = { SettingsArchivedRoute: SettingsArchivedRoute, SettingsConnectionsRoute: SettingsConnectionsRoute, SettingsGeneralRoute: SettingsGeneralRoute, + SettingsSourceControlRoute: SettingsSourceControlRoute, } const SettingsRouteWithChildren = SettingsRoute._addFileChildren( diff --git a/apps/web/src/routes/settings.source-control.tsx b/apps/web/src/routes/settings.source-control.tsx new file mode 100644 index 0000000000000000000000000000000000000000..bb09b3fd33ceb99acdab905517f274aa59573096 --- /dev/null +++ b/apps/web/src/routes/settings.source-control.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { SourceControlSettingsPanel } from "../components/settings/SourceControlSettings"; + +export const Route = createFileRoute("/settings/source-control")({ + component: SourceControlSettingsPanel, +}); diff --git a/apps/web/src/rpc/wsRpcClient.test.ts b/apps/web/src/rpc/wsRpcClient.test.ts index 56f39b1bd324230466c6c647ebd96fd3c2bcbc84..f8fb4cecb194d5ea59487b6ada7ca7f06997e30d 100644 --- a/apps/web/src/rpc/wsRpcClient.test.ts +++ b/apps/web/src/rpc/wsRpcClient.test.ts @@ -1,7 +1,7 @@ import type { - GitStatusLocalResult, - GitStatusRemoteResult, - GitStatusStreamEvent, + VcsStatusLocalResult, + VcsStatusRemoteResult, + VcsStatusStreamEvent, } from "@t3tools/contracts"; import { describe, expect, it, vi } from "vitest"; @@ -18,16 +18,16 @@ vi.mock("./wsTransport", () => ({ import { createWsRpcClient } from "./wsRpcClient"; import { type WsTransport } from "./wsTransport"; -const baseLocalStatus: GitStatusLocalResult = { +const baseLocalStatus: VcsStatusLocalResult = { isRepo: true, - hasOriginRemote: true, - isDefaultBranch: false, - branch: "feature/demo", + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "feature/demo", hasWorkingTreeChanges: false, workingTree: { files: [], insertions: 0, deletions: 0 }, }; -const baseRemoteStatus: GitStatusRemoteResult = { +const baseRemoteStatus: VcsStatusRemoteResult = { hasUpstream: true, aheadCount: 0, behindCount: 0, @@ -35,7 +35,7 @@ const baseRemoteStatus: GitStatusRemoteResult = { }; describe("wsRpcClient", () => { - it("reduces git status stream events into flat status snapshots", () => { + it("reduces vcs status stream events into flat status snapshots", () => { const subscribe = vi.fn((_connect: unknown, listener: (value: TValue) => void) => { for (const event of [ { @@ -54,7 +54,7 @@ describe("wsRpcClient", () => { hasWorkingTreeChanges: true, }, }, - ] satisfies GitStatusStreamEvent[]) { + ] satisfies VcsStatusStreamEvent[]) { listener(event as TValue); } return () => undefined; @@ -74,7 +74,7 @@ describe("wsRpcClient", () => { const client = createWsRpcClient(transport as unknown as WsTransport); const listener = vi.fn(); - client.git.onStatus({ cwd: "/repo" }, listener); + client.vcs.onStatus({ cwd: "/repo" }, listener); expect(listener.mock.calls).toEqual([ [ diff --git a/apps/web/src/rpc/wsRpcClient.ts b/apps/web/src/rpc/wsRpcClient.ts index f1183c1e9c5c80b7a4f2903fd187b878df021ce8..39abcb2c09cd5f439d1a269e8a5dbec65d2a110e 100644 --- a/apps/web/src/rpc/wsRpcClient.ts +++ b/apps/web/src/rpc/wsRpcClient.ts @@ -2,8 +2,8 @@ import { type GitActionProgressEvent, type GitRunStackedActionInput, type GitRunStackedActionResult, - type GitStatusResult, - type GitStatusStreamEvent, + type VcsStatusResult, + type VcsStatusStreamEvent, type LocalApi, ORCHESTRATION_WS_METHODS, type ServerSettingsPatch, @@ -77,24 +77,29 @@ export interface WsRpcClient { readonly editor: Parameters[1]; }) => ReturnType; }; - readonly git: { - readonly pull: RpcUnaryMethod; - readonly refreshStatus: RpcUnaryMethod; + readonly vcs: { + readonly pull: RpcUnaryMethod; + readonly refreshStatus: RpcUnaryMethod; readonly onStatus: ( - input: RpcInput, - listener: (status: GitStatusResult) => void, + input: RpcInput, + listener: (status: VcsStatusResult) => void, options?: StreamSubscriptionOptions, ) => () => void; + readonly listRefs: RpcUnaryMethod; + readonly createWorktree: RpcUnaryMethod; + readonly removeWorktree: RpcUnaryMethod; + readonly createRef: RpcUnaryMethod; + readonly switchRef: RpcUnaryMethod; + readonly init: RpcUnaryMethod; + }; + /** + * Git-specific workflows. Local repository mechanics live under `vcs`. + */ + readonly git: { readonly runStackedAction: ( input: GitRunStackedActionInput, options?: GitRunStackedActionOptions, ) => Promise; - readonly listBranches: RpcUnaryMethod; - readonly createWorktree: RpcUnaryMethod; - readonly removeWorktree: RpcUnaryMethod; - readonly createBranch: RpcUnaryMethod; - readonly checkout: RpcUnaryMethod; - readonly init: RpcUnaryMethod; readonly resolvePullRequest: RpcUnaryMethod; readonly preparePullRequestThread: RpcUnaryMethod< typeof WS_METHODS.gitPreparePullRequestThread @@ -114,6 +119,9 @@ export interface WsRpcClient { readonly updateSettings: ( patch: ServerSettingsPatch, ) => ReturnType>; + readonly discoverSourceControl: RpcUnaryNoArgMethod< + typeof WS_METHODS.serverDiscoverSourceControl + >; readonly subscribeConfig: RpcStreamMethod; readonly subscribeLifecycle: RpcStreamMethod; readonly subscribeAuthAccess: RpcStreamMethod; @@ -161,21 +169,31 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient { openInEditor: (input) => transport.request((client) => client[WS_METHODS.shellOpenInEditor](input)), }, - git: { - pull: (input) => transport.request((client) => client[WS_METHODS.gitPull](input)), + vcs: { + pull: (input) => transport.request((client) => client[WS_METHODS.vcsPull](input)), refreshStatus: (input) => - transport.request((client) => client[WS_METHODS.gitRefreshStatus](input)), + transport.request((client) => client[WS_METHODS.vcsRefreshStatus](input)), onStatus: (input, listener, options) => { - let current: GitStatusResult | null = null; + let current: VcsStatusResult | null = null; return transport.subscribe( - (client) => client[WS_METHODS.subscribeGitStatus](input), - (event: GitStatusStreamEvent) => { + (client) => client[WS_METHODS.subscribeVcsStatus](input), + (event: VcsStatusStreamEvent) => { current = applyGitStatusStreamEvent(current, event); listener(current); }, options, ); }, + listRefs: (input) => transport.request((client) => client[WS_METHODS.vcsListRefs](input)), + createWorktree: (input) => + transport.request((client) => client[WS_METHODS.vcsCreateWorktree](input)), + removeWorktree: (input) => + transport.request((client) => client[WS_METHODS.vcsRemoveWorktree](input)), + createRef: (input) => transport.request((client) => client[WS_METHODS.vcsCreateRef](input)), + switchRef: (input) => transport.request((client) => client[WS_METHODS.vcsSwitchRef](input)), + init: (input) => transport.request((client) => client[WS_METHODS.vcsInit](input)), + }, + git: { runStackedAction: async (input, options) => { let result: GitRunStackedActionResult | null = null; @@ -195,16 +213,6 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient { throw new Error("Git action stream completed without a final result."); }, - listBranches: (input) => - transport.request((client) => client[WS_METHODS.gitListBranches](input)), - createWorktree: (input) => - transport.request((client) => client[WS_METHODS.gitCreateWorktree](input)), - removeWorktree: (input) => - transport.request((client) => client[WS_METHODS.gitRemoveWorktree](input)), - createBranch: (input) => - transport.request((client) => client[WS_METHODS.gitCreateBranch](input)), - checkout: (input) => transport.request((client) => client[WS_METHODS.gitCheckout](input)), - init: (input) => transport.request((client) => client[WS_METHODS.gitInit](input)), resolvePullRequest: (input) => transport.request((client) => client[WS_METHODS.gitResolvePullRequest](input)), preparePullRequestThread: (input) => @@ -219,6 +227,8 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient { getSettings: () => transport.request((client) => client[WS_METHODS.serverGetSettings]({})), updateSettings: (patch) => transport.request((client) => client[WS_METHODS.serverUpdateSettings]({ patch })), + discoverSourceControl: () => + transport.request((client) => client[WS_METHODS.serverDiscoverSourceControl]({})), subscribeConfig: (listener, options) => transport.subscribe( (client) => client[WS_METHODS.subscribeServerConfig]({}), diff --git a/apps/web/src/sourceControlPresentation.ts b/apps/web/src/sourceControlPresentation.ts new file mode 100644 index 0000000000000000000000000000000000000000..68271228ed6562b5d401defe44d4aa2816d149d3 --- /dev/null +++ b/apps/web/src/sourceControlPresentation.ts @@ -0,0 +1,62 @@ +import { GitPullRequestIcon } from "lucide-react"; +import type { ElementType } from "react"; +import type { SourceControlProviderInfo } from "@t3tools/contracts"; +export { + DEFAULT_CHANGE_REQUEST_TERMINOLOGY, + formatChangeRequestAction, + formatCreateChangeRequestPhrase, + getChangeRequestTerminology, + resolveChangeRequestPresentation, + type ChangeRequestPresentation, + type ChangeRequestTerminology, +} from "@t3tools/shared/sourceControl"; +import { + getChangeRequestTerminology, + resolveChangeRequestPresentation, + type ChangeRequestTerminology, +} from "@t3tools/shared/sourceControl"; +import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./components/Icons"; + +export interface SourceControlPresentation { + readonly providerName: string; + readonly terminology: ChangeRequestTerminology; + readonly Icon: ElementType<{ className?: string }>; +} + +export function getSourceControlPresentation( + provider: SourceControlProviderInfo | null | undefined, +): SourceControlPresentation { + const presentation = resolveChangeRequestPresentation(provider); + switch (presentation.icon) { + case "github": + return { + providerName: provider?.name || presentation.providerName, + terminology: getChangeRequestTerminology(provider), + Icon: GitHubIcon, + }; + case "gitlab": + return { + providerName: provider?.name || presentation.providerName, + terminology: getChangeRequestTerminology(provider), + Icon: GitLabIcon, + }; + case "azure-devops": + return { + providerName: provider?.name || presentation.providerName, + terminology: getChangeRequestTerminology(provider), + Icon: AzureDevOpsIcon, + }; + case "bitbucket": + return { + providerName: provider?.name || presentation.providerName, + terminology: getChangeRequestTerminology(provider), + Icon: BitbucketIcon, + }; + case "change-request": + return { + providerName: provider?.name || presentation.providerName, + terminology: getChangeRequestTerminology(provider), + Icon: GitPullRequestIcon, + }; + } +} diff --git a/apps/web/test/wsRpcHarness.ts b/apps/web/test/wsRpcHarness.ts index 56711f4ad4bd3ad33038e6027bcb0274ee7216a7..07d4c74050caa865bb8126dd02314291a382182f 100644 --- a/apps/web/test/wsRpcHarness.ts +++ b/apps/web/test/wsRpcHarness.ts @@ -26,7 +26,7 @@ const STREAM_METHODS = new Set([ ORCHESTRATION_WS_METHODS.subscribeShell, ORCHESTRATION_WS_METHODS.subscribeThread, WS_METHODS.gitRunStackedAction, - WS_METHODS.subscribeGitStatus, + WS_METHODS.subscribeVcsStatus, WS_METHODS.subscribeTerminalEvents, WS_METHODS.subscribeServerConfig, WS_METHODS.subscribeServerLifecycle, diff --git a/bun.lock b/bun.lock index 79be837791e30a812d31674d3aa58bb66427675d..c7aeb59b76f9c466725d30fa2ba7c3d60c8569fe 100644 --- a/bun.lock +++ b/bun.lock @@ -131,6 +131,7 @@ "version": "0.0.0-alpha.1", "dependencies": { "@t3tools/contracts": "workspace:*", + "effect": "catalog:", }, "devDependencies": { "@effect/language-service": "catalog:", diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index bfe2d7828eb34cc2594906f3f250e3d8942565d1..fbe97d0dd693c4404140e40cc3affacfb7a93d60 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -15,7 +15,8 @@ "test": "vitest run" }, "dependencies": { - "@t3tools/contracts": "workspace:*" + "@t3tools/contracts": "workspace:*", + "effect": "catalog:" }, "devDependencies": { "@effect/language-service": "catalog:", diff --git a/packages/client-runtime/src/index.ts b/packages/client-runtime/src/index.ts index 9ca76328a8ec88d8c2adba6ed4f39e75e59e2861..f6f5b82758cb2eccbb520dba07511e86dfa06dcc 100644 --- a/packages/client-runtime/src/index.ts +++ b/packages/client-runtime/src/index.ts @@ -1,2 +1,3 @@ export * from "./knownEnvironment.ts"; export * from "./scoped.ts"; +export * from "./sourceControlDiscoveryState.ts"; diff --git a/packages/client-runtime/src/sourceControlDiscoveryState.test.ts b/packages/client-runtime/src/sourceControlDiscoveryState.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..9a988be00a5483277ecf897fa509ce02b5c51232 --- /dev/null +++ b/packages/client-runtime/src/sourceControlDiscoveryState.test.ts @@ -0,0 +1,107 @@ +import { assert, beforeEach, it } from "vitest"; +import type { SourceControlDiscoveryResult } from "@t3tools/contracts"; +import { AtomRegistry } from "effect/unstable/reactivity"; + +import { + EMPTY_SOURCE_CONTROL_DISCOVERY_STATE, + createSourceControlDiscoveryManager, +} from "./sourceControlDiscoveryState.ts"; + +const EMPTY_RESULT: SourceControlDiscoveryResult = { + versionControlSystems: [], + sourceControlProviders: [], +}; + +function unresolvedDiscovery() { + throw new Error("Discovery resolver was not initialized."); +} + +let registry = AtomRegistry.make(); + +beforeEach(() => { + registry.dispose(); + registry = AtomRegistry.make(); +}); + +it("stores refreshed discovery data in an atom snapshot", async () => { + const manager = createSourceControlDiscoveryManager({ + getRegistry: () => registry, + getClient: () => ({ + discoverSourceControl: async () => EMPTY_RESULT, + }), + }); + + assert.deepStrictEqual(manager.getSnapshot({ key: null }), EMPTY_SOURCE_CONTROL_DISCOVERY_STATE); + + const result = await manager.refresh({ key: "primary" }); + + assert.strictEqual(result, EMPTY_RESULT); + assert.deepStrictEqual(manager.getSnapshot({ key: "primary" }), { + data: EMPTY_RESULT, + error: null, + isPending: false, + }); +}); + +it("deduplicates in-flight discovery refreshes by target key", async () => { + let resolveDiscovery: (result: SourceControlDiscoveryResult) => void = unresolvedDiscovery; + let calls = 0; + const manager = createSourceControlDiscoveryManager({ + getRegistry: () => registry, + getClient: () => ({ + discoverSourceControl: () => { + calls += 1; + return new Promise((resolve) => { + resolveDiscovery = resolve; + }); + }, + }), + }); + + const first = manager.refresh({ key: "primary" }); + const second = manager.refresh({ key: "primary" }); + + assert.strictEqual(first, second); + assert.strictEqual(calls, 1); + assert.deepStrictEqual(manager.getSnapshot({ key: "primary" }), { + data: null, + error: null, + isPending: true, + }); + + resolveDiscovery(EMPTY_RESULT); + await first; + + assert.deepStrictEqual(manager.getSnapshot({ key: "primary" }), { + data: EMPTY_RESULT, + error: null, + isPending: false, + }); +}); + +it("keeps the previous snapshot when refresh fails", async () => { + let shouldFail = false; + const manager = createSourceControlDiscoveryManager({ + getRegistry: () => registry, + getClient: () => ({ + discoverSourceControl: async () => { + if (shouldFail) { + throw new Error("probe failed"); + } + return EMPTY_RESULT; + }, + }), + }); + + await manager.refresh({ key: "primary" }); + shouldFail = true; + + const result = await manager.refresh({ key: "primary" }); + + assert.strictEqual(result, EMPTY_RESULT); + assert.deepStrictEqual(manager.getSnapshot({ key: "primary" }), { + data: EMPTY_RESULT, + error: "probe failed", + isPending: false, + }); +}); diff --git a/packages/client-runtime/src/sourceControlDiscoveryState.ts b/packages/client-runtime/src/sourceControlDiscoveryState.ts new file mode 100644 index 0000000000000000000000000000000000000000..b18d6103b807a1a3bc34ce1d31ee64c2a3f2070e --- /dev/null +++ b/packages/client-runtime/src/sourceControlDiscoveryState.ts @@ -0,0 +1,206 @@ +import type { SourceControlDiscoveryResult } from "@t3tools/contracts"; +import { Atom, type AtomRegistry } from "effect/unstable/reactivity"; + +/* --- Types ---------------------------------------------------------- */ + +export interface SourceControlDiscoveryState { + readonly data: SourceControlDiscoveryResult | null; + readonly error: string | null; + readonly isPending: boolean; +} + +export interface SourceControlDiscoveryTarget { + readonly key: string | null; +} + +export interface SourceControlDiscoveryClient { + readonly discoverSourceControl: () => Promise; +} + +/* --- Constants ------------------------------------------------------ */ + +export const EMPTY_SOURCE_CONTROL_DISCOVERY_STATE = Object.freeze({ + data: null, + error: null, + isPending: false, +}); + +const INITIAL_SOURCE_CONTROL_DISCOVERY_STATE = Object.freeze({ + data: null, + error: null, + isPending: true, +}); + +/* --- Atoms ---------------------------------------------------------- */ + +const knownSourceControlDiscoveryKeys = new Set(); + +export const sourceControlDiscoveryStateAtom = Atom.family((key: string) => { + knownSourceControlDiscoveryKeys.add(key); + return Atom.make(INITIAL_SOURCE_CONTROL_DISCOVERY_STATE).pipe( + Atom.keepAlive, + Atom.withLabel(`source-control-discovery:${key}`), + ); +}); + +export const EMPTY_SOURCE_CONTROL_DISCOVERY_ATOM = Atom.make( + EMPTY_SOURCE_CONTROL_DISCOVERY_STATE, +).pipe(Atom.keepAlive, Atom.withLabel("source-control-discovery:null")); + +/* --- Helpers -------------------------------------------------------- */ + +export function getSourceControlDiscoveryTargetKey( + target: SourceControlDiscoveryTarget, +): string | null { + const key = target.key?.trim(); + return key && key.length > 0 ? key : null; +} + +/* --- Refresh manager ------------------------------------------------ */ + +export interface SourceControlDiscoveryManagerConfig { + /** + * Get the atom registry used to read/write source-control discovery snapshots. + */ + readonly getRegistry: () => AtomRegistry.AtomRegistry; + /** + * Resolve the runtime client for a discovery target key. + * + * Web currently uses a single `"primary"` target, but keeping this keyed + * lets mobile or future multi-environment clients provide separate discovery + * clients without changing the state primitive. + */ + readonly getClient: (key: string) => SourceControlDiscoveryClient | null; +} + +export function createSourceControlDiscoveryManager(config: SourceControlDiscoveryManagerConfig) { + const refreshInFlight = new Map>(); + + /* -- Atom helpers -------------------------------------------------- */ + + function setState(targetKey: string, nextState: SourceControlDiscoveryState): void { + config.getRegistry().set(sourceControlDiscoveryStateAtom(targetKey), nextState); + } + + function markPending(targetKey: string): void { + const current = config.getRegistry().get(sourceControlDiscoveryStateAtom(targetKey)); + const next: SourceControlDiscoveryState = + current.data === null + ? INITIAL_SOURCE_CONTROL_DISCOVERY_STATE + : { + data: current.data, + error: null, + isPending: true, + }; + + if ( + current.data === next.data && + current.error === next.error && + current.isPending === next.isPending + ) { + return; + } + + setState(targetKey, next); + } + + function setData(targetKey: string, data: SourceControlDiscoveryResult): void { + setState(targetKey, { + data, + error: null, + isPending: false, + }); + } + + function setError(targetKey: string, error: unknown): void { + const current = config.getRegistry().get(sourceControlDiscoveryStateAtom(targetKey)); + setState(targetKey, { + data: current.data, + error: error instanceof Error ? error.message : "Failed to discover source control tools.", + isPending: false, + }); + } + + /* -- Public API ---------------------------------------------------- */ + + /** + * Trigger a one-shot source-control discovery RPC for a target. + * + * Calls are deduplicated while a refresh for the same target key is in + * flight. On failure, the previous successful snapshot is kept in `data` + * and the error message is stored separately so UI can keep rendering stale + * discovery results while showing the failure. + * + * @param target The logical runtime target to refresh. + * @param client Optional pre-resolved client, useful in tests. + */ + function refresh( + target: SourceControlDiscoveryTarget, + client?: SourceControlDiscoveryClient, + ): Promise { + const targetKey = getSourceControlDiscoveryTargetKey(target); + if (targetKey === null) { + return Promise.resolve(null); + } + + const existing = refreshInFlight.get(targetKey); + if (existing) { + return existing; + } + + const resolvedClient = client ?? config.getClient(targetKey); + if (!resolvedClient) { + const error = new Error("Source control discovery client is unavailable."); + setError(targetKey, error); + return Promise.resolve(getSnapshot(target).data); + } + + markPending(targetKey); + const promise = resolvedClient.discoverSourceControl().then( + (result) => { + setData(targetKey, result); + return result; + }, + (error: unknown) => { + setError(targetKey, error); + return getSnapshot(target).data; + }, + ); + const tracked = promise.finally(() => refreshInFlight.delete(targetKey)); + refreshInFlight.set(targetKey, tracked); + return tracked; + } + + /** + * Read the current atom snapshot for `target`. + * + * Invalid targets return the inert empty state rather than creating a new + * family atom entry. + */ + function getSnapshot(target: SourceControlDiscoveryTarget): SourceControlDiscoveryState { + const targetKey = getSourceControlDiscoveryTargetKey(target); + if (targetKey === null) { + return EMPTY_SOURCE_CONTROL_DISCOVERY_STATE; + } + + return config.getRegistry().get(sourceControlDiscoveryStateAtom(targetKey)); + } + + /** + * Clear in-flight refresh tracking and reset every known discovery atom. + * Primarily used by tests and runtime teardown. + */ + function reset(): void { + refreshInFlight.clear(); + for (const key of knownSourceControlDiscoveryKeys) { + setState(key, INITIAL_SOURCE_CONTROL_DISCOVERY_STATE); + } + knownSourceControlDiscoveryKeys.clear(); + } + + return { + refresh, + getSnapshot, + reset, + }; +} diff --git a/packages/contracts/src/git.test.ts b/packages/contracts/src/git.test.ts index ebd5324fb9f5a124b1e90857cc8933a1ea90ac74..b4f7bc213ce974ecb68a6251f6aae4ec331aa567 100644 --- a/packages/contracts/src/git.test.ts +++ b/packages/contracts/src/git.test.ts @@ -2,14 +2,14 @@ import { describe, expect, it } from "vitest"; import { Schema } from "effect"; import { - GitCreateWorktreeInput, + VcsCreateWorktreeInput, GitPreparePullRequestThreadInput, GitRunStackedActionResult, GitRunStackedActionInput, GitResolvePullRequestResult, } from "./git.ts"; -const decodeCreateWorktreeInput = Schema.decodeUnknownSync(GitCreateWorktreeInput); +const decodeCreateWorktreeInput = Schema.decodeUnknownSync(VcsCreateWorktreeInput); const decodePreparePullRequestThreadInput = Schema.decodeUnknownSync( GitPreparePullRequestThreadInput, ); @@ -17,16 +17,16 @@ const decodeRunStackedActionInput = Schema.decodeUnknownSync(GitRunStackedAction const decodeRunStackedActionResult = Schema.decodeUnknownSync(GitRunStackedActionResult); const decodeResolvePullRequestResult = Schema.decodeUnknownSync(GitResolvePullRequestResult); -describe("GitCreateWorktreeInput", () => { - it("accepts omitted newBranch for existing-branch worktrees", () => { +describe("VcsCreateWorktreeInput", () => { + it("accepts omitted newRefName for existing-refName worktrees", () => { const parsed = decodeCreateWorktreeInput({ cwd: "/repo", - branch: "feature/existing", + refName: "feature/existing", path: "/tmp/worktree", }); - expect(parsed.newBranch).toBeUndefined(); - expect(parsed.branch).toBe("feature/existing"); + expect(parsed.newRefName).toBeUndefined(); + expect(parsed.refName).toBe("feature/existing"); }); }); diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 47d74dc35678edc204cf5d2ec9542a314d5639ef..dd12e749da44156c100ef64d4352e8c82e835098 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -1,5 +1,7 @@ import { Schema } from "effect"; import { NonNegativeInt, PositiveInt, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { SourceControlProviderError, SourceControlProviderInfo } from "./sourceControl.ts"; +import { VcsDriverKind } from "./vcs.ts"; const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; const GIT_LIST_BRANCHES_MAX_LIMIT = 200; @@ -40,18 +42,10 @@ const GitPushStepStatus = Schema.Literals([ ]); const GitBranchStepStatus = Schema.Literals(["created", "skipped_not_requested"]); const GitPrStepStatus = Schema.Literals(["created", "opened_existing", "skipped_not_requested"]); -const GitStatusPrState = Schema.Literals(["open", "closed", "merged"]); +const VcsStatusChangeRequestState = Schema.Literals(["open", "closed", "merged"]); const GitPullRequestReference = TrimmedNonEmptyStringSchema; const GitPullRequestState = Schema.Literals(["open", "closed", "merged"]); const GitPreparePullRequestThreadMode = Schema.Literals(["local", "worktree"]); -export const GitHostingProviderKind = Schema.Literals(["github", "gitlab", "unknown"]); -export type GitHostingProviderKind = typeof GitHostingProviderKind.Type; -export const GitHostingProvider = Schema.Struct({ - kind: GitHostingProviderKind, - name: TrimmedNonEmptyStringSchema, - baseUrl: Schema.String, -}); -export type GitHostingProvider = typeof GitHostingProvider.Type; export const GitRunStackedActionToastRunAction = Schema.Struct({ kind: GitStackedAction, }); @@ -79,7 +73,7 @@ const GitRunStackedActionToast = Schema.Struct({ }); export type GitRunStackedActionToast = typeof GitRunStackedActionToast.Type; -export const GitBranch = Schema.Struct({ +export const VcsRef = Schema.Struct({ name: TrimmedNonEmptyStringSchema, isRemote: Schema.optional(Schema.Boolean), remoteName: Schema.optional(TrimmedNonEmptyStringSchema), @@ -87,11 +81,11 @@ export const GitBranch = Schema.Struct({ isDefault: Schema.Boolean, worktreePath: TrimmedNonEmptyStringSchema.pipe(Schema.NullOr), }); -export type GitBranch = typeof GitBranch.Type; +export type VcsRef = typeof VcsRef.Type; -const GitWorktree = Schema.Struct({ +const VcsWorktree = Schema.Struct({ path: TrimmedNonEmptyStringSchema, - branch: TrimmedNonEmptyStringSchema, + refName: TrimmedNonEmptyStringSchema, }); const GitResolvedPullRequest = Schema.Struct({ number: PositiveInt, @@ -105,15 +99,15 @@ export type GitResolvedPullRequest = typeof GitResolvedPullRequest.Type; // RPC Inputs -export const GitStatusInput = Schema.Struct({ +export const VcsStatusInput = Schema.Struct({ cwd: TrimmedNonEmptyStringSchema, }); -export type GitStatusInput = typeof GitStatusInput.Type; +export type VcsStatusInput = typeof VcsStatusInput.Type; -export const GitPullInput = Schema.Struct({ +export const VcsPullInput = Schema.Struct({ cwd: TrimmedNonEmptyStringSchema, }); -export type GitPullInput = typeof GitPullInput.Type; +export type VcsPullInput = typeof VcsPullInput.Type; export const GitRunStackedActionInput = Schema.Struct({ actionId: TrimmedNonEmptyStringSchema, @@ -127,7 +121,7 @@ export const GitRunStackedActionInput = Schema.Struct({ }); export type GitRunStackedActionInput = typeof GitRunStackedActionInput.Type; -export const GitListBranchesInput = Schema.Struct({ +export const VcsListRefsInput = Schema.Struct({ cwd: TrimmedNonEmptyStringSchema, query: Schema.optional(TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(256))), cursor: Schema.optional(NonNegativeInt), @@ -135,15 +129,15 @@ export const GitListBranchesInput = Schema.Struct({ PositiveInt.check(Schema.isLessThanOrEqualTo(GIT_LIST_BRANCHES_MAX_LIMIT)), ), }); -export type GitListBranchesInput = typeof GitListBranchesInput.Type; +export type VcsListRefsInput = typeof VcsListRefsInput.Type; -export const GitCreateWorktreeInput = Schema.Struct({ +export const VcsCreateWorktreeInput = Schema.Struct({ cwd: TrimmedNonEmptyStringSchema, - branch: TrimmedNonEmptyStringSchema, - newBranch: Schema.optional(TrimmedNonEmptyStringSchema), + refName: TrimmedNonEmptyStringSchema, + newRefName: Schema.optional(TrimmedNonEmptyStringSchema), path: Schema.NullOr(TrimmedNonEmptyStringSchema), }); -export type GitCreateWorktreeInput = typeof GitCreateWorktreeInput.Type; +export type VcsCreateWorktreeInput = typeof VcsCreateWorktreeInput.Type; export const GitPullRequestRefInput = Schema.Struct({ cwd: TrimmedNonEmptyStringSchema, @@ -159,53 +153,54 @@ export const GitPreparePullRequestThreadInput = Schema.Struct({ }); export type GitPreparePullRequestThreadInput = typeof GitPreparePullRequestThreadInput.Type; -export const GitRemoveWorktreeInput = Schema.Struct({ +export const VcsRemoveWorktreeInput = Schema.Struct({ cwd: TrimmedNonEmptyStringSchema, path: TrimmedNonEmptyStringSchema, force: Schema.optional(Schema.Boolean), }); -export type GitRemoveWorktreeInput = typeof GitRemoveWorktreeInput.Type; +export type VcsRemoveWorktreeInput = typeof VcsRemoveWorktreeInput.Type; -export const GitCreateBranchInput = Schema.Struct({ +export const VcsCreateRefInput = Schema.Struct({ cwd: TrimmedNonEmptyStringSchema, - branch: TrimmedNonEmptyStringSchema, - checkout: Schema.optional(Schema.Boolean), + refName: TrimmedNonEmptyStringSchema, + switchRef: Schema.optional(Schema.Boolean), }); -export type GitCreateBranchInput = typeof GitCreateBranchInput.Type; +export type VcsCreateRefInput = typeof VcsCreateRefInput.Type; -export const GitCreateBranchResult = Schema.Struct({ - branch: TrimmedNonEmptyStringSchema, +export const VcsCreateRefResult = Schema.Struct({ + refName: TrimmedNonEmptyStringSchema, }); -export type GitCreateBranchResult = typeof GitCreateBranchResult.Type; +export type VcsCreateRefResult = typeof VcsCreateRefResult.Type; -export const GitCheckoutInput = Schema.Struct({ +export const VcsSwitchRefInput = Schema.Struct({ cwd: TrimmedNonEmptyStringSchema, - branch: TrimmedNonEmptyStringSchema, + refName: TrimmedNonEmptyStringSchema, }); -export type GitCheckoutInput = typeof GitCheckoutInput.Type; +export type VcsSwitchRefInput = typeof VcsSwitchRefInput.Type; -export const GitInitInput = Schema.Struct({ +export const VcsInitInput = Schema.Struct({ cwd: TrimmedNonEmptyStringSchema, + kind: Schema.optional(VcsDriverKind), }); -export type GitInitInput = typeof GitInitInput.Type; +export type VcsInitInput = typeof VcsInitInput.Type; // RPC Results -const GitStatusPr = Schema.Struct({ +const VcsStatusChangeRequest = Schema.Struct({ number: PositiveInt, title: TrimmedNonEmptyStringSchema, url: Schema.String, - baseBranch: TrimmedNonEmptyStringSchema, - headBranch: TrimmedNonEmptyStringSchema, - state: GitStatusPrState, + baseRef: TrimmedNonEmptyStringSchema, + headRef: TrimmedNonEmptyStringSchema, + state: VcsStatusChangeRequestState, }); -const GitStatusLocalShape = { +const VcsStatusLocalShape = { isRepo: Schema.Boolean, - hostingProvider: Schema.optional(GitHostingProvider), - hasOriginRemote: Schema.Boolean, - isDefaultBranch: Schema.Boolean, - branch: Schema.NullOr(TrimmedNonEmptyStringSchema), + sourceControlProvider: Schema.optional(SourceControlProviderInfo), + hasPrimaryRemote: Schema.Boolean, + isDefaultRef: Schema.Boolean, + refName: Schema.NullOr(TrimmedNonEmptyStringSchema), hasWorkingTreeChanges: Schema.Boolean, workingTree: Schema.Struct({ files: Schema.Array( @@ -220,52 +215,52 @@ const GitStatusLocalShape = { }), }; -const GitStatusRemoteShape = { +const VcsStatusRemoteShape = { hasUpstream: Schema.Boolean, aheadCount: NonNegativeInt, behindCount: NonNegativeInt, - pr: Schema.NullOr(GitStatusPr), + pr: Schema.NullOr(VcsStatusChangeRequest), }; -export const GitStatusLocalResult = Schema.Struct(GitStatusLocalShape); -export type GitStatusLocalResult = typeof GitStatusLocalResult.Type; +export const VcsStatusLocalResult = Schema.Struct(VcsStatusLocalShape); +export type VcsStatusLocalResult = typeof VcsStatusLocalResult.Type; -export const GitStatusRemoteResult = Schema.Struct(GitStatusRemoteShape); -export type GitStatusRemoteResult = typeof GitStatusRemoteResult.Type; +export const VcsStatusRemoteResult = Schema.Struct(VcsStatusRemoteShape); +export type VcsStatusRemoteResult = typeof VcsStatusRemoteResult.Type; -export const GitStatusResult = Schema.Struct({ - ...GitStatusLocalShape, - ...GitStatusRemoteShape, +export const VcsStatusResult = Schema.Struct({ + ...VcsStatusLocalShape, + ...VcsStatusRemoteShape, }); -export type GitStatusResult = typeof GitStatusResult.Type; +export type VcsStatusResult = typeof VcsStatusResult.Type; -export const GitStatusStreamEvent = Schema.Union([ +export const VcsStatusStreamEvent = Schema.Union([ Schema.TaggedStruct("snapshot", { - local: GitStatusLocalResult, - remote: Schema.NullOr(GitStatusRemoteResult), + local: VcsStatusLocalResult, + remote: Schema.NullOr(VcsStatusRemoteResult), }), Schema.TaggedStruct("localUpdated", { - local: GitStatusLocalResult, + local: VcsStatusLocalResult, }), Schema.TaggedStruct("remoteUpdated", { - remote: Schema.NullOr(GitStatusRemoteResult), + remote: Schema.NullOr(VcsStatusRemoteResult), }), ]); -export type GitStatusStreamEvent = typeof GitStatusStreamEvent.Type; +export type VcsStatusStreamEvent = typeof VcsStatusStreamEvent.Type; -export const GitListBranchesResult = Schema.Struct({ - branches: Schema.Array(GitBranch), +export const VcsListRefsResult = Schema.Struct({ + refs: Schema.Array(VcsRef), isRepo: Schema.Boolean, - hasOriginRemote: Schema.Boolean, + hasPrimaryRemote: Schema.Boolean, nextCursor: NonNegativeInt.pipe(Schema.NullOr), totalCount: NonNegativeInt, }); -export type GitListBranchesResult = typeof GitListBranchesResult.Type; +export type VcsListRefsResult = typeof VcsListRefsResult.Type; -export const GitCreateWorktreeResult = Schema.Struct({ - worktree: GitWorktree, +export const VcsCreateWorktreeResult = Schema.Struct({ + worktree: VcsWorktree, }); -export type GitCreateWorktreeResult = typeof GitCreateWorktreeResult.Type; +export type VcsCreateWorktreeResult = typeof VcsCreateWorktreeResult.Type; export const GitResolvePullRequestResult = Schema.Struct({ pullRequest: GitResolvedPullRequest, @@ -279,10 +274,10 @@ export const GitPreparePullRequestThreadResult = Schema.Struct({ }); export type GitPreparePullRequestThreadResult = typeof GitPreparePullRequestThreadResult.Type; -export const GitCheckoutResult = Schema.Struct({ - branch: Schema.NullOr(TrimmedNonEmptyStringSchema), +export const VcsSwitchRefResult = Schema.Struct({ + refName: Schema.NullOr(TrimmedNonEmptyStringSchema), }); -export type GitCheckoutResult = typeof GitCheckoutResult.Type; +export type VcsSwitchRefResult = typeof VcsSwitchRefResult.Type; export const GitRunStackedActionResult = Schema.Struct({ action: GitStackedAction, @@ -313,12 +308,12 @@ export const GitRunStackedActionResult = Schema.Struct({ }); export type GitRunStackedActionResult = typeof GitRunStackedActionResult.Type; -export const GitPullResult = Schema.Struct({ +export const VcsPullResult = Schema.Struct({ status: Schema.Literals(["pulled", "skipped_up_to_date"]), - branch: TrimmedNonEmptyStringSchema, - upstreamBranch: TrimmedNonEmptyStringSchema.pipe(Schema.NullOr), + refName: TrimmedNonEmptyStringSchema, + upstreamRef: TrimmedNonEmptyStringSchema.pipe(Schema.NullOr), }); -export type GitPullResult = typeof GitPullResult.Type; +export type VcsPullResult = typeof VcsPullResult.Type; // RPC / domain errors export class GitCommandError extends Schema.TaggedErrorClass()("GitCommandError", { @@ -370,6 +365,7 @@ export const GitManagerServiceError = Schema.Union([ GitManagerError, GitCommandError, GitHubCliError, + SourceControlProviderError, TextGenerationError, ]); export type GitManagerServiceError = typeof GitManagerServiceError.Type; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index ad9057174052997deb6d3520205bb414b57f28bd..a0ccc624a5e160e785d2a75fcecb932f10ea4719 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -11,6 +11,8 @@ export * from "./keybindings.ts"; export * from "./server.ts"; export * from "./settings.ts"; export * from "./git.ts"; +export * from "./vcs.ts"; +export * from "./sourceControl.ts"; export * from "./orchestration.ts"; export * from "./editor.ts"; export * from "./project.ts"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index a63c6de9626810ceffdee47dbb4d30ed807e391a..35539a86e3353228d600b7b92a6d6b9119c44ec4 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1,22 +1,22 @@ import type { - GitCheckoutInput, - GitCheckoutResult, - GitCreateBranchInput, + VcsSwitchRefInput, + VcsSwitchRefResult, + VcsCreateRefInput, GitPreparePullRequestThreadInput, GitPreparePullRequestThreadResult, GitPullRequestRefInput, - GitCreateWorktreeInput, - GitCreateWorktreeResult, - GitInitInput, - GitListBranchesInput, - GitListBranchesResult, - GitPullInput, - GitPullResult, - GitRemoveWorktreeInput, + VcsCreateWorktreeInput, + VcsCreateWorktreeResult, + VcsInitInput, + VcsListRefsInput, + VcsListRefsResult, + VcsPullInput, + VcsPullResult, + VcsRemoveWorktreeInput, GitResolvePullRequestResult, - GitStatusInput, - GitStatusResult, - GitCreateBranchResult, + VcsStatusInput, + VcsStatusResult, + VcsCreateRefResult, } from "./git.ts"; import type { FilesystemBrowseInput, FilesystemBrowseResult } from "./filesystem.ts"; import type { @@ -55,6 +55,7 @@ import type { import type { EnvironmentId } from "./baseSchemas.ts"; import { EditorId } from "./editor.ts"; import { ServerSettings, type ClientSettings, type ServerSettingsPatch } from "./settings.ts"; +import type { SourceControlDiscoveryResult } from "./sourceControl.ts"; export interface ContextMenuItem { id: T; @@ -227,6 +228,7 @@ export interface LocalApi { upsertKeybinding: (input: ServerUpsertKeybindingInput) => Promise; getSettings: () => Promise; updateSettings: (patch: ServerSettingsPatch) => Promise; + discoverSourceControl: () => Promise; }; } @@ -235,7 +237,7 @@ export interface LocalApi { * * These operations must always be routed with explicit environment context. * They represent remote stateful capabilities such as orchestration, terminal, - * project, and git operations. In multi-environment mode, each environment gets + * project, VCS, and provider operations. In multi-environment mode, each environment gets * its own instance of this surface, and callers should resolve it by * `environmentId` rather than reaching through the local desktop bridge. */ @@ -256,26 +258,28 @@ export interface EnvironmentApi { filesystem: { browse: (input: FilesystemBrowseInput) => Promise; }; + vcs: { + listRefs: (input: VcsListRefsInput) => Promise; + createWorktree: (input: VcsCreateWorktreeInput) => Promise; + removeWorktree: (input: VcsRemoveWorktreeInput) => Promise; + createRef: (input: VcsCreateRefInput) => Promise; + switchRef: (input: VcsSwitchRefInput) => Promise; + init: (input: VcsInitInput) => Promise; + pull: (input: VcsPullInput) => Promise; + refreshStatus: (input: VcsStatusInput) => Promise; + onStatus: ( + input: VcsStatusInput, + callback: (status: VcsStatusResult) => void, + options?: { + onResubscribe?: () => void; + }, + ) => () => void; + }; git: { - listBranches: (input: GitListBranchesInput) => Promise; - createWorktree: (input: GitCreateWorktreeInput) => Promise; - removeWorktree: (input: GitRemoveWorktreeInput) => Promise; - createBranch: (input: GitCreateBranchInput) => Promise; - checkout: (input: GitCheckoutInput) => Promise; - init: (input: GitInitInput) => Promise; resolvePullRequest: (input: GitPullRequestRefInput) => Promise; preparePullRequestThread: ( input: GitPreparePullRequestThreadInput, ) => Promise; - pull: (input: GitPullInput) => Promise; - refreshStatus: (input: GitStatusInput) => Promise; - onStatus: ( - input: GitStatusInput, - callback: (status: GitStatusResult) => void, - options?: { - onResubscribe?: () => void; - }, - ) => () => void; }; orchestration: { dispatchCommand: (command: ClientOrchestrationCommand) => Promise<{ sequence: number }>; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index ca68d5cfe4ec483f8e0b518d5f13194d8cc079ea..f2da90e1907adebd265f9ebd1c9e49e2dc54c3cf 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -11,28 +11,28 @@ import { } from "./filesystem.ts"; import { GitActionProgressEvent, - GitCheckoutInput, - GitCheckoutResult, + VcsSwitchRefInput, + VcsSwitchRefResult, GitCommandError, - GitCreateBranchInput, - GitCreateBranchResult, - GitCreateWorktreeInput, - GitCreateWorktreeResult, - GitInitInput, - GitListBranchesInput, - GitListBranchesResult, + VcsCreateRefInput, + VcsCreateRefResult, + VcsCreateWorktreeInput, + VcsCreateWorktreeResult, + VcsInitInput, + VcsListRefsInput, + VcsListRefsResult, GitManagerServiceError, GitPreparePullRequestThreadInput, GitPreparePullRequestThreadResult, - GitPullInput, + VcsPullInput, GitPullRequestRefInput, - GitPullResult, - GitRemoveWorktreeInput, + VcsPullResult, + VcsRemoveWorktreeInput, GitResolvePullRequestResult, GitRunStackedActionInput, - GitStatusInput, - GitStatusResult, - GitStatusStreamEvent, + VcsStatusInput, + VcsStatusResult, + VcsStatusStreamEvent, } from "./git.ts"; import { KeybindingsConfigError } from "./keybindings.ts"; import { @@ -77,6 +77,8 @@ import { ServerUpsertKeybindingResult, } from "./server.ts"; import { ServerSettings, ServerSettingsError, ServerSettingsPatch } from "./settings.ts"; +import { SourceControlDiscoveryResult } from "./sourceControl.ts"; +import { VcsError } from "./vcs.ts"; export const WS_METHODS = { // Project registry methods @@ -92,16 +94,18 @@ export const WS_METHODS = { // Filesystem methods filesystemBrowse: "filesystem.browse", - // Git methods - gitPull: "git.pull", - gitRefreshStatus: "git.refreshStatus", + // VCS methods + vcsPull: "vcs.pull", + vcsRefreshStatus: "vcs.refreshStatus", + vcsListRefs: "vcs.listRefs", + vcsCreateWorktree: "vcs.createWorktree", + vcsRemoveWorktree: "vcs.removeWorktree", + vcsCreateRef: "vcs.createRef", + vcsSwitchRef: "vcs.switchRef", + vcsInit: "vcs.init", + + // Git workflow methods gitRunStackedAction: "git.runStackedAction", - gitListBranches: "git.listBranches", - gitCreateWorktree: "git.createWorktree", - gitRemoveWorktree: "git.removeWorktree", - gitCreateBranch: "git.createBranch", - gitCheckout: "git.checkout", - gitInit: "git.init", gitResolvePullRequest: "git.resolvePullRequest", gitPreparePullRequestThread: "git.preparePullRequestThread", @@ -119,9 +123,10 @@ export const WS_METHODS = { serverUpsertKeybinding: "server.upsertKeybinding", serverGetSettings: "server.getSettings", serverUpdateSettings: "server.updateSettings", + serverDiscoverSourceControl: "server.discoverSourceControl", // Streaming subscriptions - subscribeGitStatus: "subscribeGitStatus", + subscribeVcsStatus: "subscribeVcsStatus", subscribeTerminalEvents: "subscribeTerminalEvents", subscribeServerConfig: "subscribeServerConfig", subscribeServerLifecycle: "subscribeServerLifecycle", @@ -165,6 +170,11 @@ export const WsServerUpdateSettingsRpc = Rpc.make(WS_METHODS.serverUpdateSetting error: ServerSettingsError, }); +export const WsServerDiscoverSourceControlRpc = Rpc.make(WS_METHODS.serverDiscoverSourceControl, { + payload: Schema.Struct({}), + success: SourceControlDiscoveryResult, +}); + export const WsProjectsSearchEntriesRpc = Rpc.make(WS_METHODS.projectsSearchEntries, { payload: ProjectSearchEntriesInput, success: ProjectSearchEntriesResult, @@ -188,22 +198,22 @@ export const WsFilesystemBrowseRpc = Rpc.make(WS_METHODS.filesystemBrowse, { error: FilesystemBrowseError, }); -export const WsSubscribeGitStatusRpc = Rpc.make(WS_METHODS.subscribeGitStatus, { - payload: GitStatusInput, - success: GitStatusStreamEvent, +export const WsSubscribeVcsStatusRpc = Rpc.make(WS_METHODS.subscribeVcsStatus, { + payload: VcsStatusInput, + success: VcsStatusStreamEvent, error: GitManagerServiceError, stream: true, }); -export const WsGitPullRpc = Rpc.make(WS_METHODS.gitPull, { - payload: GitPullInput, - success: GitPullResult, +export const WsVcsPullRpc = Rpc.make(WS_METHODS.vcsPull, { + payload: VcsPullInput, + success: VcsPullResult, error: GitCommandError, }); -export const WsGitRefreshStatusRpc = Rpc.make(WS_METHODS.gitRefreshStatus, { - payload: GitStatusInput, - success: GitStatusResult, +export const WsVcsRefreshStatusRpc = Rpc.make(WS_METHODS.vcsRefreshStatus, { + payload: VcsStatusInput, + success: VcsStatusResult, error: GitManagerServiceError, }); @@ -226,38 +236,38 @@ export const WsGitPreparePullRequestThreadRpc = Rpc.make(WS_METHODS.gitPreparePu error: GitManagerServiceError, }); -export const WsGitListBranchesRpc = Rpc.make(WS_METHODS.gitListBranches, { - payload: GitListBranchesInput, - success: GitListBranchesResult, +export const WsVcsListRefsRpc = Rpc.make(WS_METHODS.vcsListRefs, { + payload: VcsListRefsInput, + success: VcsListRefsResult, error: GitCommandError, }); -export const WsGitCreateWorktreeRpc = Rpc.make(WS_METHODS.gitCreateWorktree, { - payload: GitCreateWorktreeInput, - success: GitCreateWorktreeResult, +export const WsVcsCreateWorktreeRpc = Rpc.make(WS_METHODS.vcsCreateWorktree, { + payload: VcsCreateWorktreeInput, + success: VcsCreateWorktreeResult, error: GitCommandError, }); -export const WsGitRemoveWorktreeRpc = Rpc.make(WS_METHODS.gitRemoveWorktree, { - payload: GitRemoveWorktreeInput, +export const WsVcsRemoveWorktreeRpc = Rpc.make(WS_METHODS.vcsRemoveWorktree, { + payload: VcsRemoveWorktreeInput, error: GitCommandError, }); -export const WsGitCreateBranchRpc = Rpc.make(WS_METHODS.gitCreateBranch, { - payload: GitCreateBranchInput, - success: GitCreateBranchResult, +export const WsVcsCreateRefRpc = Rpc.make(WS_METHODS.vcsCreateRef, { + payload: VcsCreateRefInput, + success: VcsCreateRefResult, error: GitCommandError, }); -export const WsGitCheckoutRpc = Rpc.make(WS_METHODS.gitCheckout, { - payload: GitCheckoutInput, - success: GitCheckoutResult, +export const WsVcsSwitchRefRpc = Rpc.make(WS_METHODS.vcsSwitchRef, { + payload: VcsSwitchRefInput, + success: VcsSwitchRefResult, error: GitCommandError, }); -export const WsGitInitRpc = Rpc.make(WS_METHODS.gitInit, { - payload: GitInitInput, - error: GitCommandError, +export const WsVcsInitRpc = Rpc.make(WS_METHODS.vcsInit, { + payload: VcsInitInput, + error: VcsError, }); export const WsTerminalOpenRpc = Rpc.make(WS_METHODS.terminalOpen, { @@ -370,22 +380,23 @@ export const WsRpcGroup = RpcGroup.make( WsServerUpsertKeybindingRpc, WsServerGetSettingsRpc, WsServerUpdateSettingsRpc, + WsServerDiscoverSourceControlRpc, WsProjectsSearchEntriesRpc, WsProjectsWriteFileRpc, WsShellOpenInEditorRpc, WsFilesystemBrowseRpc, - WsSubscribeGitStatusRpc, - WsGitPullRpc, - WsGitRefreshStatusRpc, + WsSubscribeVcsStatusRpc, + WsVcsPullRpc, + WsVcsRefreshStatusRpc, WsGitRunStackedActionRpc, WsGitResolvePullRequestRpc, WsGitPreparePullRequestThreadRpc, - WsGitListBranchesRpc, - WsGitCreateWorktreeRpc, - WsGitRemoveWorktreeRpc, - WsGitCreateBranchRpc, - WsGitCheckoutRpc, - WsGitInitRpc, + WsVcsListRefsRpc, + WsVcsCreateWorktreeRpc, + WsVcsRemoveWorktreeRpc, + WsVcsCreateRefRpc, + WsVcsSwitchRefRpc, + WsVcsInitRpc, WsTerminalOpenRpc, WsTerminalWriteRpc, WsTerminalResizeRpc, diff --git a/packages/contracts/src/sourceControl.ts b/packages/contracts/src/sourceControl.ts new file mode 100644 index 0000000000000000000000000000000000000000..5776e84ff7ce5d5758762eddfa94a473619bd2e7 --- /dev/null +++ b/packages/contracts/src/sourceControl.ts @@ -0,0 +1,111 @@ +import { Schema } from "effect"; +import { PositiveInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { VcsDriverKind } from "./vcs.ts"; + +export const SourceControlProviderKind = Schema.Literals([ + "github", + "gitlab", + "azure-devops", + "bitbucket", + "unknown", +]); +export type SourceControlProviderKind = typeof SourceControlProviderKind.Type; + +export const SourceControlProviderInfo = Schema.Struct({ + kind: SourceControlProviderKind, + name: TrimmedNonEmptyString, + baseUrl: Schema.String, +}); +export type SourceControlProviderInfo = typeof SourceControlProviderInfo.Type; + +export const ChangeRequestState = Schema.Literals(["open", "closed", "merged"]); +export type ChangeRequestState = typeof ChangeRequestState.Type; + +export const ChangeRequest = Schema.Struct({ + provider: SourceControlProviderKind, + number: PositiveInt, + title: TrimmedNonEmptyString, + url: Schema.String, + baseRefName: TrimmedNonEmptyString, + headRefName: TrimmedNonEmptyString, + state: ChangeRequestState, + updatedAt: Schema.Option(Schema.DateTimeUtc), + isCrossRepository: Schema.optional(Schema.Boolean), + headRepositoryNameWithOwner: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + headRepositoryOwnerLogin: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), +}); +export type ChangeRequest = typeof ChangeRequest.Type; + +export const SourceControlRepositoryCloneUrls = Schema.Struct({ + nameWithOwner: TrimmedNonEmptyString, + url: TrimmedNonEmptyString, + sshUrl: TrimmedNonEmptyString, +}); +export type SourceControlRepositoryCloneUrls = typeof SourceControlRepositoryCloneUrls.Type; + +export const SourceControlDiscoveryStatus = Schema.Literals(["available", "missing"]); +export type SourceControlDiscoveryStatus = typeof SourceControlDiscoveryStatus.Type; + +export const SourceControlProviderAuthStatus = Schema.Literals([ + "authenticated", + "unauthenticated", + "unknown", +]); +export type SourceControlProviderAuthStatus = typeof SourceControlProviderAuthStatus.Type; + +export const SourceControlProviderAuth = Schema.Struct({ + status: SourceControlProviderAuthStatus, + account: Schema.Option(TrimmedNonEmptyString), + host: Schema.Option(TrimmedNonEmptyString), + detail: Schema.Option(TrimmedNonEmptyString), +}); +export type SourceControlProviderAuth = typeof SourceControlProviderAuth.Type; + +const SourceControlDiscoveryItemFields = { + label: TrimmedNonEmptyString, + executable: TrimmedNonEmptyString, + implemented: Schema.Boolean, + status: SourceControlDiscoveryStatus, + version: Schema.Option(TrimmedNonEmptyString), + installHint: TrimmedNonEmptyString, + detail: Schema.Option(TrimmedNonEmptyString), +} as const; + +export const SourceControlDiscoveryItem = Schema.Struct({ + kind: Schema.String, + ...SourceControlDiscoveryItemFields, +}); +export type SourceControlDiscoveryItem = typeof SourceControlDiscoveryItem.Type; + +export const VcsDiscoveryItem = Schema.Struct({ + kind: VcsDriverKind, + ...SourceControlDiscoveryItemFields, +}); +export type VcsDiscoveryItem = typeof VcsDiscoveryItem.Type; + +export const SourceControlProviderDiscoveryItem = Schema.Struct({ + kind: SourceControlProviderKind, + ...SourceControlDiscoveryItemFields, + auth: SourceControlProviderAuth, +}); +export type SourceControlProviderDiscoveryItem = typeof SourceControlProviderDiscoveryItem.Type; + +export const SourceControlDiscoveryResult = Schema.Struct({ + versionControlSystems: Schema.Array(VcsDiscoveryItem), + sourceControlProviders: Schema.Array(SourceControlProviderDiscoveryItem), +}); +export type SourceControlDiscoveryResult = typeof SourceControlDiscoveryResult.Type; + +export class SourceControlProviderError extends Schema.TaggedErrorClass()( + "SourceControlProviderError", + { + provider: SourceControlProviderKind, + operation: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Source control provider ${this.provider} failed in ${this.operation}: ${this.detail}`; + } +} diff --git a/packages/contracts/src/vcs.ts b/packages/contracts/src/vcs.ts new file mode 100644 index 0000000000000000000000000000000000000000..2dd09cf04c681cbee7899932d098447f9cce5dd8 --- /dev/null +++ b/packages/contracts/src/vcs.ts @@ -0,0 +1,154 @@ +import { Schema } from "effect"; +import { TrimmedNonEmptyString } from "./baseSchemas.ts"; + +export const VcsDriverKind = Schema.Literals(["git", "jj", "unknown"]); +export type VcsDriverKind = typeof VcsDriverKind.Type; + +export const VcsFreshnessSource = Schema.Literals([ + "live-local", + "cached-local", + "cached-remote", + "explicit-remote", +]); +export type VcsFreshnessSource = typeof VcsFreshnessSource.Type; + +export const VcsFreshness = Schema.Struct({ + source: VcsFreshnessSource, + observedAt: Schema.DateTimeUtc, + expiresAt: Schema.Option(Schema.DateTimeUtc), +}); +export type VcsFreshness = typeof VcsFreshness.Type; + +export const VcsDriverCapabilities = Schema.Struct({ + kind: VcsDriverKind, + supportsWorktrees: Schema.Boolean, + supportsBookmarks: Schema.Boolean, + supportsAtomicSnapshot: Schema.Boolean, + supportsPushDefaultRemote: Schema.Boolean, + ignoreClassifier: Schema.Literals(["native", "git-compatible-fallback"]), +}); +export type VcsDriverCapabilities = typeof VcsDriverCapabilities.Type; + +export const VcsRepositoryIdentity = Schema.Struct({ + kind: VcsDriverKind, + rootPath: TrimmedNonEmptyString, + metadataPath: Schema.NullOr(TrimmedNonEmptyString), + freshness: VcsFreshness, +}); +export type VcsRepositoryIdentity = typeof VcsRepositoryIdentity.Type; + +export const VcsListWorkspaceFilesResult = Schema.Struct({ + paths: Schema.Array(TrimmedNonEmptyString), + truncated: Schema.Boolean, + freshness: VcsFreshness, +}); +export type VcsListWorkspaceFilesResult = typeof VcsListWorkspaceFilesResult.Type; + +export const VcsRemote = Schema.Struct({ + name: TrimmedNonEmptyString, + url: TrimmedNonEmptyString, + pushUrl: Schema.Option(TrimmedNonEmptyString), + isPrimary: Schema.Boolean, +}); +export type VcsRemote = typeof VcsRemote.Type; + +export const VcsListRemotesResult = Schema.Struct({ + remotes: Schema.Array(VcsRemote), + freshness: VcsFreshness, +}); +export type VcsListRemotesResult = typeof VcsListRemotesResult.Type; + +export class VcsProcessSpawnError extends Schema.TaggedErrorClass()( + "VcsProcessSpawnError", + { + operation: Schema.String, + command: Schema.String, + cwd: Schema.String, + cause: Schema.Defect, + }, +) { + override get message(): string { + return `VCS process failed to spawn in ${this.operation}: ${this.command} (${this.cwd})`; + } +} + +export class VcsProcessExitError extends Schema.TaggedErrorClass()( + "VcsProcessExitError", + { + operation: Schema.String, + command: Schema.String, + cwd: Schema.String, + exitCode: Schema.Number, + detail: Schema.String, + }, +) { + override get message(): string { + return `VCS process failed in ${this.operation}: ${this.command} (${this.cwd}) exited with ${this.exitCode} - ${this.detail}`; + } +} + +export class VcsProcessTimeoutError extends Schema.TaggedErrorClass()( + "VcsProcessTimeoutError", + { + operation: Schema.String, + command: Schema.String, + cwd: Schema.String, + timeoutMs: Schema.Number, + }, +) { + override get message(): string { + return `VCS process timed out in ${this.operation}: ${this.command} (${this.cwd}) after ${this.timeoutMs}ms`; + } +} + +export class VcsOutputDecodeError extends Schema.TaggedErrorClass()( + "VcsOutputDecodeError", + { + operation: Schema.String, + command: Schema.String, + cwd: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `VCS output decode failed in ${this.operation}: ${this.command} (${this.cwd}) - ${this.detail}`; + } +} + +export class VcsRepositoryDetectionError extends Schema.TaggedErrorClass()( + "VcsRepositoryDetectionError", + { + operation: Schema.String, + cwd: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `VCS repository detection failed in ${this.operation}: ${this.cwd} - ${this.detail}`; + } +} + +export class VcsUnsupportedOperationError extends Schema.TaggedErrorClass()( + "VcsUnsupportedOperationError", + { + operation: Schema.String, + kind: VcsDriverKind, + detail: Schema.String, + }, +) { + override get message(): string { + return `VCS operation is unsupported for ${this.kind} in ${this.operation}: ${this.detail}`; + } +} + +export const VcsError = Schema.Union([ + VcsProcessSpawnError, + VcsProcessExitError, + VcsProcessTimeoutError, + VcsOutputDecodeError, + VcsRepositoryDetectionError, + VcsUnsupportedOperationError, +]); +export type VcsError = typeof VcsError.Type; diff --git a/packages/shared/package.json b/packages/shared/package.json index 82085dfcaf37838b2099930a91d154785c6d13df..da0f24b77aa1adc0e6ec6c230abe3296e901b1c1 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -12,6 +12,10 @@ "types": "./src/git.ts", "import": "./src/git.ts" }, + "./sourceControl": { + "types": "./src/sourceControl.ts", + "import": "./src/sourceControl.ts" + }, "./logging": { "types": "./src/logging.ts", "import": "./src/logging.ts" diff --git a/packages/shared/src/git.test.ts b/packages/shared/src/git.test.ts index 2160c460dc515ffef4a326d662d20e4aca36aaa3..5eb9fd5124429a699cbb7d5641457d672f669c8f 100644 --- a/packages/shared/src/git.test.ts +++ b/packages/shared/src/git.test.ts @@ -1,4 +1,4 @@ -import type { GitStatusRemoteResult, GitStatusResult } from "@t3tools/contracts"; +import type { VcsStatusRemoteResult, VcsStatusResult } from "@t3tools/contracts"; import { describe, expect, it } from "vitest"; import { @@ -54,17 +54,17 @@ describe("parseGitHubRepositoryNameWithOwnerFromRemoteUrl", () => { }); describe("isTemporaryWorktreeBranch", () => { - it("matches the generated temporary worktree branch format", () => { + it("matches the generated temporary worktree refName format", () => { expect(isTemporaryWorktreeBranch(buildTemporaryWorktreeBranchName())).toBe(true); }); - it("matches generated temporary worktree branches", () => { + it("matches generated temporary worktree refs", () => { expect(isTemporaryWorktreeBranch(`${WORKTREE_BRANCH_PREFIX}/deadbeef`)).toBe(true); expect(isTemporaryWorktreeBranch(` ${WORKTREE_BRANCH_PREFIX}/deadbeef `)).toBe(true); expect(isTemporaryWorktreeBranch(`${WORKTREE_BRANCH_PREFIX}/DEADBEEF`)).toBe(true); }); - it("rejects non-temporary branch names", () => { + it("rejects non-temporary refName names", () => { expect(isTemporaryWorktreeBranch(`${WORKTREE_BRANCH_PREFIX}/feature/demo`)).toBe(false); expect(isTemporaryWorktreeBranch("main")).toBe(false); expect(isTemporaryWorktreeBranch(`${WORKTREE_BRANCH_PREFIX}/deadbeef-extra`)).toBe(false); @@ -73,7 +73,7 @@ describe("isTemporaryWorktreeBranch", () => { describe("applyGitStatusStreamEvent", () => { it("treats a remote-only update as a repository when local state is missing", () => { - const remote: GitStatusRemoteResult = { + const remote: VcsStatusRemoteResult = { hasUpstream: true, aheadCount: 2, behindCount: 1, @@ -82,9 +82,9 @@ describe("applyGitStatusStreamEvent", () => { expect(applyGitStatusStreamEvent(null, { _tag: "remoteUpdated", remote })).toEqual({ isRepo: true, - hasOriginRemote: false, - isDefaultBranch: false, - branch: null, + hasPrimaryRemote: false, + isDefaultRef: false, + refName: null, hasWorkingTreeChanges: false, workingTree: { files: [], insertions: 0, deletions: 0 }, hasUpstream: true, @@ -95,16 +95,16 @@ describe("applyGitStatusStreamEvent", () => { }); it("preserves local-only fields when applying a remote update", () => { - const current: GitStatusResult = { + const current: VcsStatusResult = { isRepo: true, - hostingProvider: { + sourceControlProvider: { kind: "github", name: "GitHub", baseUrl: "https://github.com", }, - hasOriginRemote: true, - isDefaultBranch: false, - branch: "feature/demo", + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "feature/demo", hasWorkingTreeChanges: true, workingTree: { files: [{ path: "src/demo.ts", insertions: 1, deletions: 0 }], @@ -117,7 +117,7 @@ describe("applyGitStatusStreamEvent", () => { pr: null, }; - const remote: GitStatusRemoteResult = { + const remote: VcsStatusRemoteResult = { hasUpstream: true, aheadCount: 2, behindCount: 1, diff --git a/packages/shared/src/git.ts b/packages/shared/src/git.ts index 4570ba9ecb0c9c655160f3dd7d2ede81651305a2..02d9c61d6aaf5caeb01ebef912095697478f967d 100644 --- a/packages/shared/src/git.ts +++ b/packages/shared/src/git.ts @@ -1,19 +1,20 @@ import type { - GitBranch, - GitHostingProvider, - GitStatusLocalResult, - GitStatusRemoteResult, - GitStatusResult, - GitStatusStreamEvent, + VcsRef, + SourceControlProviderInfo, + VcsStatusLocalResult, + VcsStatusRemoteResult, + VcsStatusResult, + VcsStatusStreamEvent, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Random from "effect/Random"; +import { detectSourceControlProviderFromRemoteUrl } from "./sourceControl.ts"; export const WORKTREE_BRANCH_PREFIX = "t3code"; const TEMP_WORKTREE_BRANCH_PATTERN = new RegExp(`^${WORKTREE_BRANCH_PREFIX}\\/[0-9a-f]{8}$`); /** - * Sanitize an arbitrary string into a valid, lowercase git branch fragment. + * Sanitize an arbitrary string into a valid, lowercase git refName fragment. * Strips quotes, collapses separators, limits to 64 chars. */ export function sanitizeBranchFragment(raw: string): string { @@ -35,7 +36,7 @@ export function sanitizeBranchFragment(raw: string): string { } /** - * Sanitize a string into a `feature/…` branch name. + * Sanitize a string into a `feature/…` refName name. * Preserves an existing `feature/` prefix or slash-separated namespace. */ export function sanitizeFeatureBranchName(raw: string): string { @@ -49,8 +50,8 @@ export function sanitizeFeatureBranchName(raw: string): string { const AUTO_FEATURE_BRANCH_FALLBACK = "feature/update"; /** - * Resolve a unique `feature/…` branch name that doesn't collide with - * any existing branch. Appends a numeric suffix when needed. + * Resolve a unique `feature/…` refName name that doesn't collide with + * any existing refName. Appends a numeric suffix when needed. */ export function resolveAutoFeatureBranchName( existingBranchNames: readonly string[], @@ -60,7 +61,7 @@ export function resolveAutoFeatureBranchName( const resolvedBase = sanitizeFeatureBranchName( preferred && preferred.length > 0 ? preferred : AUTO_FEATURE_BRANCH_FALLBACK, ); - const existingNames = new Set(existingBranchNames.map((branch) => branch.toLowerCase())); + const existingNames = new Set(existingBranchNames.map((refName) => refName.toLowerCase())); if (!existingNames.has(resolvedBase)) { return resolvedBase; @@ -90,8 +91,8 @@ export function buildTemporaryWorktreeBranchName(): string { return `${WORKTREE_BRANCH_PREFIX}/${token}`; } -export function isTemporaryWorktreeBranch(branch: string): boolean { - return TEMP_WORKTREE_BRANCH_PATTERN.test(branch.trim().toLowerCase()); +export function isTemporaryWorktreeBranch(refName: string): boolean { + return TEMP_WORKTREE_BRANCH_PATTERN.test(refName.trim().toLowerCase()); } /** @@ -165,98 +166,39 @@ function deriveLocalBranchNameCandidatesFromRemoteRef( } /** - * Hide `origin/*` remote refs when a matching local branch already exists. + * Hide `origin/*` remote refs when a matching local refName already exists. */ export function dedupeRemoteBranchesWithLocalMatches( - branches: ReadonlyArray, -): ReadonlyArray { + refs: ReadonlyArray, +): ReadonlyArray { const localBranchNames = new Set( - branches.filter((branch) => !branch.isRemote).map((branch) => branch.name), + refs.filter((refName) => !refName.isRemote).map((refName) => refName.name), ); - return branches.filter((branch) => { - if (!branch.isRemote) { + return refs.filter((refName) => { + if (!refName.isRemote) { return true; } - if (branch.remoteName !== "origin") { + if (refName.remoteName !== "origin") { return true; } const localBranchCandidates = deriveLocalBranchNameCandidatesFromRemoteRef( - branch.name, - branch.remoteName, + refName.name, + refName.remoteName, ); return !localBranchCandidates.some((candidate) => localBranchNames.has(candidate)); }); } -function parseGitRemoteHost(remoteUrl: string): string | null { - const trimmed = remoteUrl.trim(); - if (trimmed.length === 0) { - return null; - } - - if (trimmed.startsWith("git@")) { - const hostWithPath = trimmed.slice("git@".length); - const separatorIndex = hostWithPath.search(/[:/]/); - if (separatorIndex <= 0) { - return null; - } - return hostWithPath.slice(0, separatorIndex).toLowerCase(); - } - - try { - return new URL(trimmed).hostname.toLowerCase(); - } catch { - return null; - } -} - -function toBaseUrl(host: string): string { - return `https://${host}`; -} - -function isGitHubHost(host: string): boolean { - return host === "github.com" || host.includes("github"); -} - -function isGitLabHost(host: string): boolean { - return host === "gitlab.com" || host.includes("gitlab"); -} - -export function detectGitHostingProviderFromRemoteUrl( +export function detectSourceControlProviderFromGitRemoteUrl( remoteUrl: string, -): GitHostingProvider | null { - const host = parseGitRemoteHost(remoteUrl); - if (!host) { - return null; - } - - if (isGitHubHost(host)) { - return { - kind: "github", - name: host === "github.com" ? "GitHub" : "GitHub Self-Hosted", - baseUrl: toBaseUrl(host), - }; - } - - if (isGitLabHost(host)) { - return { - kind: "gitlab", - name: host === "gitlab.com" ? "GitLab" : "GitLab Self-Hosted", - baseUrl: toBaseUrl(host), - }; - } - - return { - kind: "unknown", - name: host, - baseUrl: toBaseUrl(host), - }; +): SourceControlProviderInfo | null { + return detectSourceControlProviderFromRemoteUrl(remoteUrl); } -const EMPTY_GIT_STATUS_REMOTE: GitStatusRemoteResult = { +const EMPTY_GIT_STATUS_REMOTE: VcsStatusRemoteResult = { hasUpstream: false, aheadCount: 0, behindCount: 0, @@ -264,16 +206,16 @@ const EMPTY_GIT_STATUS_REMOTE: GitStatusRemoteResult = { }; export function mergeGitStatusParts( - local: GitStatusLocalResult, - remote: GitStatusRemoteResult | null, -): GitStatusResult { + local: VcsStatusLocalResult, + remote: VcsStatusRemoteResult | null, +): VcsStatusResult { return { ...local, ...(remote ?? EMPTY_GIT_STATUS_REMOTE), }; } -function toRemoteStatusPart(status: GitStatusResult): GitStatusRemoteResult { +function toRemoteStatusPart(status: VcsStatusResult): VcsStatusRemoteResult { return { hasUpstream: status.hasUpstream, aheadCount: status.aheadCount, @@ -282,22 +224,24 @@ function toRemoteStatusPart(status: GitStatusResult): GitStatusRemoteResult { }; } -function toLocalStatusPart(status: GitStatusResult): GitStatusLocalResult { +function toLocalStatusPart(status: VcsStatusResult): VcsStatusLocalResult { return { isRepo: status.isRepo, - ...(status.hostingProvider ? { hostingProvider: status.hostingProvider } : {}), - hasOriginRemote: status.hasOriginRemote, - isDefaultBranch: status.isDefaultBranch, - branch: status.branch, + ...(status.sourceControlProvider + ? { sourceControlProvider: status.sourceControlProvider } + : {}), + hasPrimaryRemote: status.hasPrimaryRemote, + isDefaultRef: status.isDefaultRef, + refName: status.refName, hasWorkingTreeChanges: status.hasWorkingTreeChanges, workingTree: status.workingTree, }; } export function applyGitStatusStreamEvent( - current: GitStatusResult | null, - event: GitStatusStreamEvent, -): GitStatusResult { + current: VcsStatusResult | null, + event: VcsStatusStreamEvent, +): VcsStatusResult { switch (event._tag) { case "snapshot": return mergeGitStatusParts(event.local, event.remote); @@ -308,9 +252,9 @@ export function applyGitStatusStreamEvent( return mergeGitStatusParts( { isRepo: true, - hasOriginRemote: false, - isDefaultBranch: false, - branch: null, + hasPrimaryRemote: false, + isDefaultRef: false, + refName: null, hasWorkingTreeChanges: false, workingTree: { files: [], insertions: 0, deletions: 0 }, }, diff --git a/packages/shared/src/sourceControl.test.ts b/packages/shared/src/sourceControl.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..697e65f09149b54a64b8301eca08927599c30fd2 --- /dev/null +++ b/packages/shared/src/sourceControl.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; + +import { + detectSourceControlProviderFromRemoteUrl, + getChangeRequestTerminologyForKind, + resolveChangeRequestPresentation, +} from "./sourceControl.ts"; + +describe("source control presentation", () => { + it("uses merge request terminology for GitLab", () => { + expect(getChangeRequestTerminologyForKind("gitlab")).toEqual({ + shortLabel: "MR", + singular: "merge request", + }); + }); + + it("uses pull request terminology for GitHub-compatible providers", () => { + expect(getChangeRequestTerminologyForKind("github")).toEqual({ + shortLabel: "PR", + singular: "pull request", + }); + expect(getChangeRequestTerminologyForKind("azure-devops")).toEqual({ + shortLabel: "PR", + singular: "pull request", + }); + expect(getChangeRequestTerminologyForKind("bitbucket")).toEqual({ + shortLabel: "PR", + singular: "pull request", + }); + }); + + it("falls back to generic change request copy for unknown providers", () => { + expect( + resolveChangeRequestPresentation({ kind: "unknown", name: "forge", baseUrl: "" }), + ).toEqual( + expect.objectContaining({ + shortName: "change request", + longName: "change request", + }), + ); + }); +}); + +describe("detectSourceControlProviderFromRemoteUrl", () => { + it("detects common source control hosts", () => { + expect(detectSourceControlProviderFromRemoteUrl("git@github.com:owner/repo.git")?.kind).toBe( + "github", + ); + expect( + detectSourceControlProviderFromRemoteUrl("https://gitlab.com/group/repo.git")?.kind, + ).toBe("gitlab"); + expect( + detectSourceControlProviderFromRemoteUrl("https://dev.azure.com/org/project/_git/repo")?.kind, + ).toBe("azure-devops"); + expect( + detectSourceControlProviderFromRemoteUrl("git@bitbucket.org:workspace/repo.git")?.kind, + ).toBe("bitbucket"); + }); +}); diff --git a/packages/shared/src/sourceControl.ts b/packages/shared/src/sourceControl.ts new file mode 100644 index 0000000000000000000000000000000000000000..59257a3de7f8483fe2d37f68a4de235141ed094e --- /dev/null +++ b/packages/shared/src/sourceControl.ts @@ -0,0 +1,225 @@ +import type { SourceControlProviderInfo, SourceControlProviderKind } from "@t3tools/contracts"; + +export interface ChangeRequestPresentation { + readonly icon: "github" | "gitlab" | "azure-devops" | "bitbucket" | "change-request"; + readonly providerName: string; + readonly shortName: string; + readonly longName: string; + readonly pluralLongName: string; + readonly providerLongName: string; + readonly checkoutCommandExample: string; + readonly urlExample: string; +} + +export interface ChangeRequestTerminology { + readonly shortLabel: string; + readonly singular: string; +} + +export const DEFAULT_CHANGE_REQUEST_TERMINOLOGY: ChangeRequestTerminology = { + shortLabel: "PR", + singular: "pull request", +}; + +const GITHUB_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { + icon: "github", + providerName: "GitHub", + shortName: "PR", + longName: "pull request", + pluralLongName: "pull requests", + providerLongName: "GitHub pull request", + checkoutCommandExample: "gh pr checkout 123", + urlExample: "https://github.com/owner/repo/pull/42", +}; + +const GITLAB_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { + icon: "gitlab", + providerName: "GitLab", + shortName: "MR", + longName: "merge request", + pluralLongName: "merge requests", + providerLongName: "GitLab merge request", + checkoutCommandExample: "glab mr checkout 123", + urlExample: "https://gitlab.com/group/project/-/merge_requests/42", +}; + +const AZURE_DEVOPS_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { + icon: "azure-devops", + providerName: "Azure DevOps", + shortName: "PR", + longName: "pull request", + pluralLongName: "pull requests", + providerLongName: "Azure DevOps pull request", + checkoutCommandExample: "az repos pr checkout --id 123", + urlExample: "https://dev.azure.com/org/project/_git/repo/pullrequest/42", +}; + +const BITBUCKET_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { + icon: "bitbucket", + providerName: "Bitbucket", + shortName: "PR", + longName: "pull request", + pluralLongName: "pull requests", + providerLongName: "Bitbucket pull request", + checkoutCommandExample: "bb pr checkout 123", + urlExample: "https://bitbucket.org/workspace/repo/pull-requests/42", +}; + +const GENERIC_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { + icon: "change-request", + providerName: "source control", + shortName: "change request", + longName: "change request", + pluralLongName: "change requests", + providerLongName: "change request", + checkoutCommandExample: "123", + urlExample: "#42", +}; + +export function resolveChangeRequestPresentation( + provider: SourceControlProviderInfo | null | undefined, +): ChangeRequestPresentation { + switch (provider?.kind) { + case "github": + case undefined: + return GITHUB_CHANGE_REQUEST_PRESENTATION; + case "gitlab": + return GITLAB_CHANGE_REQUEST_PRESENTATION; + case "azure-devops": + return AZURE_DEVOPS_CHANGE_REQUEST_PRESENTATION; + case "bitbucket": + return BITBUCKET_CHANGE_REQUEST_PRESENTATION; + case "unknown": + return GENERIC_CHANGE_REQUEST_PRESENTATION; + } +} + +export function resolveChangeRequestPresentationForKind( + kind: SourceControlProviderKind, +): ChangeRequestPresentation { + return resolveChangeRequestPresentation({ kind, name: "", baseUrl: "" }); +} + +export function formatChangeRequestAction( + verb: "View" | "Create", + presentation: ChangeRequestPresentation, +): string { + return `${verb} ${presentation.shortName}`; +} + +export function formatCreateChangeRequestPhrase(presentation: ChangeRequestPresentation): string { + return `create ${presentation.shortName}`; +} + +export function getChangeRequestTerminology( + provider: SourceControlProviderInfo | null | undefined, +): ChangeRequestTerminology { + if (!provider) { + return DEFAULT_CHANGE_REQUEST_TERMINOLOGY; + } + + const presentation = resolveChangeRequestPresentation(provider); + return { + shortLabel: presentation.shortName, + singular: presentation.longName, + }; +} + +export function getChangeRequestTerminologyForKind( + kind: SourceControlProviderKind, +): ChangeRequestTerminology { + const presentation = resolveChangeRequestPresentationForKind(kind); + return { + shortLabel: presentation.shortName, + singular: presentation.longName, + }; +} + +function parseRemoteHost(remoteUrl: string): string | null { + const trimmed = remoteUrl.trim(); + if (trimmed.length === 0) { + return null; + } + + if (trimmed.startsWith("git@")) { + const hostWithPath = trimmed.slice("git@".length); + const separatorIndex = hostWithPath.search(/[:/]/); + if (separatorIndex <= 0) { + return null; + } + return hostWithPath.slice(0, separatorIndex).toLowerCase(); + } + + try { + return new URL(trimmed).hostname.toLowerCase(); + } catch { + return null; + } +} + +function toBaseUrl(host: string): string { + return `https://${host}`; +} + +function isGitHubHost(host: string): boolean { + return host === "github.com" || host.includes("github"); +} + +function isGitLabHost(host: string): boolean { + return host === "gitlab.com" || host.includes("gitlab"); +} + +function isAzureDevOpsHost(host: string): boolean { + return host === "dev.azure.com" || host.endsWith(".visualstudio.com"); +} + +function isBitbucketHost(host: string): boolean { + return host === "bitbucket.org" || host.includes("bitbucket"); +} + +export function detectSourceControlProviderFromRemoteUrl( + remoteUrl: string, +): SourceControlProviderInfo | null { + const host = parseRemoteHost(remoteUrl); + if (!host) { + return null; + } + + if (isGitHubHost(host)) { + return { + kind: "github", + name: host === "github.com" ? "GitHub" : "GitHub Self-Hosted", + baseUrl: toBaseUrl(host), + }; + } + + if (isGitLabHost(host)) { + return { + kind: "gitlab", + name: host === "gitlab.com" ? "GitLab" : "GitLab Self-Hosted", + baseUrl: toBaseUrl(host), + }; + } + + if (isAzureDevOpsHost(host)) { + return { + kind: "azure-devops", + name: "Azure DevOps", + baseUrl: toBaseUrl(host), + }; + } + + if (isBitbucketHost(host)) { + return { + kind: "bitbucket", + name: host === "bitbucket.org" ? "Bitbucket" : "Bitbucket Self-Hosted", + baseUrl: toBaseUrl(host), + }; + } + + return { + kind: "unknown", + name: host, + baseUrl: toBaseUrl(host), + }; +}